SUT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
extension URLRequest {
static func search(term: String) -> URLRequest {
var components = URLComponents(string: "https://itunes.apple.com/search")
components?.queryItems = [
.init(name: "media", value: "music"),
.init(name: "entity", value: "song"),
.init(name: "term", value: "\(term)")
]

return URLRequest(url: components!.url!)
}
}

struct MusicService {

func search(_ term: String, completion: @escaping (Result<[Track], Error>) -> Void) {
URLSession.shared.dataTask(with: .search(term: term)) { data, response, error in
DispatchQueue.main.async {
completion(self.parse(data: data, error: error))
}
}.resume()
}

func parse(data: Data?, error: Error?) -> Result<[Track], Error> {
if let data = data {
return Result { try JSONDecoder().decode(SearchMediaResponse.self, from: data).results }
} else {
return .failure(error ?? URLError(.badServerResponse))
}
}
}

前几篇单元测试文章我们有讲到依赖注入,我们继续将MusicService通过依赖项:我们继续改进:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
protocol HTTPClient {
func execute(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void)
}

struct MusicService {
let httpClient: HTTPClient

func search(_ term: String, completion: @escaping (Result<[Track], Error>) -> Void) {
httpClient.execute(request: .search(term: term)) { result in
completion(self.parse(result))
}
}

private func parse(_ result: Result<Data, Error>) -> Result<[Track], Error> { ... }
}

class RealHTTPClient: HTTPClient {
func execute(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
if let data = data {
completion(.success(data))
} else {
completion(.failure(error!))
}
}
}.resume()
}
}

Mocking Data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MockHTTPClient: HTTPClient {
var inputRequest: URLRequest?
var executeCalled = false
var result: Result<Data, Error>?

func execute(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void) {
executeCalled = true
inputRequest = request
result.map(completion)
}
}

//ok,我们接下来用假数据mock真数据构建测试用例
func testSearch() {
// 1.
let httpClient = MockHTTPClient()
let sut = MusicService(httpClient: httpClient)

// 2.
sut.search("A") { _ in }

// 3. 检查方法执行情况
XCTAssertTrue(httpClient.executeCalled)
// 4. 校验请求的api是否一致
XCTAssertEqual(httpClient.inputRequest, .search(term: "A"))
}

接下来,我们校验结果集是否一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func testSearchWithSuccessResponse() throws {
// 1.准备测试数据
let expectedTracks = [Track(trackName: "A", artistName: "B")]
let response = try JSONEncoder().encode(SearchMediaResponse(results: expectedTracks))

// 2.构建mock对象
let httpClient = MockHTTPClient()
httpClient.result = .success(response)

let sut = MusicService(httpClient: httpClient)

var result: Result<[Track], Error>?

// 3.调用search方法,将结果映射给result
sut.search("A") { result = $0 }

// 4.断言比较预期值和结果值
XCTAssertEqual(result?.value, expectedTracks)
}

我们也可验证请求失败的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func testSearchWithFailureResponse() throws {
// 1.
let httpClient = MockHTTPClient()
httpClient.result = .failure(DummyError())

let sut = MusicService(httpClient: httpClient)

var result: Result<[Track], Error>?

// 2.
sut.search("A") { result = $0 }

// 4.
XCTAssertTrue(result?.error is DummyError)
}

struct DummyError: Error {}

Testing Before & After

我们可以将异步业务分为异步处理之前,异步处理之后
例如刚刚的SUI,我们分为test before

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func testSearchBefore() {
let sut = MusicService()

// 1.
sut.search("A") { _ in }

// 2.
let lastRequest = URLSession.shared.tasks.last?.currentRequest
XCTAssertEqual(lastRequest?.url, URLRequest.search(term: "A").url)
}

extension URLSession {
var tasks: [URLSessionTask] {
var tasks: [URLSessionTask] = []
let group = DispatchGroup()
group.enter()
getAllTasks {
tasks = $0
group.leave()
}
group.wait()
return tasks
}
}

在处理异步业务之后,MusicService需要处理数据解析。那么可以把这一块作为一个小的UST来进行测试。
1
2
3
4
5
6
7
8
9
10
func testSearchAfterWithSuccess() throws {
let expectedTracks = [Track(trackName: "A", artistName: "B")]
let response = try JSONEncoder().encode(SearchMediaResponse(results: expectedTracks))

let sut = MusicService()

let result = sut.parse(data: response, error: nil)

XCTAssertEqual(result.value, expectedTracks)
}

1
2
3
4
5
6
7
8
9
func testSearchAfterWithFailure() {
let sut = MusicServiceWithoutDependency()

let result = sut.parse(data: nil, error: DummyError())

XCTAssertTrue(result.error is DummyError)
}

struct DummyError: Error {}

这么一看,好像写了一堆废代码。咋看都错误匹配。但就是校验错误异常是否为DummyError。看起来有点傻。

Expectations

以上Mock测试方法和Test Before & After 虽然也管用,但始终没有测试到异步代码。XCTest框架提供XCTestExpectation。 这个模式方法总结出以下几个步骤。

  1. 创建XCTestExpectation实例。
  2. 当异步任务结束时候完成此期望。
  3. 等待期望被兑现。
  4. 断言期望的结果

ok,回到HttpClient的版本,看我们如何编写这个单元测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func testSearch() {
// 1.构建期望
let didReceiveResponse = expectation(description: #function)

// 与mock不同的是,这里不做mock,直接给真实数据对象
let sut = MusicService(httpClient: RealHTTPClient())

var result: Result<[Track], Error>?
sut.search("ANYTHING") {
result = $0
didReceiveResponse.fulfill() //2. 填充期望
}

// 3.等待期望值....,这一步非常重要。在5秒等待响应结果,内部应该有个loop检查结果是否返回
wait(for: [didReceiveResponse], timeout: 5)

// 4.输出期望结果
XCTAssertNotNil(result?.value)
}

XCTestExpectation 是一个多用途的工具,可以应用于许多场景。

1
2
3
4
5
6
7
8
9
10
11
12
13
func testInvertedExpectation() {
// 1.
let exp = expectation(description: #function)
exp.isInverted = true

// 2.
sut.maybeComplete {
exp.fulfill()
}

// 3.
wait(for: [exp], timeout: 0.1)
}

Notification Expectation 可用作当通知被接收的校验

1
2
3
4
5
6
7
8
func testExpectationForNotification() {
let exp = XCTNSNotificationExpectation(name: .init("MyNotification"), object: nil)

...
sut.postNotification()

wait(for: [exp], timeout: 1)
}

以及通过expectedFulfillmentCount属性,可验证fulfill()被调用的次数。
1
2
3
4
5
6
7
8
9
10
func testExpectationFulfillmentCount() {
let exp = expectation(description: #function)
exp.expectedFulfillmentCount = 3
exp.assertForOverFulfill = true

...
sut.doSomethingThreeTimes()

wait(for: [exp], timeout: 1)
}

Busy Assertion

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
通过开启一个runloop,在一定时间范围内循环判断isFulfilled的状态,如果是则退出循环。
extension XCTest {

func expectToEventually(
_ isFulfilled: @autoclosure () -> Bool,
timeout: TimeInterval,
message: String = "",
file: StaticString = #filePath,
line: UInt = #line
) {
func wait() { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) }

let timeout = Date(timeIntervalSinceNow: timeout)
func isTimeout() -> Bool { Date() >= timeout }

repeat {
if isFulfilled() { return }
wait()
} while !isTimeout()

XCTFail(message, file: file, line: line) //当超时未拿到结果,抛出失败。
}
}

用法:

1
2
3
4
5
6
7
8
9
func testSearchBusyAssert() {
let sut = MusicService(httpClient: RealHTTPClient())

var result: Result<[Track], Error>?

sut.search("ANYTHING") { result = $0 }

expectToEventually(result?.value != nil, timeout: 5)
}

评论