9eaecb7b9bfb8a806209392dd5642035
WWDC2018: 高效使用单元测试

WWDC2018: 高效使用测试

一、 What's New in Testing
1.代码覆盖率
2.测试选项和顺序
3.并行测试
二、 Tips & Tricks
1.测试网络请求
2.测试通知
3.模拟协议
4.测试速率优化


What's New in Testing

代码覆盖率

Xcode 9.3

在 Xcode 9.3 中,苹果完全重写了代码覆盖率功能,性能和准确性都有很大提升,我们能更细粒度地控制测试的Target,以帮助改进代码覆盖。还推出了一个全新的命令行工具 xccov,同时,在源代码编辑器中,也提供了代码覆盖功能。

性能和准确度

在 Xcode 9.3 中,Xcode 的代码覆盖率功能的性能得到了极大的提升,通过在一个苹果内部的大型项目的数据显示,从 Xcode 9 的 6.5s ,提升到了 Xcode9.3 的 0.3s,性能提升了 95% 。同时代码覆盖文件的大小从 Xcode9 时的 214 MB 也减小到了 18.5 MB。
同时准确度也比以前有了不小的提升,Xcode 9中不能正确收集和显示头文件中的代码覆盖,而在 C++ 代码中,有不少的代码实现是位于头文件中的。现在 Xcode 的代码覆盖对头文件中的实现也能正确收集和显示了。

Target 选项

现在在代码覆盖的选项中,可以选择 Enable for all targets/ Enable for selected targets/Disabled 三种选择,比如项目在使用第三方代码时,而我们不需要测试它们;或者在大型项目中,只测试自己负责的模块时,这选项就很有用。我们可以在 Test 的 Scheme 中,选择 Code Coverage 包含的 Targets 。
Code Coverage

Code Coverage

xccov

xccov 是一个全新的用于输出代码覆盖率的命令行工具,可以很方便地集成在脚本中,它能产生对人和对机器都可读的输出,还能输出覆盖数据的详细视图。

覆盖率数据的原理

启用代码覆盖后,在测试运行时,Xcode 生成两个文件:包含每个 target 和源文件覆盖率和函数的覆盖报告(xccovreport 文件)和包含每个文件的原始执行次数的覆盖归档(xccovarchive 文件),这些覆盖率数据都位于项目的 Derived data 目录下的 Logs/Test 目录。如果在 xcodebuild 脚本中加上 -resultBundlePath 标识,这些文件也会被打进 Result Bundle 中。

xccov 的用法

通过 xccov,我们可以:

  • 在命令行中查看测试覆盖率报告;
  • 从测试覆盖率报告中生成 JSON 文件;
  • 列出测试覆盖率产生的文件;
  • 查看某个指定文件的测试覆盖率。
    普通文本:
$xcrun xccov view Build/Logs/Test/*.xccovreport

普通文本格式输出测试覆盖率

普通文本格式输出测试覆盖率

JSON 格式:

$xcrun xccov view --json Build/Logs/Test/*.xccovreport

JSON 格式输出测试覆盖率

JSON 格式输出测试覆盖率

文件列表:

$ xcrun xccov view --file-list Build/Logs/Test/*.xccovarchive/

显示指定文件的覆盖率:

$ xcrun xccov view --file ~/Desktop/XCCov-Demo/XCCov-Demo/AppDelegate.swift  Build/Logs/Test/*.xccovarchive/

源代码编辑器

我们也可以在源代码编辑器中显示代码覆盖,只需要 Editor->选择Show Code Coverage 就行了。
Source Editor

Source Editor

测试选项和顺序

有时候我们只需要执行部分测试,在 Xcode 10 以前,我们可以通过选择跳过测试来避免每次都执行不必要做的测试,这样做的副作用是,每次添加新测试用例后,都会自动添加到所有的 scheme 中,为此还得手动在每个 scheme 中删除不必要的测试用例。
而在 Xcode 10 中,我们可以选择 scheme 是否自动包含新添加的测试用例。这样根据需求,我们可以自己选择哪些 scheme 需要自动包含新添加的测试用例,哪些不需要自动包含了。

Automatically include new tests

Automatically include new tests

新版 Xcode 还可以指定测试顺序,默认情况下,测试顺序是按名字排序的,这意味着除非重命名测试用例,否则执行测试的顺序永远是固定的。这是把双刃剑,很容易漏掉一些 bug,比如有的情况下一些测试依赖于在它之前执行的其他测试。
比如这样的例子:
test ABC 之间有依赖关系

test ABC 之间有依赖关系

有三个测试,依次为 TestA、Test B、Test C。其中 Test A 创建了一个数据库,Test B 往这个数据库中写入了数据,Test C 删除数据库。这几个测试用例很依赖于执行顺序,如果交换它们的顺序(比如重命名),再次执行时,假如在 Test A 执行前,数据库还没有被创建,就执行了 Test B ,那测试就 fail 了。为了避免这种情况,我们在写测试时需要自己生成和销毁自己的状态,保持测试的独立,而不要在不同测试间共享状态。为了防止这种潜在的依赖关系, Xcode 10 添加了随机测试顺序模式。
随机顺序
随机顺序

并行测试

并行测试是 Xcode 10 中单元测试的一项重要更新。在 Xcode 9 之前的单测中,串行的测试有时会耗时很长。而在单测通过之前,直接 push 代码通常心里会很没底(😰)。而 Xcode 9 增加了并行测试的选项,并行测试可以同时在多个目标(不同平台、模拟器等)上进行测试。我们可以按下面的方式来在不同测试目标上并行测试:

xcodebuild test
    -destination 'platform=iOS,name=iPhone X'
    -destination 'platform=iOS,name=iPad'

这会节省一些时间,但是这个功能也有很多限制:
首先,它只对针对在多个目标上的测试有用;其次,只能通过 xcodebuild 命令来使用,所以主要还是用于 Xcode Server 或 Jenkins 这样的持续集成工具。
而在 Xcode 10 中,新增了并行分发测试,我们可以在同一个测试目标上进行并行测试了,并且不管在 xcodebuild 命令中还是 Xcode 中,都可以使用这个新特性。
那么,Xcode 是如何实现并行测试的呢?这就要从测试时做了些什么事说起了。
首先看看单元测试,单元测试会编译进一个测试 bundle。运行时,Xcode 启动一个 app 的实例作为测试的运行器。运行器加载测试包并且执行其中所有的测试,这就是单元测试的实现了。 那么,UI 测试呢?UI 测试也类似,同样编译进一个包,不同的是测试包是被一个 Xcode 创建的自定义 app 加载的,你的 app 不再直接执行测试,而是自动操作你的 app ,和不同的 UI 组件进行交互。如果你对这个过程想了解更多,可以参考 2016 年的 WWDC session Advanced Testing and Continuous Imntegration
我们知道测试的原理了,就可以聊聊并行测试是怎么实现的了。在并行测试时,Xcode 同样会启动测试运行器,但不止一个。Xcode 会启动多个运行器,其中的每个都会执行一部分测试。
实际上,Xcode 会动态分发测试到这些运行器来最好地利用机器的资源,我们进一步了解一下。
Xcode 是按照类来分发测试的,每个类会收到一个测试类来执行,在执行完这个类之后,会接着执行其他的测试类。直到所有的类都被执行完毕。
也许你会好奇为什么 Xcode 以类为单位分发测试而不是以测试方法为单位,原因如下:首先,在同一个类中的测试可能有潜在的依赖关系,如果 Xcode 把同一个类中的测试分发到不同的运行器,可能导致不可预知的失败;其次,每个测试类都有一个类级的 set up 和 tear down 方法,可能导致过量的运算,把一个类的测试限制在同一个运行器下,这些方法就只需要调用一次,会节省大量宝贵的时间。再来看看在模拟器上进行并行测试的一些例子。在模拟器上执行测试时,Xcode 会将这个模拟器生成一些不同的拷贝,Xcode 会自动管理这些模拟器的生命周期。模拟器拷贝生成后,Xcode 会在每个拷贝上启动一个运行器,然后运行器开始执行测试。

需要注意的是:
首先,原始的模拟器在测试中并没有被使用,而是作为其他模拟器的模板。你可以配置这个原始的模拟器,使得生成的其他模拟器都具有相同的配置。
其次,你的 app 会有多个拷贝,每个拷贝都有自己的数据容器。这意味着如果你的测试类会修改磁盘上的文件,这些修改对其他测试类未必可见因为它可能在完全分离的另一个数据容器中。
实际上,类在不同拷贝上如何执行对你的测试是不可见的,但我们要有这个意识。那么,我们需要在什么情况下使用并行测试?我们可以在 macOS 上并行运行单元测试,也可以在 iOS 和 tvOS 模拟器中运行单元测试和 UI 测试。
我们也可以用命令行来执行并行分发测试,并且可以设置以下参数:

  • -maximum-concurrent-test-device-destinations NUMBER :并行测试的最大目标设备数
  • -maximum-concurrent-test-simulator-destinations NUMBER :并行测试的最大目标模拟器数
  • -parallel-testing-enabled YES|NO :是否重写 scheme 中每个目标的设置
  • -parallel-testing-worker-count NUMBER :并行测试期间生成的测试运行器的确切数量
  • -maximum-parallel-testing-workers NUMBER :并行测试期间将生成的最大测试运行器数

Tips:

  1. 考虑将耗时过长的测试类拆分。因为测试类并行执行,测试永远不会比运行时间最长的类运行得更快。但并不要以为你应该把所有的类都拆开,这没必要,不过如果你觉得这是个性能瓶颈,你也可以尝试一下。
    2.将性能测试放进关闭并行测试的单独的包,因为性能测试对系统活动很敏感,开启并行测试可能无法满足其基线。
    3.了解哪些测试对于并行测试是不安全的。大部分测试并行测试都没问题,但如果测试需要访问共享的系统资源,比如文件或数据库,则可能需要引入显式同步来允许它们并发运行。

测试的 Tips & Tricks

测试网络请求

分解代码使代码可测试

关于如何使代码可测试,我们可以参考 WWDC 2017 的 Session Engineering for Testability,测试应该分为单元测试-集成测试-端到端测试三个阶段。总之,我们应该使应用中的类和方法粒度足够细,满足单一职责原则。这样也能写出粒度足够细的测试方法,在遇到问题时能产生清晰的错误信息,而且运行速度也会很快。

我们可以看一看这段代码:

func loadData(near coord: CLLocationCoordinate2D) {
    let url = URL(string: "/locations?lat=\(coord.latitude)&long=\(coord.longitude)")!
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data else { self.handleError(error); return }
        do {
            let values = try JSONDecoder().decode([PointOfInterest].self, from: data)
            DispatchQueue.main.async {
                self.tableValues = values
                self.tableView.reloadData()
            }
        } catch {
            self.handleError(error)
        }
    }.resume()
}

这段代码把所有行为写在了一起。这个方法获取用户位置作为参数,并使用该参数构造一个用来发起请求的 query 的参数。然后使用 URLSession 的 API 生成一个向那个 URL 发起 get 请求的 data task。然后解压 server 响应的数据,使用 JSONDecoder 的 API 解码,放进一个数组中。然后把这组数组保存到一个属性中,然后驱动 tableView 来刷新界面。
这所有的代码加起来不到15行,很简洁,但是这段代码的可维护性很差,尤其是可测试性,现在我们来改写它。
首先是请求准备和解析响应数据的步骤。
为了使代码易于测试,我们把它抽出 ViewController 并在这个专门的
PointsOfInterestRequest 类中添加两个方法来解耦,这两个方法传入特定的参数并输出转换的结果,不产生任何副作用。

struct PointsOfInterestRequest {
    func makeRequest(from coordinate: CLLocationCoordinate2D) throws -> URLRequest {
        guard CLLocationCoordinate2DIsValid(coordinate) else {
            throw RequestError.invalidCoordinate
        }
        var components = URLComponents(string: "https://example.com/locations")!
        components.queryItems = [
            URLQueryItem(name: "lat", value: "\(coordinate.latitude)"),
            URLQueryItem(name: "long", value: "\(coordinate.longitude)")
        ]
        return URLRequest(url: components.url!)
    }
    func parseResponse(data: Data) throws -> [PointOfInterest] {
        return try JSONDecoder().decode([PointOfInterest].self, from: data)
    }
}

这样写测试就很直截了当了。

class PointOfInterestRequestTests: XCTestCase {
    let request = PointsOfInterestRequest()

    // 测试 request
    func testMakingURLRequest() throws {
        let coordinate = CLLocationCoordinate2D(latitude: 37.3293, longitude: -121.8893)
        let urlRequest = try request.makeRequest(from: coordinate)
        XCTAssertEqual(urlRequest.url?.scheme, "https")
        XCTAssertEqual(urlRequest.url?.host, "example.com")
        XCTAssertEqual(urlRequest.url?.query, "lat=37.3293&long=-121.8893")
    }

   // 测试 response
    func testParsingResponse() throws {
        let jsonData = "[{\"name\":\"My Location\"}]".data(using: .utf8)!
        let response = try request.parseResponse(data: jsonData)
        XCTAssertEqual(response, [PointOfInterest(name: "My Location")])
    }
}

用 URLProtocol 作为 Mock 工具

对于 URLSession 的测试,我们同样把它从 ViewController 中提出来,创建一个 APIRequest 的 protocol 类型和 APIRequestLoader 类。使用一个 request 和一个 urlsession 实例来初始化。

protocol APIRequest {
    associatedtype RequestDataType
    associatedtype ResponseDataType
    func makeRequest(from data: RequestDataType) throws -> URLRequest
    func parseResponse(data: Data) throws -> ResponseDataType
}
class APIRequestLoader<T: APIRequest> {
    let apiRequest: T
    let urlSession: URLSession
    init(apiRequest: T, urlSession: URLSession = .shared) {
        self.apiRequest = apiRequest
        self.urlSession = urlSession
    }
}

    func loadAPIRequest(requestData: T.RequestDataType,
                        completionHandler: @escaping (T.ResponseDataType?, Error?) -> Void) {
        do {
            let urlRequest = try apiRequest.makeRequest(from: requestData)
            urlSession.dataTask(with: urlRequest) { data, response, error in
                guard let data = data else { return completionHandler(nil, error) }
                do {
                    let parsedResponse = try self.apiRequest.parseResponse(data: data)
                    completionHandler(parsedResponse, nil)
                } catch {
                    completionHandler(nil, error)
                }
            }.resume()
        } catch { return completionHandler(nil, error) }
    }

在 URL Loading System 中,URLSession 提供了一些高级的 API 来操作网络请求。在这背后,还有一个低级的 API URLProtocol ,执行打开网络连接、编写请求、读取响应的工作。URLProtocol 设计为一个虚基类,使得开发者可以继承这个类来对 URL loading system 进行扩展。
Foundation 内置了 protocol 的子类来实现常用的协议,比如 HTTPS。
我们也可以用自定义的 mock protocol 类来拦截请求,并编写 mock 响应。

class MockURLProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        return true
    }
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }
    override func stopLoading() {
        // ...
    }
    static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
    override func startLoading() {
        guard let handler = MockURLProtocol.requestHandler else {
            XCTFail("Received unexpected request with no handler set")
            return
        }
        do {
            let (response, data) = try handler(request)
            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            client?.urlProtocol(self, didLoad: data)
            client?.urlProtocolDidFinishLoading(self)
        } catch {
            client?.urlProtocol(self, didFailWithError: error)
        }
}

有了这个 protocol,我们就可以这样写测试代码了:

class APILoaderTests: XCTestCase {
    var loader: APIRequestLoader<PointsOfInterestRequest>!
    override func setUp() {
        let request = PointsOfInterestRequest()
        let configuration = URLSessionConfiguration.ephemeral
        configuration.protocolClasses = [MockURLProtocol.self]
        let urlSession = URLSession(configuration: configuration)
        loader = APIRequestLoader(apiRequest: request, urlSession: urlSession)
    }

    func testLoaderSuccess() {
        let inputCoordinate = CLLocationCoordinate2D(latitude: 37.3293, longitude: -121.8893)
        let mockJSONData = "[{\"name\":\"MyPointOfInterest\"}]".data(using: .utf8)!
        MockURLProtocol.requestHandler = { request in
            XCTAssertEqual(request.url?.query?.contains("lat=37.3293"), true)
            return (HTTPURLResponse(), mockJSONData)
        }
        let expectation = XCTestExpectation(description: "response")
        loader.loadAPIRequest(requestData: inputCoordinate) { pointsOfInterest, error in
            XCTAssertEqual(pointsOfInterest, [PointOfInterest(name: "MyPointOfInterest")])
            expectation.fulfill()
        }
        wait(for: [expectation], timeout: 1)
    }
}

最后,包含端到端的测试也很有价值,比如 UI 测试。
关于 UI 测试,可以参考 WWDC 2015 的 session UI Testing in Xcode
通过 Mock protocol ,我们可以控制反馈到应用中的数据,来确保 UI 测试的可靠性。

给测试策略分层

我们可以通过向 mock 服务器发起请求,但同时也不能缺少向真实服务器发起请求的测试,我们可以这样做:在单元测试包中一部分请求直接向真实服务器发起请求。这样就可以验证服务器是否以你的 app 的方式来接受请求,并且你能够解析服务器的响应,而无需同时关注 UI 测试的复杂性。

测试通知

  1. 测试通知接收
    通知是一个一对多的通信机制,当单个通知发出时,会发送到整个应用程序中的多个监听者,甚至可能是框架中运行的代码。所以我们应该尽量以孤立的方式测试通知以避免意外的副作用来避免不可靠的测试。
    我们来看看代码:
class PointsOfInterestTableViewController {

    var observer: AnyObject?
    init() {
        let name = CurrentLocationProvider.authChangedNotification
        observer = NotificationCenter.default.addObserver(forName: name, object: nil,
                                                          queue: .main) { [weak self] _ in
            self?.handleAuthChanged()                                                
        }

    }
    var didHandleNotification = false
    func handleAuthChanged() {
        didHandleNotification = true
    }
}

这段代码通过观察通知 authChanged,在 app 的位置认证更新时,重新加载数据。我们的测试代码可以检查是否正确地收到了通知。我们看到这是用默认的消息中心来添加观察者,这样会有不可预知的副作用。

class PointsOfInterestTableViewControllerTests: XCTestCase {
    func testNotification() {
        let observer = PointsOfInterestTableViewController()
        XCTAssertFalse(observer.didHandleNotification)
        //!!!这个通知会被进程里所有对象收到,可能会有不可预知的后果
        let name = CurrentLocationProvider.authChangedNotification
        NotificationCenter.default.post(name: name, object: nil)
        XCTAssertTrue(observer.didHandleNotification)
    }
}

我们可以初始化一个自定义的通知中心代替默认的通知中心,并在测试代码中通过自定义的通知中心添加观察者,这样就不会影响到其他代码了。
```
class PointsOfInterestTableViewController {
let notificationCenter: NotificationCenter
var observer: AnyObject?
init(notificationCenter: NotificationCenter = .default) {
self.notificationCenter = notificationCenter
let name = CurrentLocationProvider.authChangedNotification
observer = notificationCenter.addObserver(forName: name, object: nil, queue: .main) {[weak self] _ in
self?.handleAuthChanged()
}
}
var didHandleNotification = false
func handleAuthChanged() {
didHandleNotification = true
}

top Created with Sketch.