写出能发现问题的测试
Write tests to fail
2020年6月24日
一句话判断
这个 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 次从未失败,它很可能需要审查。