什么是单元测试

单元测试是运行一个方法(程序的已部分功能代码)验证其输入和输出结果的正确性。通过调用开放的API和行为属性,一般有:

  • 方法返回值
  • 改变Class的属性
  • 缓存数据的CURD
  • 发送通知

为什么需要写单元测试

单元测试可有效减少程序bug,保证程序结果的准确性,改善代码结构。

定义一个好的单元测试

一个好的单元测试具有以下特点:

  • 可读性高
  • 可维护性
  • 可靠的

可读性

当单元测试失败的时候,你能够快速找到失败的原因代码。单元测试需要符合代码规范,格式、命名、模式等等。

  • 简洁的命名、变量
  • 清晰的异常信息
  • 遵循合适的断言结构

可维护性

  • 只测试public,或者internals变量或者方法。不要仅仅为了验证私有和文件私有属性或方法而削弱封装。
  • 不要把测试代码放到生成环境
  • 每项测试只测试一个业务功能
  • 重用代码。如果测试用例包含很多复制重复的代码,需要进行抽象封装,创建一个共享共用的方法如下片段UserStorageTests代码。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class UserStorageTests: XCTestCase {
    let userDefaults = UserDefaultsMock()
    let keychain = KeychainMock()
    let user = User(id: 1, username: "U1", password: "P1")

    func testUsernameSavedToStorage() {
    makeSUT().save(user)
    XCTAssertNotNil(userDefaults.inputUsername)
    }

    func testPasswordSavedToSecureStorage() {
    makeSUT().save(user)
    XCTAssertNotNil(keychain.inputPassword)
    }

    func makeSUT() -> UserStorage {
    return UserStorage(storage: userDefaults, secureStorage: keychain)
    }
    }

可靠性

  • 100%的通过率
  • 测试用例可以重现步骤。
  • 避免在编写单元测试时候写测试逻辑。不要写for,while等循环,不要写if,else,Switch等条件语句,不要写do{}catch{}异常捕获(例如下列片段代码,我们可以用throws将异常抛出)。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    struct Item:Codable,Equatable {
    let id:Int
    }

    //`Item` has custom Codable implementations that we want to test

    class SwiftTestUITests: XCTestCase {

    func testItemCodable() throws {
    let expected = Item(id: 1)
    let encoded = try JSONEncoder().encode(expected)
    let decoded = try JSONDecoder().decode(Item.self, from: encoded)
    XCTAssertEqual(decoded, expected)
    }
    }

单元测试举例

1
2
3
4
5
struct UsernameValidator {
func isValid(_ username: String) -> Bool {
return username.count > 4
}
}

根据之前提到的,我们需要怎么样去测试这个方法呢?首先:

  1. 定义对象,根据需求设置测试对象。
  2. 调用测试对象的方法
  3. 断言反馈测试结果
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
       class UsernameValidatorTests: XCTestCase {

    func testTooShortUsername() {
    let sut = UsernameValidator()
    let result = sut.isValid("U1")
    XCTAssertFalse(result) //XCTAssert是XCTest.framework框架里的断言方法

    //当然以上代码是没问题,但推荐尽量用一两行代码让测试用例结果更显而易见
    XCTAssertFalse(UsernameValidator().isValid("U1"))
    }
    }

特殊断言

有一些特殊断言,比如比较相等XCTAssertEqual(x,y),判断非空XCTAssertNotNil(x)。在你犹豫用XCTAssert还是XCTAssertNil的时候,显然用特殊断言比较方便。

1
2
3
4
5
6
7
8
9
10
11
// Equality
XCTAssert(x == y) // ❌
XCTAssertEqual(x, y) // ✅

//浮点数的比较,accuracy为精确到小数点
let epsilon = 0.0001
XCTAssertEqual(x, y, accuracy: epsilon)

// Nil and Non-nil
XCTAssert(x != nil) // ❌
XCTAssertNotNil(x) // ✅

模拟假数据

当我们调用的方法包含大量依赖项时候,将导致不可控的变化。比如测试UIApplicationDelegate的 application(:, didFinishLaunchingWithOptions:)方法。为了阻止依赖项的影响,我们需要隔离所有依赖,通过依赖注入等技术可以有效解决。
比如App的登录逻辑,我们想测试用户名密码登录的权限,以下代码片段:

1
2
3
4
5
6
7
8
9
class AuthService {
func login(with username: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
let request = URLRequest.login(username: username, password: password)

URLSession.shared.dataTask(with: request) { data, response, error in
// Handle response
}.resume()
}
}

调用login方法有不可控的影响:它执行了一个网络请求。我们在编写单元测试的时候需要隔离AuthService从网络请求的依赖。
首先我们在做顶层网络请求的时候就需要设计一个抽象,然后将它作为一个依赖注入到所需要的服务中,经过改进如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protocol HTTPClient {
func execute(request: URLRequest, completion: @escaping (Result<Data, Error>) -> Void)
}

class AuthService {
let httpClient: HTTPClient

// Initialization code

func login(with username: String, password: String, completion: @escaping (Result<User, Error>) -> Void) {
let request = URLRequest.login(username: username, password: password)

httpClient.execute(request: request) { result in
// Handle response
}
}
}

那么我们在mock数据的时候,我们就可以对这个依赖项做一个mock,因为httpClient是个协议,那么我么构建一个MockHtttpClient:
1
2
3
4
5
6
7
8
9
class HTTPClientMock: HTTPClient {
var inputRequest: URLRequest?
var executeCalled = false

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

那么我们在做单元测试的时候,就可以很好地利用这个mock,模拟真实的网络请求。我们来验证是否被调用,以及验证请求的api是否正确。
1
2
3
4
5
6
7
8
9
10
11
class AuthServiceTests: XCTestCase {
func testLogin() {
let httpClient = HTTPClientMock()
let sut = AuthService(httpClient: httpClient)

sut.login(with: "U1", password: "P1") { _ in }

XCTAssertTrue(httpClient.executeCalled)
XCTAssertEqual(httpClient.inputRequest?.url, .login)
}
}

这样,我们就完成了一个用户名密码登录这样一个单元测试,校验了请求API的正确性。

评论