Skip to content

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 序列里的事件,并通过接收到的事件来检查测试的结果。

  • mockResponseEventRecorded类型,也是来自 RxTest,用于模拟事件的发送,例如模拟成功接收到网络数据事件或者错误事件。

所需的变量定义完毕以后,可以在beforeEach()方法里面初始化testSchedulertestObserver,具体代码如下:

swift
beforeEach {
    testScheduler = TestScheduler(initialClock: 0)
    testObserver = testScheduler.createObserver(MomentsDetails.self)
}

因为初始化操作都在beforeEach()方法里面,所以每个测试案例执行前都会重新初始化这两个变量。

初始化完毕后,我们就可以测试GetMomentsByUserIDSessiongetMoments()方法了,具体代码如下:

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的实现。也就是说,哪怕我们替换了PersistentDataStoreTypeGetMomentsByUserIDSessionType的实现,在不修改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 类型来遵循PersistentDataStoreTypeGetMomentsByUserIDSessionType协议,这些 Mock 类型只是把传递的参数保存在属性中,并不进行具体的操作,例如不会读写本地数据库和访问网络。

有了这些 Mock 类型以后,我们就可以把它们注入测试对象testSubject中,具体代码如下:

swift
beforeEach {
    mockUserDefaultsPersistentDataStore = MockUserDefaultsPersistentDataStore()
    mockGetMomentsByUserIDSession = MockGetMomentsByUserIDSession()
    testSubject = MomentsRepo(persistentDataStore: mockUserDefaultsPersistentDataStore, getMomentsByUserIDSession: mockGetMomentsByUserIDSession)
}

在上一讲的思考题中我提问过:为什么保存依赖的属性都是通过init()方法来注入,而不是在内部进行初始化?一个重要的原因是我们可以在执行单元测试时把 Mock 类型注入进来 。例如在生产代码中,我们为MomentsRepopersistentDataStore属性注入UserDefaultsPersistentDataStore.shared来访问 iOS 系统的 UserDefaults。UserDefaults 上的数据在程序退出以后还会保留,而单元测试的案例是无状态的,因此所有测试都不应该读写 UserDefaults 上的数据。我们可以在测试代码中,通过注入MockUserDefaultsPersistentDataStore的对象来避免访问 UserDefaults。

依赖注入是面向抽象编程中一种有效的实践方式,不但方便我们编写测试代码,使得测试不依赖于任何的具体环境,同时还能帮我们很容易地替换某个模块的具体实现,例如,当我们决定使用 CoreData 来替换 UserDefaults 作为本地数据存储时,只需在生产代码中注入CoreDataPersistentDataStore.shared即可。

有了 Mock 类型以后,我们看一下如何测试MomentsRepomomentsDetails属性,其中 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>)方法来接收事件,并提供了lastElementlastErrorisCompleted属性来检查最后一条事件的类型。有了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 类型mockUserDefaultsPersistentDataStoremomentsDetails属性发出一个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(())
}

在该方法实现中,我们会调用getMomentsByUserIDSessiongetMoments(userID:)来读取网络数据,并调用persistentDataStoresave(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")))
        }
    }
}

因为所有的转换逻辑都封装在UserProfileListItemViewModelinit(userDetails:)方法里面,所以我们可以通过测试该init()方法来验证数据转换的逻辑。上面的例子中,我们把预先准备好的 Model 数据TestFixture.userDetails传递给UserProfileListItemViewModel来初始化testSubject,然后在it("should initialize the properties correctly")方法里检验各个属性的转换结果,例如name等于 "Jake Lin",而avatarURLbackgroundImageURL都正确地从字符串转换成 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