如何在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()
测试输出是ListMoviesPresentable和MoviesDataProvidable协议中定义的方法:
func presentFetchedMovies(_电影:[电影])
func fetchMovies(completionHandler:@escaping([Movie])-> Void)
我们实际上需要测试是否同时调用了这两个输出。 我们现在不在乎它们内部发生了什么。 我们将在到达那里时进行测试。 因此,我们可以对它们进行双打测试。 为了测试两种方法是否都被调用,我用间谍代替了Presenter和Worker 。 此替换仅持续到测试方法结束时,因此,如果我们要进行更多测试,则需要在每次测试开始时再次设置这些测试倍数。
//标记:-测试翻倍
class PresenterSpy:ListMoviesPresentable {
var presentMoviesCalled = false
var电影:[电影]?
func presentFetchedMovies(_电影:[电影]){
presentMoviesCalled = true
self.movies =电影
}
}
class MoviesWorkerSpy:MoviesDataProvidable {
var fetchMoviesCalled = false
各种电影:[电影]
init(电影:[电影] = []){
self.movies =电影
}
覆盖func fetchMovies(completionHandler:@escaping
([电影])->虚空){
fetchMoviesCalled = true
completeHandler(电影)
}
}
测试方法应测试一件事,名称应充分描述测试的含义。 因此,我将调用Presenter和Interactor的测试分开。 在“ 给定”中,我为工作人员设置了间谍,以检查其方法是否被调用。 然后,我用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.movies?.count,
movie.count,“ fetchMovies()应该要求演示者格式化
所获取的电影数量相同”)
XCTAssertEqual(presenterSpy.movies,电影,
“ 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还通过IBOutlets和IBActions与用户界面进行交互。 IBActions是ViewController的输入,而IBOutlet是输出。 紧接着,当视图加载,出现或消失时,可能需要发生外部事件。 这些视图生命周期事件也是ViewController的输入。
对于我们的ViewController,我们将检查这3种可能需要测试的不同输入:
- 查看生命周期方法:
在viewDidLoad()
调用方法fetchMovies()
向交互器发出请求。 - ListMoviesDisplayable协议中的方法:
displayFetchedMovies(_ movies: [ListMoviesViewModel])
- 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的行数是否等于传递回ViewController的viewModels的数量:
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。