什么是单元测试
单元测试是运行一个方法(程序的已部分功能代码)验证其输入和输出结果的正确性。通过调用开放的API和行为属性,一般有:
定义一个好的单元测试
一个好的单元测试具有以下特点:
- 可读性高
- 可维护性
- 可靠的
可读性
当单元测试失败的时候,你能够快速找到失败的原因代码。单元测试需要符合代码规范,格式、命名、模式等等。
- 简洁的命名、变量
- 清晰的异常信息
- 遵循合适的断言结构
可维护性
- 只测试public,或者internals变量或者方法。不要仅仅为了验证私有和文件私有属性或方法而削弱封装。
- 不要把测试代码放到生成环境
- 每项测试只测试一个业务功能
- 重用代码。如果测试用例包含很多复制重复的代码,需要进行抽象封装,创建一个共享共用的方法如下片段UserStorageTests代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class 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
15struct 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 | struct UsernameValidator { |
根据之前提到的,我们需要怎么样去测试这个方法呢?首先:
- 定义对象,根据需求设置测试对象。
- 调用测试对象的方法
- 断言反馈测试结果
1
2
3
4
5
6
7
8
9
10
11class 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
9class 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
17protocol 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
9class 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
11class 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的正确性。