Write tests to fail
Developer Tools 入门 26m

写出能发现问题的测试

Write tests to fail

2020年6月24日

在 Apple 官方观看视频

一句话判断

这个 Session 的核心观点是:写测试的目的不是让绿色勾号变多,而是让测试能在代码出问题时变红。如果你的测试从来没有失败过,那它很可能没有测试到正确的东西。

这场 Session 讲了什么

Session 围绕”有意义的测试”展开。Apple 的工程师指出,很多团队的测试覆盖率很高但质量很低——测试要么只验证了”代码没有崩溃”,要么测试了实现细节而非行为。正确的做法是让测试聚焦在”预期行为”上。

Session 提出了几个关键原则:第一,测试应该验证行为而非实现。如果你的测试在重构后就会失败,那说明你在测试实现细节。第二,测试应该有明确的断言。没有断言的测试就是”它没崩溃所以通过了”,这毫无意义。第三,测试应该覆盖边界条件,不只是 happy path。Session 还介绍了如何使用 XCTest 的 expect assertion 来写更精确的异步测试。

值得深挖的点

测试的”烟雾报警器”原则

一个好的测试就像烟雾报警器——平时不会响(不会误报),但在真的着火时一定要响(能发现真正的 bug)。很多开发者写测试的方式是”先写代码,再补测试”,结果测试只是把代码的逻辑重新描述了一遍。Apple 建议反过来:先定义你期望的行为,再写测试来验证它,最后写代码让测试通过。这就是经典的 TDD(测试驱动开发),但 Session 更强调的是思维方式的转变。

测试命名与可读性

测试方法的命名应该描述”在什么条件下、期望什么结果”。testUserLogin() 是一个糟糕的名字——它只说了测什么,没说期望什么。testLoginWithExpiredTokenReturnsUnauthorizedError() 才是好名字——读测试名就知道期望行为。这在测试失败时尤为重要,你不需要点进去看代码就知道哪个场景出了问题。

代码片段

对比:无意义的测试 vs 有意义的测试

场景:测试用户登录功能。

// 无意义的测试:只验证"没有崩溃"
func testUserLogin() {
    let auth = AuthService()
    auth.login(username: "test", password: "pass")
    // 没有任何断言,通过了只能说明没崩溃
}

// 有意义的测试:验证预期行为
func testLoginWithValidCredentialsReturnsSuccess() {
    let auth = AuthService()
    
    let expectation = XCTestExpectation(description: "登录成功回调")
    
    auth.login(username: "testuser", password: "correctpass") { result in
        switch result {
        case .success(let user):
            // 验证返回的用户信息是正确的
            XCTAssertEqual(user.username, "testuser")
            XCTAssertNotNil(user.token)
            XCTAssertFalse(user.token!.isEmpty)
            expectation.fulfill()
        case .failure:
            XCTFail("有效凭证不应该登录失败")
        }
    }
    
    wait(for: [expectation], timeout: 5.0)
}

测试边界条件和错误路径

场景:测试输入验证的各种边界情况。

class InputValidationTests: XCTestCase {
    
    let validator = InputValidator()
    
    // 测试空输入
    func testValidateEmptyEmailReturnsInvalidError() {
        let result = validator.validateEmail("")
        
        if case .failure(let error) = result {
            XCTAssertEqual(error, .emptyInput)
        } else {
            XCTFail("空邮箱应该返回 emptyInput 错误")
        }
    }
    
    // 测试边界长度的密码
    func testValidatePasswordExactly8CharactersIsValid() {
        let result = validator.validatePassword("12345678")  // 恰好 8 位
        
        if case .success = result {
            // 通过
        } else {
            XCTFail("8 位密码应该有效")
        }
    }
    
    // 测试边界值 - 1
    func testValidatePassword7CharactersIsInvalid() {
        let result = validator.validatePassword("1234567")  // 7 位,差 1 位
        
        if case .failure(let error) = result {
            XCTAssertEqual(error, .tooShort)
        } else {
            XCTFail("7 位密码应该返回 tooShort 错误")
        }
    }
    
    // 测试特殊字符
    func testValidateEmailWithPlusSignIsValid() {
        let result = validator.validateEmail("user+tag@example.com")
        
        if case .success = result {
            // Gmail 的 + 别名应该被允许
        } else {
            XCTFail("包含 + 号的邮箱地址应该有效")
        }
    }
}

使用 XCTAssertNotNil 和 guard 组合

场景:测试可选值的解包和处理。

func testParseValidJSONReturnsCorrectModel() {
    let jsonString = """
    {
        "id": 42,
        "name": "测试用户",
        "email": "test@example.com"
    }
    """
    let jsonData = jsonString.data(using: .utf8)!
    
    let result = try? JSONDecoder().decode(User.self, from: jsonData)
    
    // 先验证解析成功
    guard let user = result else {
        XCTFail("有效的 JSON 应该解析成功")
        return
    }
    
    // 再验证每个字段的值
    XCTAssertEqual(user.id, 42)
    XCTAssertEqual(user.name, "测试用户")
    XCTAssertEqual(user.email, "test@example.com")
}

// 同时测试解析失败的路径
func testParseInvalidJSONReturnsError() {
    let jsonString = "{ invalid json }"
    let jsonData = jsonString.data(using: .utf8)!
    
    do {
        _ = try JSONDecoder().decode(User.self, from: jsonData)
        XCTFail("无效的 JSON 应该抛出解码错误")
    } catch {
        // 预期的行为:解码失败
        // 可以进一步验证错误类型
        XCTAssertTrue(error is DecodingError)
    }
}

最佳实践

已有项目:审查现有测试套件,找出那些”永远不会失败”的测试。要么添加有意义的断言,要么删除它们——一个没有断言的测试比没有测试更危险,因为它给了你虚假的安全感。重点关注核心业务逻辑的测试覆盖。

新项目:建立”先写失败测试”的开发习惯。每个新功能从写一个空的测试方法开始,方法名描述期望行为,然后逐步填充断言。要求 PR 中必须包含失败路径的测试——只有 happy path 测试的 PR 不应该被合并。

还有什么值得关注

  • XCTest 的 XCTContext.runActivity(named:block:) 可以在测试报告中创建结构化的步骤描述。
  • 使用 setUp()tearDown() 管理测试状态,确保每个测试都是独立可重复的。
  • 考虑在 CI 中统计”测试从未失败率”——如果一个测试跑了 1000 次从未失败,它很可能需要审查。
WWDC 2020