如何在Clean-Swift中编写单元测试

第1部分中, 我解释了如何改善应用程序的体系结构,使其更好地进行单元测试。

编写测试应该和编写代码一样有趣,对吗? 但是为什么不呢? 这就是我问自己的问题,我注意到每次尝试编写一些测试时,我都不知道从哪里开始或要测试什么。 而且,如果我有一个主意,结果证明我无法测试或不知道如何测试。

一段时间后,我开始意识到,如果您的代码结构清晰,那么您将知道在代码中放置什么内容,并且可以清楚地测试什么。 在第1部分中,您可以阅读如何根据Clean Swift模式构造代码。 在这一部分中,您可以阅读可以测试的内容以及如何进行测试。

通常,好的测试是灵活的,但并不脆弱,这意味着,如果更改一行代码,则不应破坏很多测试。 您的测试应该广泛,涵盖所有边缘情况并快速运行。 因此,您可以经常运行它们并尽快获得反馈。

单元测试的结构

在测试中,您可以区分要测试的主题输入输出 。 最好确定这三个不同的部分,因为它们可以帮助您弄清楚您需要如何处理它们。 例如,您永远不会存根您的测试主题,但可以存根 。 您将需要尽可能多的输入集测试以测试其结果。 并且在测试的断言中验证输出的值。

单元测试的典型结构是:

  • 给定
  • 什么时候
  • 然后

在“ 给定中,您将创建模拟并准备输入”中,在“执行方法以对主题进行测试时”中, 然后在“使用声明中验证输出”。

不同类型的测试双打

为了为我们的测试创建真正的黑匣子,我们可能希望掩盖实现的某些部分。 我们可以使用所谓的测试双打来做到这一点。

当您必须在测试中传递参数时,但是您知道它永远不会在被测试的方法中使用时,可以传递一个哑元 。 假人返回什么都无所谓。

当您需要一个返回特定值的虚拟对象,因为系统的其余部分依靠它继续运行测试时,该虚拟对象称为stub

如果仅想测试是否调用了方法,则可以使用间谍 。 不利的一面是,您监视的内容越多,您将测试与应用程序实现的耦合就越紧密。

模拟是其中包含断言的间谍。 它检查使用什么参数,何时以及多久调用一次函数。

我们最后的味道是假的 。 到目前为止,所有讨论过的测试倍数都不关心您传入的参数。但是假冒的确如此,因为它具有业务逻辑,可以为不同的输入提供不同的输出。

将单元测试文件添加到您的项目

要将单元测试文件添加到Xcode项目中,可以在新项目开始时检查单元测试,也可以稍后将测试目标添加到现有项目中。 在项目浏览器中单击您的项目 ,添加一个新目标并选择一个单元测试包

在文件顶部,您需要import XCTest并通过添加@testable import [YourTarget]使您的应用程序目标可用于测试。

您的类需要从XCTestCase继承。 已经有一个setUp()方法可以在测试之前进行一些初始化或准备工作,而tearDown()方法可以在测试之后进行一些清理,因此测试不会相互干扰。 在每次测试之前和之后都将调用这些方法。 请记住,单元测试是随机运行的,因此顺序将始终更改。 同样不要忘记首先在这些方法中调用super。

最后,您可以编写实际的测试。 这些被编写为始终需要以单词test开头的方法。

将测试数据放在一个文件中

为了挑战自己,为您的测试数据找到正确的名称并防止重复,我建议将您的测试数据放入一个文件中。 我称之为Seeds ,它看起来像这样:

 结构种子{ 
结构电影{
static let slumdogMillionaire =电影(标题:“ Slumdog
百万富翁”,发布日期:“ 2008-11-11”)
  static let painLocker = Movie(title:“受伤的储物柜”, 
releaseDate:“ 2009–01–29”)

static let kingsSpeech = Movie(title:“国王的演讲”,
releaseDate:“ noDate”)
 静态让所有人= [slumdogMillionaire,hurtLocker, 
kingsSpeech]
}
}

对于不同的测试文件,您可以创建此Seeds结构的扩展名。

测试交互器

测试中的测试对象(列表)ListMoviesInteractor 。 因此, 交互器是从ViewController获取其输入的黑匣子。

测试输入是ListMoviesInteractable协议中定义的方法: func fetchMovies()

测试输出是ListMoviesPresentableMoviesDataProvidable协议中定义的方法:

  func presentFetchedMovies(_电影:[电影]) 
func fetchMovies(completionHandler:@escaping([Movie])-> Void)

我们实际上需要测试是否同时调用了这两个输出。 我们现在不在乎它们内部发生了什么。 我们将在到达那里时进行测试。 因此,我们可以对它们进行双打测试。 为了测试两种方法是否都被调用,我用间谍代替了PresenterWorker 。 此替换仅持续到测试方法结束时,因此,如果我们要进行更多测试,则需要在每次测试开始时再次设置这些测试倍数。

  //标记:-测试翻倍 
  class PresenterSpy:ListMoviesPresentable { 
var presentMoviesCalled = false
var电影:[电影]?
  func presentFetchedMovies(_电影:[电影]){ 
presentMoviesCalled = true
self.movi​​es =电影
}
}
  class MoviesWorkerSpy:MoviesDataProvidable { 
var fetchMoviesCalled = false
各种电影:[电影]
  init(电影:[电影] = []){ 
self.movi​​es =电影
}
 覆盖func fetchMovies(completionHandler:@escaping 
([电影])->虚空){
fetchMoviesCalled = true
completeHandler(电影)
}
}

测试方法应测试一件事,名称应充分描述测试的含义。 因此,我将调用PresenterInteractor的测试分开。 在“ 给定”中,我为工作人员设置了间谍,以检查其方法是否被调用。 然后,我用Presenter-WorkerSpy初始化交互 。 在我调用方法fetchMovies() ,该方法是测试对象的输入: Interactor 。 在然后,我进行断言以检查是否通过Worker调用了Worker中的方法。

  func testFetchMoviesCallsWorkerToFetch(){ 
  //给定 
让movieWorkerSpy = MoviesWorkerSpy()
让sut = ListMoviesInteractor(presenter:
PresenterSpy(),工作者:moviesWorkerSpy)
  // 什么时候 
sut.fetchMovies()
  // 然后 
XCTAssert(moviesWorkerSpy.fetchMoviesCalled,“ fetchMovies()
应该请工人拿电影”)
}

用于检查Presenter是否被Interactor调用的测试类似:

  func testFetchMoviesCallsPresenterToFormatMovies(){ 

//给定
让presenterSpy = PresenterSpy()
让sut = ListMoviesInteractor(presenter:
presenterSpy,工作人员:MoviesWorkerSpy())
  // 什么时候 
sut.fetchMovies()
  // 然后 
XCTAssert(presenterSpy.presentMoviesCalled,
“ fetchMovies()应要求演示者格式化
电影”)
}

您还可以测试是否将正确(数量)的电影移交给了Presenter

  func testFetchMoviesCallsPresenterToFormatFetchedMovies(){ 

//给定
让电影= Seeds.Movies.all
让presenterSpy = PresenterSpy()
让moviesWorkerSpy = MoviesWorkerSpy(电影:电影)
让sut = ListMoviesInteractor(presenter:
presenterSpy,工作者:moviesWorkerSpy)
  // 什么时候 
sut.fetchMovies()
  // 然后 
XCTAssertEqual(presenterSpy.movi​​es?.count,
movie.count,“ fetchMovies()应该要求演示者格式化
所获取的电影数量相同”)
  XCTAssertEqual(presenterSpy.movi​​es,电影, 
“ fetchMovies()应该要求演示者设置相同的格式
取来的电影”)
}

测试演示者

该测试类的设置与Interactor相似,但现在, 被测 主题(sut)ListMoviesPresenter 。 该测试的输入是ListMoviesPresentable协议中的presentFetchedMovies(_ movies: [Movie])方法。 输出是ListMoviesDisplayable协议中定义的方法,在这种情况下为displayFetchedMovies(_ movies: [ListMoviesViewModel])方法。

  //标记:-测试翻倍 
class ViewControllerSpy:ListMoviesDisplayable {
var displayFetchedMoviesCalled = false
var displayMovies:[ListMoviesViewModel] = []
  func displayFetchedMovies(_电影:[ListMoviesViewModel]){ 
displayFetchedMoviesCalled = true
显示电影=电影
}
}
  // MARK:-测试 
func testDisplayFetchedMoviesCalledByPresenter(){
  //给定 
让viewControllerSpy = ViewControllerSpy()
让sut = ListMoviesPresenter(viewController:viewControllerSpy)
  // 什么时候 
sut.presentFetchedMovies([])
  // 然后 
XCTAssert(viewControllerSpy.displayFetchedMoviesCalled,
“ presentFetchedMovies()应该要求视图控制器执行以下操作:
显示它们”)
}

在此测试之后,我们要测试模型中 viewModels的格式是否正确。 我们将在调用presentFetchedMovies(_ movies: [Movie])方法时进行测试,我们将获得与作为参数提供的模型相同数量的viewModel ,并且格式是否正确。

  func testPresentFetchedMoviesShouldFormatFetchedMoviesForDisplay(){ 

//给定
让viewControllerSpy = ViewControllerSpy()
让sut = ListMoviesPresenter(viewController:viewControllerSpy)
让电影= Seeds.Movies.slumdog百万富翁
  // 什么时候 
sut.presentFetchedMovies(电影)
  // 然后 
让displayMovies = viewControllerSpy.displayedMovies

XCTAssertEqual(displayedMovies.count,电影.count,
“ presentFetchedMovies()应该要求视图控制器执行以下操作:
显示与接收的电影数量相同的电影”)

用于displayMovies.enumerated()中的(index,displayMovie)
XCTAssertEqual(displayedMovie.title,“贫民窟的百万富翁”
XCTAssertEqual(displayedMovie.year,“ 2008”)
}
}

测试ViewController

除了参与VIP周期外, ViewController还通过IBOutletsIBActions与用户界面进行交互。 IBActionsViewController的输入,而IBOutlet是输出。 紧接着,当视图加载,出现或消失时,可能需要发生外部事件。 这些视图生命周期事件也是ViewController的输入。

对于我们的ViewController,我们将检查这3种可能需要测试的不同输入:

  1. 查看生命周期方法:
    viewDidLoad() 调用方法fetchMovies()向交互器发出请求。
  2. ListMoviesDisplayable协议中的方法:
    displayFetchedMovies(_ movies: [ListMoviesViewModel])
  3. IBAction方法:无

我们的测试的setUp()方法将通过一些逻辑扩展,以便在每次测试开始之前设置sut并加载视图。

  // MARK:—测试生命周期 
  var sut:ListMoviesViewController! 
var interactorSpy:InteractorSpy!
 覆盖func setUp(){ 
super.setUp()
interactorSpy = InteractorSpy!
sut = ListMoviesViewController(interactor:interactorSpy)
sut.beginAppearanceTransition(true,动画:false)
sut.endAppearanceTransition()
}

现在已经在setUp()方法中完成了给定测试。

  func testShouldFetchMoviesWhenViewDidLoad(){ 

// 什么时候
sut.viewDidLoad()

// 然后
XCTAssert(interactorSpy.fetchMoviesCalled,“应该
加载视图时获取电影”)
}

在第二项测试中,我们要测试是否显示了订单。 这是通过Apple的UITableView中reloadData()方法完成的。 因此,我们需要确保调用此方法。 我们也可以通过创建UITableView的子类来对此进行监视。

  TableViewSpy类:UITableView { 
var reloadDataCalled = false
 覆盖func reloadData(){ 
reloadDataCalled = true
}
}

在测试中,我们将tableView设置为TableViewSpy,并调用应该触发重新加载的displayFetchedMovies(_ movies: [ListMoviesViewModel])方法。

  func testShouldDisplayFetchedMovies(){ 

//给定
让tableViewSpy = TableViewSpy()
sut.tableView = tableViewSpy
让viewModels:[ListMoviesViewModel] = []
  // 什么时候 
sut.displayFetchedMovies(viewModels)
  // 然后 
XCTAssert(tableViewSpy.reloadDataCalled,“显示已获取
电影应重新加载表格视图”)
}

除了这些测试之外,我们还可以编写一些测试来检查节的数量是否返回1,以及tableView的行数是否等于传递回ViewControllerviewModels的数量:

  func testNumberOfRowsInAnySectionShouldEqualNumberOfMoviesToDisplay(){ 
  //给定 
让tableView = sut.tableView
让viewModels:[ListMoviesViewModel] =
[ListMoviesViewModel(title:“ Test”,年份:“ 1988”)]
sut.displayFetchedMovies(viewModels)
  // 什么时候 
让numberOfRows = sut.tableView(tableView !,
numberOfRowsInSection:1)
  // 然后 
XCTAssertEqual(numberOfRows,viewModels.count,“
tableview行应等于要显示的电影数量”)
}

我们要测试的最后一件事是,是否在UITableViewCell的正确视图中正确显示viewModel的所有项目。 因为我们只有一个viewModel ,所以在此示例中,我们仅检查第0部分和第0行。

  func testShouldConfigureTableViewCellToDisplayOrder(){ 
  //给定 
让tableView = sut.tableView
让viewModels:[ListMoviesViewModel] =
[ListMoviesViewModel(标题:“ ET”,年份:“ 1982”)]
sut.displayFetchedMovies(viewModels)
  // 什么时候 
让indexPath = IndexPath(row:0,section:0)
让cell = sut.tableView(tableView !, cellForRowAt:indexPath)
如! ListMoviesTableViewCell
  // 然后 
XCTAssertEqual(cell.titleLabel?.text,“ ET”,“正确
配置的表格视图单元格应显示电影标题”)
XCTAssertEqual(cell.yearLabel?.text,“ 1982”,“正确
配置的表格视图单元格应显示电影年份”)
}

运行测试

要在Xcode中构建测试,您可以转到产品>构建>测试(⇧⌘U),或者运行它们并选择产品>测试(⌘U) 。 在测试导航器 (⌘6)中,您可以查看测试结果。 如果您运行了一次测试,则在测试文件中的每次测试之前,您几乎看不到菱形。 如果您只想重新运行一个测试,则可以单击它前面的图标,将鼠标悬停在该图标上时该图标会更改。 或者,如果您想重新运行一个类的所有测试,则可以单击测试类前面的图标。 在视图>调试区域>显示调试区域中,您可以查看测试日志。 它显示通过和失败的次数,以及运行每个测试和所有测试所需的时间。

如果已经运行测试,则可以转到“ 报表浏览器” (⌘9),单击最后一个测试,然后单击“ 覆盖率” 。 您将看到,除了ViewController的初始化外,所有内容都经过100%测试。 如果看不到任何内容,请转到产品>方案>编辑方案。 然后转到“ 测试”和“选项” 选项卡。 如果在Gather coverage前面打勾,则下次运行测试时可以看到coverage。