Appearance
23TDD与单元测试:如何保证功能模块的高质量?
如果一个 App 有很多的 Bug 或者崩溃率非常高,我们往往就需要花大量的时间和精力去不断查错和"救火"。那怎样才能提高代码的质量,让我们可以把有效的时间专注于产品功能的迭代上呢?经过多年实践经验的总结,我们发现测试驱动开发,也叫作 TDD(Test-Driven Development),是一种行而有效的方法实践。
TDD 的核心是编写单元测试。单元测试能方便我们模拟不同的测试场景,覆盖不同的边界条件,从而提高代码的质量并减少 Bug 的数量。同时,使用 TDD 所开发的代码能降低模块间的耦合度,提高模块的灵活性和可扩展性。
下面我们以 Moments App 作为例子来看看如何通过编写单元测试来进行 TDD。这里主要讲述测试代码的步骤与结构,以及如何为网络层、Repository 层和 ViewModel 层编写单元测试。
测试代码的步骤与结构
在编写测试代码时候,我们一般遵守 AAA 步骤,所谓AAA 就是 Arrange、Act 和 Assert。
Arrange:用于搭建测试案例,例如,初始化测试对象及其依赖。
Act:表示执行测试,例如,调用测试对象的方法。
Assert:用于检验测试的结果。
那怎样才能按照 AAA 步骤来编写测试代码呢?为了简化编写测试的工作,并提高代码的结构性与可读性,我们在 Moments App 中使用了 Quick 和 Nimble 库。下面我以MomentsTimelineViewModelTests
为例子给你讲述一种实用的测试代码结构:
swift
final class MomentsTimelineViewModelTests: QuickSpec {
override func spec() {
describe("MomentsTimelineViewModel") {
var testSubject: MomentsTimelineViewModel!
beforeEach {
testSubject = MomentsTimelineViewModel() // Arrange
}
context("loadItems()") {
beforeEach {
testSubject.loadItems() // Act
}
it("call `momentsRepo.getMoments` with the correct parameters") {
expect(mockMomentsRepo.getMomentsHasBeenCalled).to(beTrue()) // Assert
}
it("check another assertion") { }
}
context("anotherMethod()") { }
}
}
}
首先是测试类的定义 。我们定义了一个继承于QuickSpec
的测试类,测试类通常以<需要测试的类型>Tests
的规范来命名。在上面的例子中,我们需要测试MomentsTimelineViewModel
,因此把测试类定义为MomentsTimelineViewModelTests
。然后在类里面重写spec()
方法来封装所有测试案例。接着在spec()
方法里面通过describe()
方法来进行分组,我的做法是一个测试类型只有一个describe()
方法,并把要测试类型的名称传递给该方法,在例子中就写成describe("MomentsTimelineViewModel")
,这样能保证在批量执行测试案例时可以快速定位出错的测试类。
接下来是执行 AAA 中的 Arrange 步骤来搭建测试案例所需的对象 。在describe()
方法里,我们先定义一个名叫testSubject
的测试对象,它的类型为需要测试的类型,在我们的例子中,testSubject
的类型是MomentsTimelineViewModel!
。你可能注意到,我们定义testSubject
时使用了!
来表示该对象不会为nil
。不过,这里需要提醒一下,在生产代码中,我们绝对不使用!
来定义属性,因为一旦该属性为nil
时,调用该属性的方法就会导致程序崩溃。那为什么在测试代码中反而使用!
呢?因为我们希望在执行每一个测试案例之前都重新生成一个新的testSubject
对象来保证每个案例都是无状态的,所以我们需要把初始化操作放到beforeEach()
方法里面,如果testSubject
不是定义为!
,就会有编译错误。
然后是执行 AAA 里面的 Act 步骤 。测试类型中的每一个公共的方法和属性都需要测试,因此,我们需要把它们的测试案例进行分组。为此,我会使用到context()
方法。例如,当我们要测试loadItems()
方法时,就把方法名字传递给context()
方法,写成context("loadItems()")
,并在该 context 下的beforeEach()
方法里调用测试方法loadItems()
,这样就执行 Act 步骤了。
最后看一下如何执行 AAA 里面的 Assert 步骤 。我们可以通过it()
方法来检验每个测试案例的执行结果。为了使得测试更加容易读,我通常把测试的预期行为都写在it()
方法里面,示例中的it("call momentsRepo.getMoments()with the correct parameters")
表示当我们调用loadItems()
方法时就必须调用momentsRepo
属性的getMoments()
函数。
至此,编写单元测试代码的步骤与框架就讲完了。下面我们再结合真实的例子来看看如何为网络层、Repository 层和 ViewModel 层编写测试代码。
网络层的测试
我们以GetMomentsByUserIDSessionTests
为例子看看如何为网络层编写单元测试的代码。因为我们使用了 RxSwift,在测试的时候可以引用RxTest 库来简化测试的流程。
首先,我们在describe("GetMomentsByUserIDSession")
函数里定义需要初始化的变量,代码如下:
swift
var testSubject: GetMomentsByUserIDSession!
var testScheduler: TestScheduler!
var testObserver: TestableObserver<MomentsDetails>!
var mockResponseEvent: Recorded<Event<GetMomentsByUserIDSession.Response>>!
testSubject
是测试的对象,在这个例子中是我们需要测试的GetMomentsByUserIDSession
。testScheduler
的类型是来自 RxTest 的TestScheduler
,是一个用于测试的排程器。testObserver
的类型是 RxTest 的TestableObserver
,用来订阅 Observable 序列里的事件,并通过接收到的事件来检查测试的结果。mockResponseEvent
是Recorded
类型,也是来自 RxTest,用于模拟事件的发送,例如模拟成功接收到网络数据事件或者错误事件。
所需的变量定义完毕以后,可以在beforeEach()
方法里面初始化testScheduler
和testObserver
,具体代码如下:
swift
beforeEach {
testScheduler = TestScheduler(initialClock: 0)
testObserver = testScheduler.createObserver(MomentsDetails.self)
}
因为初始化操作都在beforeEach()
方法里面,所以每个测试案例执行前都会重新初始化这两个变量。
初始化完毕后,我们就可以测试GetMomentsByUserIDSession
的getMoments()
方法了,具体代码如下:
swift
context("getMoments(userID:)") {
context("when response status code 200 with valid response") {
beforeEach {
mockResponseEvent = .next(100, TestData.successResponse)
getMoments(mockEvent: mockResponseEvent)
}
}
}
我们使用context("getMoments(userID:)")
把getMoments(userID:)
所有的测试案例都组织在一起。先看成功的测试案例,该案例封装在context("when response status code 200 with valid response")
函数里面,表示网络成功返回有效数据时的情况。在beforeEach()
方法里做了两件事情,第一件执行 Arrange 步骤,让mockResponseEvent
发出一个.next
事件,该事件里面包含了类型为GetMomentsByUserIDSession.Response
的数据对象successResponse
。下面是这个数据对象的定义:
swift
private struct TestData {
static let successResponse: GetMomentsByUserIDSession.Response = {
let response = try! JSONDecoder().decode(GetMomentsByUserIDSession.Response.self,
from: TestData.successjson.data(using: .utf8)!)
return response
}()
static let successjson = """
{
"data": { ... } // JSON 数据
}
"""
}
代码中的私有结构体TestData
用于配置测试数据,它提供了一个名叫successResponse
的静态属性来返回类型为GetMomentsByUserIDSession.Response
的测试数据。在这个属性里,我们使用了JSONDecoder().decode()
方法来解码 JSON 字符串。在开发的过程,我们可以从 BFF 的返回值中拷贝该 JSON 字符串。通过这个测试案例,我们可以快速地测试GetMomentsByUserIDSession
结构体的映射是否正确。这种做法比通过修改后台来返回测试数据要方便很多。
beforeEach()
方法里第二件事情是执行 Act 步骤,可以通过调用getMoments(mockEvent: mockResponseEvent)
方法来完成这一任务。该方法是一个私有方法,其定义如下:
swift
func getMoments(mockEvent: Recorded<Event<GetMomentsByUserIDSession.Response>>) {
let testableObservable = testScheduler.createHotObservable([mockEvent])
testSubject = GetMomentsByUserIDSession { _ in testableObservable.asObservable() }
testSubject.getMoments(userID: "0").subscribe(testObserver).disposed(by: disposeBag)
testScheduler.start()
}
首先我们把模拟数据传递给testScheduler.createHotObservable()
方法来生成一个新的testableObservable
对象,然后把该对象注入GetMomentsByUserIDSession
的初始化方法里并生成新的测试对象testSubject
,接着调用测试对象的getMoments(userID:)
方法,最后通过调用testScheduler.start()
方法来启动排程器,模拟一个异步网络请求的过程。
执行完 Act 步骤以后,我们还需要执行 Assert 步骤来检验测试的结果,验证的代码都放在it("should complete and map the response correctly")
方法里面,如下所示:
swift
it("should complete and map the response correctly") {
let expectedMomentsDetails = TestFixture.momentsDetails
let actualMomentsDetails = testObserver.events.first!.value.element!
expect(actualMomentsDetails).toEventually(equal(expectedMomentsDetails))
}
我们从testObserver
里取出它接收到的第一个事件,然后调用expect()
方法来比较实际数据和预期数据。因为网络的数据是异步返回的,所以我们在比较过程时使用了toEventually()
方法,该方法会等待结果返回以后才进行比较。
成功案例已经测试完毕,接下来我们看一个失败的案例。下面的代码模拟了网络访问失败的情况:
swift
context("when response status code non-200") {
let networkError: APISessionError = .networkError(error: MockError(), statusCode: 500)
beforeEach {
mockResponseEvent = .error(100, networkError, GetMomentsByUserIDSession.Response.self)
getMoments(mockEvent: mockResponseEvent)
}
it("should throw a network error") {
let actualError = testObserver.events.first!.value.error as! APISessionError
expect(actualError).toEventually(equal(networkError))
}
}
你可以看到,测试代码的结构和成功案例是一致的,不同的地方是我们让mockResponseEvent
返回一个错误的事件,在检验的过程中,我们从testObserver
取出error
来进行对比,而不是element
。
网络测试的代码就讲到这里,你可以打开拉勾教育网的代码仓库来查看更多网络层的测试案例。
Repository 层的测试
下面我们以MomentsRepoTests
为例子看一下如何测试 Repository 层。
MomentsRepoTests
用于测试MomentsRepo
。我们在《19 | 数据层架构:如何使用仓库模式设计数据存储层?》中描述过,MomentsRepo
依赖了PersistentDataStoreType
来读取本地数据,并且依赖了GetMomentsByUserIDSessionType
从 BFF 读取朋友圈信息。那我们测试MomentsRepo
的时候是不是也一同测试两个类型的实现呢?答案是否定的,因为所谓单元测试就是只单独测试某个类型的具体实现,而不测试它的依赖类型 。回到MomentsRepoTests
的例子,它仅仅测试MomentsRepo
的实现。也就是说,哪怕我们替换了PersistentDataStoreType
和GetMomentsByUserIDSessionType
的实现,在不修改MomentsRepoTests
的情况下,所有测试案例都必须通过验证。
那怎样才能使得MomentsRepoTests
只测试MomentsRepo
的实现,而不测试其他任何的依赖类型呢?我们可以通过 Mock 类型来达到这一目的。下面是 Mock 类型的示例代码:
swift
private class MockUserDefaultsPersistentDataStore: PersistentDataStoreType {
private(set) var momentsDetails: ReplaySubject<MomentsDetails> = .create(bufferSize: 1)
private(set) var savedMomentsDetails: MomentsDetails?
func save(momentsDetails: MomentsDetails) {
savedMomentsDetails = momentsDetails
}
}
private class MockGetMomentsByUserIDSession: GetMomentsByUserIDSessionType {
private(set) var getMomentsHasbeenCalled = false
private(set) var passedUserID: String = ""
func getMoments(userID: String) -> Observable<MomentsDetails> {
passedUserID = userID
getMomentsHasbeenCalled = true
return Observable.just(TestFixture.momentsDetails)
}
}
我们分别定义了两个 Mock 类型来遵循PersistentDataStoreType
和GetMomentsByUserIDSessionType
协议,这些 Mock 类型只是把传递的参数保存在属性中,并不进行具体的操作,例如不会读写本地数据库和访问网络。
有了这些 Mock 类型以后,我们就可以把它们注入测试对象testSubject
中,具体代码如下:
swift
beforeEach {
mockUserDefaultsPersistentDataStore = MockUserDefaultsPersistentDataStore()
mockGetMomentsByUserIDSession = MockGetMomentsByUserIDSession()
testSubject = MomentsRepo(persistentDataStore: mockUserDefaultsPersistentDataStore, getMomentsByUserIDSession: mockGetMomentsByUserIDSession)
}
在上一讲的思考题中我提问过:为什么保存依赖的属性都是通过init()
方法来注入,而不是在内部进行初始化?一个重要的原因是我们可以在执行单元测试时把 Mock 类型注入进来 。例如在生产代码中,我们为MomentsRepo
的persistentDataStore
属性注入UserDefaultsPersistentDataStore.shared
来访问 iOS 系统的 UserDefaults。UserDefaults 上的数据在程序退出以后还会保留,而单元测试的案例是无状态的,因此所有测试都不应该读写 UserDefaults 上的数据。我们可以在测试代码中,通过注入MockUserDefaultsPersistentDataStore
的对象来避免访问 UserDefaults。
依赖注入是面向抽象编程中一种有效的实践方式,不但方便我们编写测试代码,使得测试不依赖于任何的具体环境,同时还能帮我们很容易地替换某个模块的具体实现,例如,当我们决定使用 CoreData 来替换 UserDefaults 作为本地数据存储时,只需在生产代码中注入CoreDataPersistentDataStore.shared
即可。
有了 Mock 类型以后,我们看一下如何测试MomentsRepo
的momentsDetails
属性,其中 Arrange 和 Act 步骤的代码如下:
swift
context("momentsDetails") {
var testObserver: TestObserver<MomentsDetails>!
beforeEach {
testObserver = TestObserver<MomentsDetails>() // Arrange
testSubject.momentsDetails.subscribe(testObserver).disposed(by: disposeBag) // Act
}
}
首先,初始化了一个TestObserver
对象来帮助测试 RxSwift 的代码。TestObserver
是我们自定义的一个类,定义如下:
swift
class TestObserver<ElementType>: ObserverType {
private var lastEvent: Event<ElementType>?
var lastElement: ElementType? {
return lastEvent?.element
}
var lastError: Error? {
return lastEvent?.error
}
var isCompleted: Bool {
return lastEvent?.isCompleted ?? false
}
func on(_ event: Event<ElementType>) {
lastEvent = event
}
}
TestObserver
定义了on(_ event: Event<ElementType>)
方法来接收事件,并提供了lastElement
、lastError
和isCompleted
属性来检查最后一条事件的类型。有了testObserver
对象,我们可以把它传递给subscribe()
方法来订阅momentsDetails
属性的事件,然后通过它来验证 RxSwift 代码的测试结果,下面是 Assert 步骤的代码:
swift
it("should be `nil` by default") {
expect(testObserver.lastElement).to(beNil()) // Assert
}
context("when persistentDataStore has new data") {
beforeEach {
mockUserDefaultsPersistentDataStore.momentsDetails.onNext(TestFixture.momentsDetails)
}
it("should notify a next event with the new data") {
expect(testObserver.lastElement).toEventually(equal(TestFixture.momentsDetails)) // Assert
}
}
在开始的时候,testObserver
不应该接收到任何事件,所以它的lastElement
属性返回nil
。当我们往 Mock 类型mockUserDefaultsPersistentDataStore
的momentsDetails
属性发出一个next
事件后,testObserver
会接收到该事件,我们可以调用toEventually()
方法来进行检查。
下面我们再看看getMoments(userID:)
方法的测试。具体代码如下:
swift
context("getMoments(userID:)") {
beforeEach {
testSubject.getMoments(userID: "1").subscribe().disposed(by: disposeBag)
}
it("should call `GetMomentsByUserIDSessionType.getMoments`") {
expect(mockGetMomentsByUserIDSession.getMomentsHasbeenCalled).to(beTrue())
expect(mockGetMomentsByUserIDSession.passedUserID).to(be("1"))
}
it("should save a `MomentsDetails` object") {
expect(mockUserDefaultsPersistentDataStore.savedMomentsDetails).to(equal(TestFixture.momentsDetails))
}
}
我们在beforeEach()
方法中执行了 Act 步骤来调用getMoments(userID:)
方法,这里首先复习一下《第 19 讲| 数据层架构:如何使用仓库模式设计数据存储层?》里讲过的MomentsRepo
方法的实现。
swift
func getMoments(userID: String) -> Observable<Void> {
return getMomentsByUserIDSession
.getMoments(userID: userID)
.do(onNext: { persistentDataStore.save(momentsDetails: $0) })
.map { _ in () }
.catchErrorJustReturn(())
}
在该方法实现中,我们会调用getMomentsByUserIDSession
的getMoments(userID:)
来读取网络数据,并调用persistentDataStore
的save(momentsDetails:)
方法把网络返回结果保存到本地数据库中。在测试过程中,我们已经为这两个依赖项分别注入了不同的 Mock 对象,因此在检验结果的时候,我们可以通过比较 Mock 对象的属性就能验证测试是否正确执行。例如,我们检查mockGetMomentsByUserIDSession.passedUserID
来验证getMomentsByUserIDSession
的执行结果,然后检查mockUserDefaultsPersistentDataStore.savedMomentsDetails
来验证persistentDataStore
的执行结果。
ViewModel 层的测试
完成 Repository 层的测试以后,我们再一起看看如何测试 ViewModel 层的代码。
朋友圈功能的 ViewModel 层由三个 ViewModel 类型所组成,其中MomentsTimelineViewModel
类型的测试方式与MomentsRepo
是一样的,都是通过注入 Mock 类型类来测试 RxSwift 返回的结果。你可以在拉勾教育网的代码仓库查看详细的代码实现。
因为UserProfileListItemViewModel
的责任是把 Model 类型的数据转换成 UI 呈现所需的 ViewModel 类型,那么作为其测试类型,UserProfileListItemViewModelTests
的工作就是验证这些数据转换的逻辑是否正确。我们一起看看UserProfileListItemViewModelTests
的实现代码,首先是成功的测试案例,如下所示:
swift
context("init(userDetails:)") {
context("when all data provided") {
beforeEach {
testSubject = UserProfileListItemViewModel(userDetails: TestFixture.userDetails)
}
it("should initialize the properties correctly") {
expect(testSubject.name).to(equal("Jake Lin"))
expect(testSubject.avatarURL).to(equal(URL(string: "https://avatars-url.com")))
expect(testSubject.backgroundImageURL).to(equal(URL(string: "https://background-image-url.com")))
}
}
}
因为所有的转换逻辑都封装在UserProfileListItemViewModel
的init(userDetails:)
方法里面,所以我们可以通过测试该init()
方法来验证数据转换的逻辑。上面的例子中,我们把预先准备好的 Model 数据TestFixture.userDetails
传递给UserProfileListItemViewModel
来初始化testSubject
,然后在it("should initialize the properties correctly")
方法里检验各个属性的转换结果,例如name
等于 "Jake Lin",而avatarURL
和backgroundImageURL
都正确地从字符串转换成 URL 类型。
下面是转换错误时的情况,代码如下:
swift
context("when `userDetails.avatar` is not a valid URL") {
beforeEach {
testSubject = UserProfileListItemViewModel(userDetails: MomentsDetails.UserDetails(id: "1", name: "name", avatar: "this is not a valid URL", backgroundImage: "https://background-image-url.com"))
}
it("`avatarURL` should be nil") {
expect(testSubject.avatarURL).to(beNil())
}
}
当我们把无效的 URL 字符串传递给avatar
属性时,转换后的testSubject.avatarURL
就会变成nil
。
其他转换错误的案例与上面的例子类似,你可以在拉勾教育的代码仓库里进行查看。
到此为止,我们已经讲完如何为 MVVM 架构开发单元测试了。
总结
在这一讲,我们以朋友圈功能作为例子,讲述如何通过 AAA 方法一步步地为网络层、Repository 层和 ViewModel 层编写单元测试。通过 TDD 方式所开发的代码会迫使我们定义良好的接口,并使用依赖注入的方式来管理所有依赖项,因此,通过 TDD 方法所开发的模块都具备强内聚、弱耦合、可扩展等特性。同时,单元测试能帮助我们便捷地模拟不同的测试案例,从而提高代码的质量,减少 Bug 和 App 的崩溃率。希望你在工作中也可以推动 TDD,根据我的经验,编写单元测试所花费的时间远比以后修改 Bug 所需时间要少很多。
现在,"架构与实现"模块我们已经讲述完毕了。在这个模块中,我们讲述了如何使用 BFF 设计跨平台的系统架构,然后分析了如何使用 RxSwift 来设计一套根据数据流自动更新的 MVVM 框架,并详细介绍了 MVVM 每一层的具体实现。
在结束这一模块前,我还想再分享一下我个人对 App 架构与实现的理解,希望对你有所帮助。
苹果公司所提供的 MVC 模式并没有很好地解决 App 架构的问题,iOS 开发社区在探索的过程中形成了多种架构,例如,使用 MVC 加上 Coordinator 的 MVCC 模式,还有 MVP、VIPER 以及文章中讲述的 MVVM 架构等。尽管它们的架构与实现可能不一样,但是它们的目的都是解决臃肿的 MVC 问题。除此之外,它们在设计过程中都遵循一些通用的原则,例如单一责任原则(每一个组件只完成单独的一个功能)和开闭原则(通过抽象的协议来封闭具体的实现,但同时开放对类型的扩展),等等。
基于这些通用的设计原则,并结合多年的经验与教训,我为 Moments App 重新架构和实现了一套基于 RxSwift 的 MVVM 架构 。通过这一模块的学习,想必你已经见识到这套框架的威力了,但并不代表这就是默认的或者标准的 MVVM 实现方式,甚至可以说在软件架构领域根本就没有什么一成不变的标准方案,一套好的方案应该可以根据需求的变化而不断地迭代与改进。
当你想使用这套框架的时候,可以结合自己的项目,遵循通用的设计原则来慢慢改进,例如,把所有的网络层逻辑都封装到一个独立的模块中,其他模块必须通过网络层模块来访问网络,或者把所有数据访问都放到 Repository 模块中,UI 需要访问数据时都通过 Repository 来存取。又例如,当一个模块需要依赖于其他模块时,都在初始化方法中进行依赖注入,这样能方便我们以后替换具体实现,提高架构的可扩展性。
还有一点我想强调一下,写代码是一门手艺活。这个模块的代码实现比较多,我建议你从 GitHub 上把代码下载下来,并对照文章的内容一同学习,然后通过实现新功能来加深理解。任何架构能力都是建立在代码能力之上的,要提高架构能力首先需要不断提高编写高质量代码的能力。 编写高质量代码通常需要灵活运用各种软件设计的原则,当能熟练使用这些原则时,架构 App 也就变成水到渠成的事情了。
思考题
这是架构与实现模块的最后一篇,我建议你在朋友圈时间轴页面里添加分享文章的功能,并编写相关的单元测试。通过这个功能的开发,能让你从头到尾理解整个 MVVM 框架的核心思想。
如果你完成该功能,请提交一个 PR 哦。如果你有什么想法,也可以写到留言区。下一讲我们会讲述"如何统一管理 Certificates 和 Profiles",这就进入下一个模块------上架与优化。
源码地址
单元测试的源码地址:
https://github.com/lagoueduCol/iOS-linyongjian/tree/main/Moments/MomentsTests