CppCon 2015 学习:All Your Tests are Terrible
5 Properties of Good Tests
在软件开发中,良好的测试是确保代码质量和可靠性的关键。以下是五个良好测试的属性,它们帮助我们创建高质量的测试代码,确保其在各种场景下都能有效地执行并提供有价值的反馈。
1. Correctness (正确性)
- 含义:测试必须准确地验证软件的行为,确保软件按照预期工作。一个正确的测试会捕捉到系统中的错误,并能验证程序在不同情况下的功能。
- 如何实现:
- 测试应该与代码的实际要求和预期行为一致。
- 需要定义明确的预期结果,并与实际结果进行比较。
- 需要覆盖不同的边界条件和正常情况,避免测试偏差。
举例:
- 例如,如果你正在测试一个加法函数,测试应该验证加法运算在各种正常和异常情况下都能正确执行(比如正数、负数、零等)。
2. Readability (可读性)
- 含义:测试代码应该简洁、清晰、易于理解。无论是谁读这个测试,应该能够快速理解它的目的和逻辑。
- 如何实现:
- 使用清晰的命名和良好的注释来解释测试的目的。
- 确保测试方法短小精悍,避免冗长的代码。
- 使用结构化的方式来组织测试,使其便于维护。
举例:
- 比如,使用
test_addition_of_two_numbers
作为测试方法名,比使用test1
更能直观地表明该测试的功能。
3. Completeness (完整性)
- 含义:测试应覆盖软件的所有关键功能和边界情况。一个完整的测试套件能够确保程序的各个方面都经过验证。
- 如何实现:
- 设计单元测试时要确保涵盖常见的输入、边界条件、异常情况和错误处理路径。
- 测试应该全面覆盖代码中的逻辑分支,确保每个代码路径都被执行过。
举例:
- 如果测试一个函数的加法操作,应该不仅测试正数加法,还要测试负数、零、非常大的数字,甚至是非法输入(如空值或非数字)。
4. Demonstrability (可演示性)
- 含义:测试应该能够在没有复杂环境设置的情况下运行,并且能够容易地展示其结果。一个良好的测试应该能够简单地被运行,并且清晰地反馈成功或失败。
- 如何实现:
- 确保测试代码独立并且易于执行,避免对外部依赖或复杂配置的强制要求。
- 测试框架应当能够清晰地展示通过或失败的结果,便于开发者理解。
举例:
- 比如,一个良好的测试框架会在测试失败时提供清晰的错误信息,帮助开发者快速定位问题所在。
5. Resilience (韧性)
- 含义:测试应该具有一定的韧性,即使在面对变化时仍然能够稳定运行。测试代码不应该因为小的系统或环境变化而频繁失败。
- 如何实现:
- 避免在测试中使用硬编码的值,特别是依赖于时间、外部环境或数据库的部分。
- 使用模拟和存根(mocking and stubbing)来模拟外部依赖,保证测试的独立性。
- 设计测试时要尽量保持灵活性,避免对系统细节过度依赖。
举例:
- 例如,测试时可以模拟网络请求而不依赖于实际的网络服务,以避免由于网络环境变化而导致测试失败。
总结
良好的测试不仅能帮助我们发现潜在的缺陷,还能增强代码的可维护性。通过确保测试具备正确性、可读性、完整性、可演示性和韧性,我们能够提升软件的质量,确保系统能够在各种情况下稳定运行。
Step 0: Write Tests!
在软件开发过程中,写测试是至关重要的第一步。测试不仅仅是为了确保代码能通过编译和运行,而是为了验证代码是否按照预期功能正确运行。编写好的测试能够帮助开发者确保代码质量,提前发现潜在的错误。
The Goofus vs. Gallant Analogy
这个对比通过Goofus和Gallant两个角色来展示不同的编写测试的心态和方法。
- Goofus:
- 态度:Goofus 对于测试的编写非常随便,他只关心测试是否通过,而不在意测试的质量和内容。
- 行为:他写了任何能够通过的测试,只要测试通过了就认为任务完成了。
- 后果:虽然这些测试可能通过,但它们不一定能真正验证代码的正确性,或者它们只是对代码的表面进行检查,并没有深入验证逻辑或边界情况。
例子:Goofus 编写了一个测试,只检查了程序是否返回 “true”,但没有检查其他可能的结果或者对错误条件的处理。 - 结论:这种方式虽然可能让测试通过,但并没有保证代码的质量,甚至可能遗漏了潜在的bug。
- Gallant:
- 态度:Gallant 则是一个更加深思熟虑的开发者,他会花时间思考他到底在测试什么。
- 行为:在编写测试之前,Gallant 会考虑应用的需求、功能和预期行为。他会确保测试能够涵盖程序的主要功能,并在必要时测试边界条件和错误处理。
- 后果:Gallant 编写的测试更加深入且全面,能够确保程序的各个方面都能正常工作,并能提前发现潜在的缺陷。
例子:Gallant 编写了一个测试,除了验证程序返回 “true” 之外,还会检查错误输入、边界条件和不同的输入值,以确保程序在各种情况下都能正确运行。 - 结论:Gallant 的方式更加可靠,虽然可能会花费更多时间,但最终能够确保代码质量和稳定性。
Key Takeaway:
- Goofus和Gallant的对比传达了两种不同的编写测试的态度:一个是走捷径,只关心测试是否通过;另一个则是更加注重测试的质量,考虑如何全面验证程序的行为。
- 编写测试时,我们不仅要确保测试能通过,还需要思考测试的意义、范围和边界条件,确保我们所编写的测试能真正帮助我们发现问题。
总结:
在测试的编写过程中,最重要的是思考测试的覆盖范围和测试的质量。盲目地写过关的测试会导致测试变得形式化,甚至误导开发者相信系统是正确的,而忽视了潜在的缺陷。因此,Gallant的做法更加值得提倡,我们应该在写测试时花更多的时间和精力,确保它们能真正验证系统的正确性。
测试的正确性
正确性是优秀测试的一个关键属性。编写测试的主要目标是 验证系统是否按预期工作,并满足指定的需求。一个好的测试应该清晰地验证系统的功能是否按照规格要求正常运行。
正确性(Correctness) - Goofus 的错误示例
在 Goofus 的示例中,测试依赖于已知的错误,这就是我们通常说的 测试依赖于不正确或缺失的功能。这并不是一个有效的测试方法,因为它没有真正验证程序的功能是否符合预期,而只是验证一个系统已知的“错误”或不完整实现。
Goofus的代码:
int square(int x) {// TODO(goofus): Implementreturn 0; // 返回硬编码的0,表示没有实现
}
TEST(SquareTest, MathTests) {EXPECT_EQ(0, square(2)); // 依赖于硬编码的返回值EXPECT_EQ(0, square(3)); // 依赖于硬编码的返回值EXPECT_EQ(0, square(7)); // 依赖于硬编码的返回值
}
问题分析:
- 功能缺失:
square
函数实际上并没有实现任何计算逻辑,它只是硬编码返回0
。Goofus 的测试并不验证square
是否正确地计算了数字的平方,而是验证了一个固定的、不变的返回值0
。这并没有实际意义,因为它没有验证所期望的功能。 - 错误的测试依赖:Goofus 依赖一个“已知错误”来通过测试,这种测试仅仅是在测试一个缺失的功能或者不完整的实现,而不是测试程序是否按照正确的要求工作。这种做法不能确保功能的实际正确性,甚至无法帮助开发者发现真正的问题。
- 测试的没有价值:这些测试根本没有验证任何实际的功能,因为
square
函数并未实现任何逻辑,只是一个始终返回0
的占位符。测试的实际效果就是验证硬编码的0
是否与预期的0
相等,而这并不代表程序的逻辑是否正确。
正确的做法:
正确的做法是编写能够验证实际功能的测试,而不是依赖已知错误。具体来说,Gallant 会确保 square
函数实现正确,并通过测试验证每个输入的平方值。
Gallant的代码:
int square(int x) {return x * x; // 正确的实现:计算数字的平方
}
TEST(SquareTest, MathTests) {EXPECT_EQ(4, square(2)); // 2的平方是4EXPECT_EQ(9, square(3)); // 3的平方是9EXPECT_EQ(49, square(7)); // 7的平方是49
}
改进分析:
- 正确实现的功能:
square
函数正确地实现了计算平方的逻辑,而不再是硬编码的0
。这个实现验证了函数在不同输入情况下的预期行为。 - 实际需求验证:测试的目的是验证
square
函数能否正确计算输入值的平方。在这种情况下,我们能确信测试的目的是明确的,且符合要求。 - 高质量的测试:这些测试真实地验证了
square
函数的实现是否符合其需求,确保了功能的正确性,而不是验证已知的错误或不完整的实现。
总结:
- Goofus 的做法依赖于已知错误,测试的核心并没有验证系统的正确行为,而是通过测试一个缺失的功能(硬编码的返回值)。
- Gallant 的做法验证了实际的需求,编写了真实的测试,确保
square
函数的每个输入都能得到正确的平方结果。
正确的测试应该是基于对系统需求的验证,确保功能按预期工作,而不是依赖缺陷或不完整的实现。
Correctness - 测试必须验证系统要求是否满足
正确性是测试中最重要的原则之一。一个良好的测试不仅要验证功能是否按预期工作,还要确保其行为符合系统的需求规范。
Goofus 的错误做法:
1. 依赖已知的 Bug:
Goofus 的测试可能会依赖于系统中已知的缺陷或不完全实现的功能。这种做法并不会真正验证程序是否符合规范,而是通过已知的错误进行“通过”测试。这样,测试的结果没有任何实际意义,因为它没有验证正确性,而是依赖于程序的缺陷。
举例来说:
int square(int x) {// TODO: 实现平方计算return 0; // 无论传入什么数,都返回 0
}
TEST(SquareTest, MathTests) {EXPECT_EQ(4, square(2)); // 期望 2 的平方是 4,但函数返回的是 0EXPECT_EQ(9, square(3)); // 期望 3 的平方是 9,但函数返回的是 0EXPECT_EQ(49, square(7)); // 期望 7 的平方是 49,但函数返回的是 0
}
在这个例子中,square
函数并没有实现平方计算功能,它始终返回 0。Goofus 的测试期望 square
函数返回正确的平方结果,但实际上所有的期望都无法满足,因为 square
返回的总是 0,这样的测试是完全没有意义的。
2. 测试未执行实际场景:
Goofus 编写的测试并没有模拟或验证程序的实际运行场景,而只是依赖于“理想化”的测试条件。例如,可能会编写一些看似正确的测试用例,但实际上这些测试并没有反映真实应用中可能遇到的各种情形。
例如,在以下情况中,Goofus 可能会编写测试来验证一个没有考虑所有边界条件或错误处理的功能:
int divide(int a, int b) {return a / b; // 忽略除零的情况
}
TEST(DivideTest, BasicTests) {EXPECT_EQ(5, divide(10, 2)); // 10 除以 2,正常情况EXPECT_EQ(0, divide(10, 0)); // 除以零,测试并不完整
}
这个测试的关键问题是没有考虑除零错误。在 Goofus 的测试中,第二个测试用例 divide(10, 0)
将导致程序崩溃或异常,这并不是一个真实的场景,因此它并不能有效地验证 divide
函数的完整性。
Gallant 的正确做法:
1. 不依赖已知的 Bug:
Gallant 知道测试应该验证系统的需求,而不是依赖系统中的缺陷。好的测试会确保每个功能都按照预期工作,而不是利用程序的错误行为。
2. 测试实际场景:
Gallant 的测试会覆盖所有可能的边界情况和错误情况,而不仅仅是正常场景。例如,他会确保在进行除法操作时,除数不为零,并且会验证程序在各种输入下的行为。
int square(int x) {return x * x; // 实现正确的平方计算
}
TEST(SquareTest, MathTests) {EXPECT_EQ(4, square(2)); // 验证 2 的平方是 4EXPECT_EQ(9, square(3)); // 验证 3 的平方是 9EXPECT_EQ(49, square(7)); // 验证 7 的平方是 49
}
int divide(int a, int b) {if (b == 0) {throw std::invalid_argument("Cannot divide by zero");}return a / b;
}
TEST(DivideTest, BasicTests) {EXPECT_EQ(5, divide(10, 2)); // 验证 10 除以 2 的结果是 5EXPECT_THROW(divide(10, 0), std::invalid_argument); // 验证除零时抛出异常
}
在 Gallant 的做法中:
square
函数的实现是正确的,测试验证了它是否正确计算平方。divide
函数考虑了除零错误,并且验证了异常处理。
这些测试不仅确保了函数在正常场景下工作正常,还考虑了错误和边界条件。
总结:
- Goofus 的错误做法:依赖已知的 Bug 和不真实的场景进行测试。这样的测试无法验证系统的正确性,反而可能误导开发人员,导致潜在的问题被忽视。
- Gallant 的正确做法:测试应该验证系统的实际需求,并覆盖所有可能的场景(包括错误处理、边界条件等)。测试不仅要验证功能的正确性,还要确保在各种实际场景下的行为是正确的。
在编写测试时,正确性是最重要的原则。测试应该覆盖程序的全部需求,确保系统的行为符合预期,而不是依赖系统中已知的缺陷或者不完整的场景。
Goofus vs. Gallant: 测试的可读性
Goofus的方式:
Goofus 写测试时不考虑其他读者或将来维护代码的人。他写的测试可能很难读懂、理解不清,或者测试的名字和结构都不清晰。
例如,Goofus 可能写出这样的测试:
TEST(FooTest, MathStuff) {int x = 2;int y = 3;int z = 7;EXPECT_EQ(4, x + x);EXPECT_EQ(6, x + y);EXPECT_EQ(49, z * z);
}
虽然这个测试能够通过,但并不清楚到底在测试什么或者为什么。测试函数名(MathStuff
)没有表达任何特定的意义,也没有描述被测试的系统行为或数值上下文。
Gallant的方式:
Gallant 则理解到测试不仅仅是让代码通过,它们也是写给人类看的。未来的开发者,甚至 Gallant 自己,都可能需要阅读和理解这些测试。Gallant 写的测试更清晰、描述明确,让代码更易于维护和理解。
Gallant 可能会这样写:
TEST(FooTest, ShouldReturnCorrectSquareOfTwo) {int x = 2;EXPECT_EQ(4, x * x); // 验证 2 的平方是否正确
}
TEST(FooTest, ShouldReturnCorrectSumOfTwoAndThree) {int x = 2;int y = 3;EXPECT_EQ(5, x + y); // 验证 2 和 3 的和是否正确
}
TEST(FooTest, ShouldReturnCorrectSquareOfSeven) {int z = 7;EXPECT_EQ(49, z * z); // 验证 7 的平方是否正确
}
主要区别:
- 意图的清晰性:
- Gallant 的测试命名清楚地描述了正在测试什么和为什么要测试它。测试名字(
ShouldReturnCorrectSquareOfTwo
、ShouldReturnCorrectSumOfTwoAndThree
)使得读者立刻明白预期的结果是什么。 - Goofus 的测试命名模糊(
MathStuff
),没有提供任何有用的信息,读者很难明白测试的目的是什么。
- Gallant 的测试命名清楚地描述了正在测试什么和为什么要测试它。测试名字(
- 描述性的断言:
- Gallant 的测试使用描述性的命名和断言,清楚地说明了每个断言在检查什么。
- Goofus 的测试虽然有一系列断言,但没有解释这些断言的意义,需要读者深入理解代码才能知道它们在做什么。
- 易于维护:
- Gallant 的测试易于修改。未来的任何开发者都可以通过读测试的名字和断言来理解它们在做什么。
- Goofus 的测试可能会导致混淆或bug,因为它不清楚为什么要进行这些测试,或者它们具体验证的是哪个行为。
为什么测试的可读性很重要:
- 协作: 在团队中工作时,其他开发人员也需要阅读和理解这些测试。清晰、写得好的测试让每个人更容易合作,避免误解。
- 维护: 随着时间的推移,测试可能需要更新或修改以反映系统的变化。如果测试很难理解,这个过程就会变得非常费时费力,容易出错。
- 调试: 当测试失败时,快速理解问题所在至关重要。清晰的测试名称和描述可以帮助更快定位问题。
编写可读测试的最佳实践:
- 描述性的测试名称: 始终选择能描述测试行为的名称。这样,读者一眼就能理解这个测试是验证什么的。
- 描述性的断言: 使用清晰的断言,提供背景说明断言的意义。
- 简单且聚焦的测试: 测试应该关注一个单一的行为,并确保它被彻底测试,同时保持清晰。
- 必要时添加注释: 如果测试中的某些部分比较复杂或需要额外的上下文说明,添加注释帮助理解。
总结:
- Goofus 写的测试专注于“通过”,但没有考虑其他人如何理解这些测试。
- Gallant 写的测试清晰、易读且容易理解,使得其他人可以轻松理解测试的目的以及系统预期的行为。
Goofus与Gallant的可读性:过度的样板代码和分心的部分
Goofus的做法:
Goofus写的测试往往充满了不必要的样板代码和冗余部分,这会让人很难迅速理解测试的核心意图。例如,Goofus可能在每个测试中写了大量的初始化代码或对象创建,而这些代码和测试的主要目标没有直接关系,反而让阅读者分心。
TEST(BigSystemTest, CallIsUnimplemented) {TestStorageSystem storage;auto test_data = GetTestFileMap();storage.MapFilesystem(test_data);BigSystem system;ASSERT_OK(system.Initialize(5));ThreadPool pool(10);pool.StartThreads();storage.SetThreads(pool);system.SetStorage(storage);// Meaningless setup.ASSERT_TRUE(system.IsRunning());EXPECT_TRUE(IsUnimplemented(system.Status()));
}
在这个示例中,大量的设置和初始化工作,如storage.MapFilesystem(test_data)
、ThreadPool pool(10)
、storage.SetThreads(pool)
等,实际上并没有直接与**“CallIsUnimplemented”测试的核心目标相关。这些设置代码使得测试难以阅读和理解,增加了分心和不必要的复杂性**。
Gallant的做法:
Gallant则会去掉冗余的部分,并且只保留直接影响测试目的的代码,确保测试简洁且清晰。Gallant会简化不必要的样板代码,集中于验证系统行为,使得测试更具可读性。
TEST(BigSystemTest, CallIsUnimplemented) {BigSystem system;ASSERT_OK(system.Initialize(5));ASSERT_TRUE(system.IsRunning());EXPECT_TRUE(IsUnimplemented(system.Status()));
}
在Gallant的版本中,冗余的初始化和设置代码被删除,保留了对系统行为的直接验证。测试只关心核心功能:初始化系统并验证其状态是否未实现。这使得测试更加清晰,读者一目了然。
问题:为什么冗余代码分散了测试的注意力?
- 不相关的初始化:测试的目的是验证
BigSystem
的状态是否正确,而ThreadPool
和storage
的初始化没有直接关系。过多的初始化代码不仅让测试变得冗长,还让读者难以理解重点。 - 分心的设置:测试应该专注于验证特定的功能,而不应该被无关的设置或初始化所干扰。如果设置代码复杂且不相关,它可能使读者迷失在细节中,而忽略了核心测试目标。
最佳实践:避免过多的样板代码
- 关注核心功能:每个测试都应该围绕其验证的功能或行为进行。避免引入不必要的上下文或初始化工作,保持测试简洁和直观。
- 清晰的测试目标:测试应清晰表明其目的。每个断言都应当紧密围绕验证某个具体行为展开。
- 使用测试夹具:如果某些初始化代码在多个测试中都会用到,可以考虑使用**测试夹具(Test Fixtures)**来减少冗余。测试夹具可以帮助你将常见的设置代码提取到一个单独的地方,从而避免在每个测试中重复编写相同的代码。
- 避免无关的复杂操作:避免在测试中引入与测试目标无关的复杂操作或状态。集中测试需要验证的行为,去掉所有无关的内容。
- 简洁明了的命名:确保测试名称能够准确反映它所验证的行为,这样即使没有阅读实现代码,其他开发者也能理解测试的意图。
总结
- Goofus通过添加冗长和不相关的设置代码,令测试变得难以理解和维护。
- Gallant则通过去除冗余并专注于核心功能,确保测试简洁且直观。
可读性(Readability)
Goofus的做法:
Goofus编写的测试往往存在两个主要问题:
- 过多的样板代码和分心的部分,这些不必要的部分使得测试看起来冗长,降低了其可读性。
- 缺乏足够的上下文,使得测试的意义和目的不够清晰,读者需要花更多的时间去推测测试的真正意图。
此外,Goofus还可能过度使用高级测试框架的特性,这使得测试更加复杂,增加了理解的难度。
示例 1:缺乏上下文的测试
TEST(BigSystemTest, ReadMagicBytes) {BigSystem system = InitializeTestSystemAndTestData();EXPECT_EQ(42, system.PrivateKey());
}
在这个示例中,测试的目的可能是验证 BigSystem
的 PrivateKey
是否为 42
,但是测试缺少上下文,使得阅读代码的人很难理解:
BigSystem
是什么?PrivateKey
是用来干什么的?InitializeTestSystemAndTestData()
是做了什么?为什么它能正确初始化测试数据?
由于这些问题,未来的读者(甚至是你自己)在阅读这个测试时可能会感到困惑,无法迅速理解这个测试的真实意图。
Gallant的做法:
Gallant会在测试中保持足够的上下文,以帮助读者理解测试的目的和背景。通过明确的命名和必要的注释,Gallant能够清楚地表达测试的目标和相关背景。
示例 2:更有上下文的测试
TEST(BigSystemTest, ShouldReturnPrivateKeyForValidSystem) {// 初始化系统和测试数据BigSystem system = InitializeTestSystemAndTestData();// 验证私钥是否为预期值 42EXPECT_EQ(42, system.PrivateKey());
}
在这个示例中,Gallant的测试增加了足够的上下文:
- 通过
ShouldReturnPrivateKeyForValidSystem
来明确表达测试的目标。 - 注释解释了初始化过程和预期的验证结果。
- 测试变得更易于理解,即使是第一次阅读代码的人也能很快了解测试的目的。
问题 2:过度使用高级框架特性
Goofus还可能使用不必要的高级测试框架特性,导致测试代码更加复杂,难以理解。这可能包括过多的模拟、断言、或者条件判断等复杂的框架特性,而这些特性并不一定有助于测试的核心目标。
示例:过度使用高级框架特性
TEST(BigSystemTest, AdvancedTestWithMocksAndComplexSetup) {MockStorage mockStorage;EXPECT_CALL(mockStorage, ReadData()).WillOnce(Return("mocked data"));BigSystem system(mockStorage);ASSERT_NO_THROW(system.Initialize());// Complex assertionsEXPECT_TRUE(system.IsRunning());EXPECT_EQ(system.GetStatus(), Status::Ready);EXPECT_CALL(mockStorage, SaveData(_)).Times(1);mockStorage.SaveData("some data");
}
在这个例子中,Goofus使用了模拟对象(Mocks)和复杂的设置,虽然这些技术在某些场景下非常有用,但在测试一个简单的系统时,它们使得代码变得冗长且难以理解。过度依赖这些技术可能会使测试失去简单性和清晰性,尤其是当目标是验证一个基础功能时。
Gallant的做法:
Gallant会避免不必要的复杂性,集中测试的核心目标,确保测试简洁明了。即使使用了高级框架特性,也会确保它们是必要的,并且能够增加测试的清晰度和有效性。
总结:
- 保持上下文:确保测试能够提供足够的上下文,让读者理解测试的目标和背景。避免写出不带有任何背景信息的测试,这样读者才能理解测试的目的。
- 避免过度使用复杂特性:测试应尽可能简洁明了,避免使用过多的高级框架特性,除非它们对于验证目标是必要的。
- 关注测试目的:测试应该专注于验证核心功能,而不是过多地涉及不必要的细节或复杂的框架特性。
总之,一个好的测试不仅要通过验证功能来保证代码的正确性,还要确保测试本身易于理解和维护。
可读性(Readability)
在测试代码中,可读性非常重要。Goofus的做法通常会让测试代码变得复杂或者过于依赖高级的测试框架特性,但这种复杂性并不是每次都必要的。Gallant的做法则是尽量保持代码简洁,避免过度使用高级特性,除非确实需要它们。
示例:避免不必要的高级测试框架特性
在你提供的代码中,BigSystemTest
使用了 Google Test 框架的测试夹具(test fixture)功能。测试夹具允许你在多个测试用例之间共享相同的初始化和清理工作。虽然这种做法对于一些复杂的测试来说非常有用,但对于简单的测试来说,使用测试夹具的方式就显得有些过于复杂。
class BigSystemTest : public ::testing::Test {
public:BigSystemTest() : filename_("/foo/bar/baz") { }void SetUp() override {ASSERT_OK(file::WriteData(filename_, "Hello World!\n"));}
protected:BigSystem system_;std::string filename_;
};
TEST_F(BigSystemTest, BasicTest) {EXPECT_TRUE(system_.Initialize());
}
在这个示例中:
BigSystemTest
是一个派生自::testing::Test
的类,提供了一个测试夹具。它在SetUp()
函数中执行了一些初始化操作,确保每个测试用例都能有一个已初始化的状态。TEST_F
是 Google Test 框架中的一个宏,用来为BigSystemTest
测试夹具编写测试用例。
问题:过度依赖高级框架特性
虽然测试夹具和 TEST_F
是强大的工具,但在简单的测试场景下并不需要它们,它们使得代码变得更复杂了。对于一些简单的功能测试,直接使用 TEST
而不使用夹具会更加简洁明了。
改进:避免不必要的复杂性
如果测试本身不需要测试夹具中的额外功能,那么可以通过去掉测试夹具,直接使用 TEST
来减少冗余代码,保持代码的可读性。
TEST(BigSystemTest, BasicTest) {std::string filename = "/foo/bar/baz";ASSERT_OK(file::WriteData(filename, "Hello World!\n"));BigSystem system;EXPECT_TRUE(system.Initialize());
}
在这个改进后的版本中:
- 我们直接在测试用例内部进行初始化,避免了使用测试夹具(
TEST_F
)。 filename
的声明和初始化是局部的,使得代码更加简洁。- 通过直接在测试用例中进行数据写入和系统初始化,使得代码更加直观,易于理解。
总结:
- 避免过度使用框架特性:如果某个测试功能不复杂,避免使用复杂的框架特性(如测试夹具)。这样可以减少不必要的代码,使测试更加简洁。
- 提高可读性:测试代码应该尽量清晰明了,不要因为使用高级特性而增加不必要的复杂性。通过简化代码,可以提高测试的可读性和维护性。
Goofus(固弗斯) - 只为两个简单的用例写测试。
- 然后就兴奋地“拿着剪刀乱跑”。Weeeeeeeeee!
- 他说:“这些剪刀正好可以用来测试一些边缘情况。嘿嘿!”
- 他总是一边说着“我写了测试”一边把代码部署上线,其实只测了最基础的部分。
Gallant(加兰特) - 认真地测试各种边界情况。
- 确保代码在各种情况下都能正常工作。
- 他用测试证明代码是真正正确的,不是“看起来没报错”就完事了。
这是一段幽默但很真实的示范,揭示了测试覆盖不足可能带来的风险。
主题:Completeness(完整性)
Goofus 的做法(错误示范):
他只测试了“容易的情况”,比如:
TEST(FactorialTest, BasicTests) {EXPECT_EQ(1, Factorial(1));EXPECT_EQ(120, Factorial(5));
}
乍一看,这两个测试都通过了。但实际上他的实现是这样的:
int Factorial(int n) {if (n == 1) return 1;if (n == 5) return 120;return -1; // TODO(goofus): figure this out.
}
看起来测试“都通过了”,但其实代码根本没实现真正的阶乘逻辑。只是硬编码返回了两个测试用例的结果。
Gallant 的做法(正确示范):
Gallant 会这样思考:
- “两个测试不够,我需要测试边界情况、错误输入、通用情况。”
- 他会补充比如:
EXPECT_EQ(1, Factorial(0)); // 测试 0 的阶乘 EXPECT_EQ(6, Factorial(3)); // 测试一般值 EXPECT_EQ(-1, Factorial(-2)); // 测试非法输入
- 然后实现一个通用、健壮的
Factorial
函数:int Factorial(int n) {if (n < 0) return -1;int result = 1;for (int i = 2; i <= n; ++i) {result *= i;}return result; }
中文总结:
Goofus 测试通过了,但其实代码是错的,只是因为测试写得太少太“巧了”。
Gallant 明白:测试不能只覆盖“会过的情况”,更要覆盖那些“容易错的角落”。
这一段是**“测试完整性”**(Completeness)的幽默对比示范,继续用 Goofus 和 Gallant 的方式强调 测试覆盖范围的重要性。
主题:Completeness(测试完整性)
Goofus 的做法:
Goofus 只测试最容易、最明显的情况。
TEST(FactorialTest, BasicTests) {EXPECT_EQ(1, Factorial(1));EXPECT_EQ(120, Factorial(5));
}
他只管能“跑通”的案例,代码可能是硬编码,边界和异常情况统统忽略。
Gallant 的做法:
Gallant 测试了:
常见输入(1、5)
边界情况(0)
极限情况(12)
异常/异常输入(负数、溢出)
TEST(FactorialTest, BasicTests) {EXPECT_EQ(1, Factorial(1)); // 常规EXPECT_EQ(120, Factorial(5)); // 常规EXPECT_EQ(1, Factorial(0)); // 边界值EXPECT_EQ(479001600, Factorial(12)); // 大数但仍安全EXPECT_EQ(std::numeric_limits<int>::max(), Factorial(13)); // 超过 int 范围,处理溢出EXPECT_EQ(std::numeric_limits<int>::max(), Factorial(-10)); // 非法输入,优雅处理
}
他考虑到:
- 0 的阶乘是数学上的一个边界(应返回 1)
- 12 是 int 阶乘的最大安全值
- 13 开始会溢出(Gallant 使用
std::numeric_limits<int>::max()
处理) - 负数输入是非法的(Gallant 定义行为,比如返回最大值表示错误)
总结(中文):
- Goofus 只测“会过”的例子,无法发现错误。
- Gallant 测全范围输入,从正常到极端、到非法输入,确保代码可靠。
这一段又是用幽默的 “Goofus vs. Gallant” 风格,展示在**测试完整性(Completeness)**中一种常见但低效的错误做法:
主题:Completeness(完整性)
Goofus 的做法(反面教材):
TEST(FilterTest, WithVector) {vector<int> v; // 测试标准库 vector 的功能v.push_back(1);EXPECT_EQ(1, v.size());v.clear();EXPECT_EQ(0, v.size());EXPECT_TRUE(v.empty());// 现在才测试自己实现的 Filterv = Filter({1, 2, 3, 4, 5}, [](int x) { return x % 2 == 0; });EXPECT_THAT(v, ElementsAre(2, 4));
}
问题在哪?
- Goofus 浪费时间测试了 不属于他负责的代码(例如 C++ 标准库的
vector
)。 - 他在测试中“验证
vector::push_back
和size()
是否工作” —— 这是没必要的,因为这些是 STL 已经充分测试过的功能。 - 他“本该专注于 Filter 的行为”,却被不相关的东西分散了注意力。
Gallant 的做法(正面教材):
Gallant 知道:
“如果你不信任 vector,就该换语言;如果你信任它,就别测它。”
他会直接写:
TEST(FilterTest, FiltersEvenNumbers) {auto result = Filter({1, 2, 3, 4, 5}, [](int x) { return x % 2 == 0; });EXPECT_THAT(result, ElementsAre(2, 4));
}
他只测试自己实现的 Filter 函数是否正确,完全信任 vector 的行为。
中文总结:
- Goofus 在测试别人负责的代码(标准库的
vector
),不仅没意义,还浪费时间。 - Gallant 测试他自己实现的逻辑(Filter 是否正确地筛选出偶数)。
- 测试要专注于你负责的代码和行为,其他模块要靠契约和接口稳定性,不是重复测试。
这一段是在以 讽刺的方式 展示 Goofus 写测试时的问题:
他在测试一个叫 Filter
的函数,但却花大篇幅在验证 标准库 vector
的基本行为,甚至语法都被打乱了,显得更混乱和低效。
主题:Completeness(测试完整性)
Goofus 的错误做法:
vector<int> v;
// 不信任标准库,重复测试它的行为(push_back、size、clear、empty)
v.push_back(1);
EXPECT_EQ(1, v.size());
v.clear();
EXPECT_EQ(0, v.size());
EXPECT_TRUE(v.empty());
接下来才开始测试自己的函数 Filter
:
v = Filter({1, 2, 3, 4, 5}, [](int x) { return x % 2 == 0; });
EXPECT_THAT(v, ElementsAre(2, 4));
问题总结:
- Goofus 不分清楚测试边界:
他在测试“别人”的 API(标准库),不是自己的Filter
。 - 测试代码写得乱七八糟(中断、格式错误),反映了他对测试重点和结构的混乱理解。
- 这是个对“完整性”的误解:他以为测试越多越完整,但却测到了不该测的地方。
Gallant 的正确做法:
Gallant 明白:
“我只需要验证
Filter
在依赖 vector 的同时能正确运行。vector 不归我管。”
他写的测试清晰简洁:
TEST(FilterTest, FiltersEvenNumbers) {auto result = Filter({1, 2, 3, 4, 5}, [](int x) { return x % 2 == 0; });EXPECT_THAT(result, ElementsAre(2, 4));
}
优点总结:
- 测试关注点明确:只测试 Filter 行为。
- 结构清晰,表达清楚。
- 遵循软件测试原则:“信任下层依赖,只验证你控制的功能。”
中文总结:
Goofus | Gallant | |
---|---|---|
测试重点 | 别人的 API(vector) | 自己的 API(Filter) |
风格 | 混乱、啰嗦、不聚焦 | 简洁、清晰、重点明确 |
完整性理解 | 测得多就是好 | 测得准才叫完整 |
测试质量 | 浪费时间、易维护困难 | 精准测试,易于维护和扩展 |
这一段依然是以幽默的方式,通过 Goofus 和 Gallant 的对比,揭示一个非常重要的测试原则:不要滥用私有 API 进行测试。
主题:使用私有 API
Goofus 的行为:
“Mmmm. These private APIs are delectable.”
“这些私有 API 真是太美味了!”
他在测试中:
- 直接访问了私有方法 / 内部实现细节。
- 写出了测试场景是“用户永远无法做到”的情况。
- 假设测试代码拥有比实际使用者更多的权限。
// Goofus 在测试中这样做(伪代码):
InternalBuffer& buf = filter.internal_buffer_; // 私有成员!
buf.flush(); // 调用只有内部系统能触发的行为
问题:
- 违反封装原则:测试应该只使用公开接口,模拟真实使用。
- 测试易碎:内部实现一改,测试全部崩。
- 误导性:测试通过≠API可用,因为用户压根不能像你这么用。
Gallant 的行为:
“Gallant uses tests to show how the API should be used.”
他知道:
-
测试是API 设计的一部分,用来告诉他人:
“这是你应该如何使用这个 API。”
-
他坚持通过公开接口来验证行为。
// Gallant 的测试(简洁、真实):
TEST(FilterTest, RemovesOddNumbers) {auto result = Filter({1, 2, 3, 4}, [](int x) { return x % 2 == 0; });EXPECT_THAT(result, ElementsAre(2, 4));
}
好处:
- 测试 真实、可靠、稳定。
- 若用户按文档使用 API,能得到一致的行为。
- 代码演示了正确用法,甚至能当成样例代码使用。
中文总结:
Goofus | Gallant | |
---|---|---|
用法 | 偷偷吃“私有 API”,违规访问内部细节 | 只通过正式渠道(公开 API)与系统交互 |
风险 | 易碎、误导、不维护封装 | 稳定、真实、有指导意义 |
测试角色 | 瞎测,满足自我 | 代表用户,传达“应如何使用这个 API” |
总结金句:
“这些私有 API 看起来很香,但下肚后会要命。”
—— 请节制。好测试就像健康饮食,用对食材,用对方式。
这段是关于 Demonstrability(可演示性) 的测试原则,强调:
测试不仅是为了验证正确性,还应展示 API 的合理用法。
主题:Demonstrability(可演示性)
Goofus 的问题行为:
Goofus writes tests with:
● 依赖私有方法,或使用
friend
/TestOnly
黑科技。
● 在测试中用出了错误或反模式的 API 使用方式,暗示 API 本身设计糟糕。
示例(伪代码):
// Goofus 把测试写成这样:
auto internal = myApi.getInternalState(); // 依赖私有接口或 friend
EXPECT_EQ(internal.status, MAGIC_ENUM);
// 或者这样:
MyThing thing;
thing.doStuffManually(); // 手动调用几个方法绕过正常流程
thing.reset(); // 暴力清理内部状态
问题:
- 测试用法和真实用户场景完全脱节。
- 暗示 API 很难用,需要“内部后门”或 hack 才能用。
- 如果别人看测试来学习怎么用这个 API,会学到错误方式。
- 测试变成“扭曲 API 去适配测试”,而不是“测试来展现良好的 API”。
Gallant 的好做法:
Tests should demonstrate the API — show how it’s meant to be used.
Gallant:
- 写测试就像写示例代码。
- 每个测试都像在说:“这是你应该怎么用这个函数。”
- 只调用公开 API,按预期流程执行。
示例:
TEST(UserFlowTest, FiltersEvenNumbers) {auto input = std::vector<int>{1, 2, 3, 4, 5};auto output = Filter(input, [](int x) { return x % 2 == 0; });EXPECT_THAT(output, ElementsAre(2, 4));
}
好处:
- 测试既是验证,也是教学。
- 对未来读代码的人来说,测试 = 使用指南。
- 保证 API 对外行为是清晰的、合理的、易用的。
中文总结:
项目 | Goofus | Gallant |
---|---|---|
使用方式 | 私有接口、friend、TestOnly、hack 方法 | 正常流程、公开 API |
可演示性 | 差(误导他人、暴露内部实现) | 强(每个测试都像官方用法示例) |
测试对 API 的作用 | 掩盖设计缺陷,造成技术债 | 检验设计合理性,推动 API 改进 |
总结金句:
好的测试就像好的例子:能教会别人怎么优雅地用你的 API。
这段是一个典型的 “Demonstrability(可演示性)” 反例:测试代码依赖于私有方法(通过 friend
暴露),导致:
- 误导性强:看测试的人会以为这种“快捷方式”是推荐用法;
- 违反封装:测试越过了正常的 API 使用边界;
- API 设计被掩盖:主流程(
Setup()
)没被测试,说明设计可能存在问题。
具体拆解:
Goofus 的写法:
class Foo {friend FooTest; // 暴露给测试类访问私有内容
public:bool Setup();
private:bool ShortcutSetupForTesting(); // 本不该公开的方法
};
TEST(FooTest, Setup) {Foo foo;EXPECT_TRUE(foo.ShortcutSetupForTesting()); // 不测试公开 API
}
问题在哪?
- 测试私有方法不是“演示用法”
ShortcutSetupForTesting()
是内部实现细节。- 用户永远不会、也不该用这个接口。
- 你测试它就是在“鼓励滥用内部 API”。
- 跳过主流程
Setup()
- 应该测试的是
Setup()
的行为,因为那才是“用户的路径”。 - 测试应该验证从用户角度出发,API 能否工作。
- 应该测试的是
- 暴露
friend
是技术债- 一般只在没有其他选择时才用,频繁使用说明设计需要重构。
Gallant 应该这样做:
他会测试 公开接口 Setup()
,不依赖私有成员,不用 friend
黑科技:
TEST(FooTest, SetupWorksAsExpected) {Foo foo;EXPECT_TRUE(foo.Setup()); // 这是用户实际会调用的 API
}
如果 Setup()
太复杂或依赖太多外部组件 ——
Gallant 会用依赖注入(DI)、Mock、Stub 来做隔离,而不是走私有后门。
中文总结:
项目 | Goofus | Gallant |
---|---|---|
接口使用 | 测试私有方法,通过 friend 暴露 | 只测试公开 API |
可演示性 | 差(误导用法,不代表实际场景) | 强(测试 = 使用文档) |
对设计的反馈 | 掩盖设计问题 | 推动接口设计更清晰、稳定 |
总结金句:
你测试的接口,应该和用户看到的一模一样。否则不是测试,是作弊。
这段代码是在进一步强调 Demonstrability(可演示性) 的测试原则问题,用一种讽刺、混乱的格式表现 Goofus 的错误方式:
示例(结构还原后):
class Foo {friend FooTest; // 暴露私有接口给测试用例访问
public:bool Setup(); // 公共方法,面向用户
private:bool ShortcutSetupForTesting(); // 私有方法,仅供测试偷用
};
TEST(FooTest, Setup) {Foo foo;EXPECT_TRUE(foo.Setup()); // <--- 测试的是公开 API?还是?
}
(注:你贴的原始文本是拆开的,像是手误或讽刺 Goofus 写代码很混乱)
问题分析:
- 定义了私有的 Shortcut 方法,暴露给测试使用(通过
friend FooTest;
),但测试却只写了一行:
乍一看好像没问题,但这暴露一个更深层的问题:EXPECT_TRUE(foo.Setup());
- 为什么需要
ShortcutSetupForTesting()
存在?- 如果这个私有方法只存在是为了绕过
Setup()
,说明Setup()
难测、不稳定、耦合过多; - Gallant 会认为:这不是测试的问题,而是 API 设计需要改进。
- 如果这个私有方法只存在是为了绕过
- 测试应该展现的是“正常使用方式”:
EXPECT_TRUE(foo.Setup());
本身是合理的,但上下文却显示:- 有人试图通过私有方法绕路;
- API 设计可能不方便使用或测试;
- 这可能是 Goofus 做的测试“妥协”,掩盖了真实问题。
正确姿势(Gallant 风格):
Gallant 会:
- 不引入
friend FooTest;
,避免破坏封装; - 让
Setup()
本身易于测试(可注入依赖、可配置); - 写清晰、有表达力的测试:
TEST(FooTest, SetupShouldSucceedUnderNormalConditions) {Foo foo;EXPECT_TRUE(foo.Setup()); // 测试的是公开 API 的行为
}
如果 Setup()
行为复杂,Gallant 会考虑引入 mock:
// 例如:注入配置、资源管理器、外部依赖等作为参数
Foo foo(MockConfig{}, FakeResource{});
EXPECT_TRUE(foo.Setup());
中文总结:
项目 | Goofus | Gallant |
---|---|---|
封装原则 | 打破封装,使用 friend 暴露私有方法 | 坚守封装,只测试公开接口 |
可演示性 | 测试方式模糊、不代表实际用户使用 | 测试就是教学案例,清晰展现正确用法 |
设计思维 | 测试绕过设计问题 | 用测试来反推 API 设计合理性 |
这段是在调侃 Goofus 的测试风格:
他写测试完全随心所欲、毫无规范,不考虑代码质量、维护性、稳定性,甚至测试逻辑混乱。
主题:Anything goes…(随意测试)
Goofus 的行为:
- 测试没有边界,没结构,什么都测。
- 依赖不该依赖的东西,比如内部状态、私有方法、环境变量、甚至硬编码数据。
- 没有考虑测试的可重复性、独立性,随便写完就算。
- 测试代码乱,甚至对主业务逻辑没啥帮助。
为什么这不好?
- 测试不稳定,一改底层就挂。
- 维护成本高,别人看不懂,也改不了。
- 误导开发者,看测试不懂 API 用法,误导业务判断。
- 浪费时间资源,测试执行没效率,问题没抓到。
Gallant 的做法:
- 有计划、有边界,测试覆盖合理且重点突出。
- 只测试自己负责的代码和行为,不乱依赖外部或私有实现。
- 测试代码简洁、清晰、有文档,像示例一样教别人用。
- 保证测试独立、可重复,随时能跑,能帮忙捕捉问题。
中文总结:
Goofus | Gallant | |
---|---|---|
测试态度 | “想测啥测啥” | 有规划、有原则的测试 |
测试范围 | 无边界、随意 | 重点突出、聚焦职责范围 |
测试质量 | 混乱、不稳定、难维护 | 稳定、清晰、易维护 |
总结金句:
**“Anything goes” 是对测试质量的最大威胁。
好的测试,是有原则、有界限的艺术。**
这段其实是在对比 Goofus 和 Gallant 对测试依赖范围的不同态度。
主题:依赖的边界 — 只依赖公开 API
Goofus 的做法:
- 他写测试时依赖太多“内部细节”、“私有实现”,甚至“隐藏的约定”。
- 这种测试容易脆弱、易碎。
- 换句话说,他“偷偷依赖”了非公开的、不稳定的东西——
这就像“嘘!别吵,妈妈睡觉了”,暗示这些是不该被碰的敏感东西。
Gallant 的做法:
- 只依赖公开且文档保证的 API 行为。
- 测试即是规范,且对外部用户透明。
- 只要API没改,测试就稳定。
中文总结:
角色 | 依赖范围 | 风险/优势 |
---|---|---|
Goofus | 依赖私有实现、隐藏细节 | 测试脆弱,易碎,不稳定 |
Gallant | 只依赖公开的、文档保障的API | 稳定、透明、可维护 |
总结金句:
“测试就像孩子,不该去吵妈妈睡觉——那些私有实现和内部细节,别随便碰!”
这段讲的是测试的韧性(Resilience),也就是测试的稳定性和可靠性问题,强调 Goofus 的测试写得很糟糕,经常失败,而且失败的原因让人摸不着头脑。
主题:Resilience(韧性)
Goofus 的测试问题:
- Flaky tests(不稳定测试)
有时通过,有时失败,测试结果像“碰运气”。 - Brittle tests(脆弱测试)
稍微代码变动或环境变更就挂。 - Tests that depend on execution ordering(依赖执行顺序的测试)
顺序一变,测试挂了。 - Mocks with deep dependence upon underlying APIs(过度依赖底层 API 的 Mock)
Mock 写得太复杂,紧耦合到底层实现。 - Non-hermetic tests(非隔离测试)
测试之间互相影响,或受外部环境干扰(如文件系统、网络状态、时间)。
Gallant 的做法:
- 写稳定的测试,保证无论何时、环境如何都能跑通。
- 测试独立,每个测试自包含,不依赖其他测试的状态。
- 避免顺序依赖,测试运行顺序任意都通过。
- Mock 设计合理,只模拟必要接口,不与底层紧耦合。
- 保证测试环境隔离(hermetic),用虚拟资源、Stub、Fake 隔离外部依赖。
中文总结:
问题类型 | Goofus | Gallant |
---|---|---|
测试稳定性 | 不稳定、脆弱、顺序依赖 | 稳定、健壮、无序执行都正确 |
Mock 设计 | 复杂耦合、难维护 | 简单抽象、关注行为接口 |
测试隔离 | 互相影响、受外部环境影响 | 完全隔离、环境可控 |
这段代码是典型的 Resilience(韧性) 反面示例,特别是 flaky tests(不稳定测试) 的经典写法。
具体问题解析:
TEST(UpdaterTest, RunsFast) {Updater updater;updater.UpdateAsync();SleepFor(Seconds(.5)); // 等半秒钟,期望更新完成EXPECT_TRUE(updater.Updated());
}
为什么这测试很脆弱、不稳定?
- 用固定时间等待异步操作完成,
SleepFor(Seconds(.5))
,是个“魔法数字”; - 如果机器快,半秒够用,测试通过;
- 如果机器慢或CPU繁忙,或者网络延迟等因素,半秒不够,测试失败;
- 结果:同一个构建、同一个测试环境,有时通过,有时失败 —— flaky test。
Gallant 推荐的写法:
- 避免用固定时间等待,改为“轮询”或“等待事件完成”机制;
- 用同步回调、条件变量、信号量等同步手段,保证异步操作完成后才断言;
- 这样测试才能稳定、可重复。
示例伪代码:
TEST(UpdaterTest, RunsFast) {Updater updater;updater.UpdateAsync();// 等待条件,最多等 5 秒,每 10ms 检查一次ASSERT_TRUE(WaitForCondition([&]() { return updater.Updated(); }, 5s));
}
中文总结:
测试写法 | 风险/缺陷 | 改进方法 |
---|---|---|
固定时间睡眠等待 | flaky,环境差异导致测试结果不稳定 | 轮询等待异步完成,或事件同步 |
轮询或事件驱动等待 | 测试稳定,避免不确定因素 | 推荐做法 |
结论:
**用固定睡眠时间等待异步完成是制造 flaky 测试的最快捷径,
好的测试应该智能等待事件完成,而非盲目等待。**
这段代码展示了 Resilience(韧性) 中的另一个常见问题 —— 脆弱测试(Brittle tests)。
具体问题分析:
TEST(Tags, ContentsAreCorrect) {TagSet tags = {5, 8, 10};// TODO(goofus): Figure out why these are ordered funny.EXPECT_THAT(tags, ElementsAre(8, 5, 10));
}
问题点:
TagSet tags = {5, 8, 10};
初始化顺序是5, 8, 10
;- 断言却期待顺序是
8, 5, 10
,顺序不匹配; - 注释表明作者都不明白为什么顺序“奇怪”,说明测试依赖了 一个不稳定或不确定的顺序;
- 如果实现变更导致元素顺序变了(但内容没变),测试就会失败;
- 这个测试对顺序非常敏感,虽然顺序可能对业务没影响;
- 结果导致测试失败与代码逻辑无关,测试变得脆弱。
Gallant 推荐做法:
- 明确业务逻辑对顺序的要求:
- 如果顺序重要,确保排序逻辑清晰且测试覆盖顺序。
- 如果顺序不重要,使用不关心顺序的断言(比如
UnorderedElementsAre
)。
- 确保测试只关注真正需要验证的行为。
示例:
TEST(Tags, ContentsAreCorrect) {TagSet tags = {5, 8, 10};EXPECT_THAT(tags, UnorderedElementsAre(5, 8, 10)); // 顺序无关紧要
}
中文总结:
类型 | Goofus 写法 | Gallant 写法 |
---|---|---|
测试脆弱性 | 断言顺序但业务无顺序保证 | 使用无序断言或明确排序要求 |
测试稳定性 | 代码无变动,顺序变动导致失败 | 测试只关注必要的行为 |
这段代码展示了 Goofus 写的 脆弱测试(Brittle tests) 的典型问题,涉及日志捕获和断言。
具体问题分析:
TEST(MyTest, LogWasCalled) {StartLogCapture();EXPECT_TRUE(Frobber::Start());EXPECT_THAT(Logs(), Contains("file.cc:421: Opened file frobber.config"));
}
问题点:
- 测试依赖日志内容和具体文件名、行号
"file.cc:421: Opened file frobber.config"
; - 代码改动(如文件名变更、代码重构导致行号变动)会导致测试失败,虽然功能正常;
- 这使得测试对无关代码改动非常敏感,难维护;
- 这样的测试不稳定且脆弱,属于典型的“与被测代码无关的失败”;
Gallant 推荐做法:
- 避免断言日志中带有具体文件名和行号,或者对这些做抽象封装;
- 断言日志内容时,只关注核心关键信息,比如
"Opened file frobber.config"
; - 使用更高层次的断言,避免绑定实现细节;
- 或者,测试应该关注业务行为,而非内部日志细节,除非日志本身是产品输出且有明确需求。
示例改进:
TEST(MyTest, LogWasCalled) {StartLogCapture();EXPECT_TRUE(Frobber::Start());EXPECT_THAT(Logs(), Contains("Opened file frobber.config"));
}
中文总结:
类型 | Goofus 写法 | Gallant 写法 |
---|---|---|
测试脆弱性 | 断言具体文件名和行号,易因重构失败 | 断言核心日志内容,避免依赖细节 |
可维护性 | 低,代码变动频繁导致测试维护成本高 | 高,关注业务本质,易于维护 |
这段代码是 Resilience(韧性) 中关于 测试顺序依赖(Ordering dependency) 的典型反面案例。
具体问题分析:
static int i = 0;
TEST(Foo, First) {ASSERT_EQ(0, i);++i;
}
TEST(Foo, Second) {ASSERT_EQ(1, i);++i;
}
问题点:
- 两个测试用例之间共享全局状态
i
,并依赖测试的执行顺序; First
测试期望i == 0
并自增;Second
测试期望i == 1
并自增;- 如果单独运行
Second
测试,它会失败,因为i
不是 1; - 如果改变测试执行顺序,也会失败;
- 这种测试耦合度高、脆弱且难维护,测试之间不独立。
Gallant 推荐做法:
- 每个测试应独立,无论顺序如何运行都能通过;
- 不依赖或共享全局状态;
- 使用测试框架提供的 Setup/TearDown 函数初始化环境。
改进示例:
TEST(Foo, First) {int i = 0; // 局部变量,互不影响ASSERT_EQ(0, i);++i;
}
TEST(Foo, Second) {int i = 0; // 独立环境,避免依赖顺序i = 1;ASSERT_EQ(1, i);++i;
}
中文总结:
问题类型 | Goofus 写法 | Gallant 写法 |
---|---|---|
测试间依赖 | 共享全局状态,依赖顺序 | 测试相互独立,不共享状态 |
运行顺序敏感 | 仅顺序执行正确,单独执行失败 | 任意顺序或单独执行均可通过 |
维护难度 | 高,修改一处影响全局 | 低,测试自包含,易于维护 |
这段代码展示了 Resilience(韧性) 中的 非隔离测试(Nonhermeticity) 问题,也就是测试彼此之间互相影响,导致测试不稳定。
具体问题分析:
TEST(Foo, StorageTest) {StorageServer* server = GetStorageServerHandle();auto my_val = rand();server->Store("testkey", my_val);EXPECT_EQ(my_val, server->Load("testkey"));
}
问题点:
- 该测试使用的是一个共享的
StorageServer
资源(可能是全局服务或共享存储); - 使用固定的 key
"testkey"
来存储和读取数据; - 如果多个测试或不同人同时运行此测试,会导致写入和读取的 key 互相冲突(覆盖);
- 这种冲突会导致测试结果不确定,容易失败,测试不稳定;
- 测试缺乏隔离性(hermetic),容易互相干扰。
Gallant 推荐做法:
- 为每个测试使用独立的资源/环境,避免共享状态;
- 生成唯一的 key 或使用隔离的存储空间,防止冲突;
- 使用 mock、fake 或独立的测试服务器实例;
- 或在测试开始时清理环境,确保环境干净。
改进示例:
TEST(Foo, StorageTest) {StorageServer* server = GetStorageServerHandle();auto unique_key = "testkey_" + std::to_string(GetUniqueTestId());auto my_val = rand();server->Store(unique_key, my_val);EXPECT_EQ(my_val, server->Load(unique_key));
}
或者用测试专用的独立存储实例:
TEST(Foo, StorageTest) {StorageServer server; // 本地独立实例auto my_val = rand();server.Store("testkey", my_val);EXPECT_EQ(my_val, server.Load("testkey"));
}
中文总结:
问题类型 | Goofus 写法 | Gallant 写法 |
---|---|---|
测试共享资源 | 共享存储,key 固定,冲突导致失败 | 使用唯一 key 或独立存储,避免冲突 |
测试隔离性 | 低,测试间相互干扰 | 高,测试环境相互独立 |
测试稳定性 | 脆弱,易受其他测试影响 | 稳定,结果可预测 |
这段代码体现了 Resilience(韧性) 中 深度依赖(Deep Dependence) 的典型问题,尤其是对 Mock 对象和被测代码实现细节的强耦合。
具体问题分析:
class File {
public:...virtual bool Stat(Stat* stat);virtual bool StatWithOptions(Stat* stat, StatOptions options) {return Stat(stat); // Ignore options by default}
};
TEST(MyTest, FSUsage) {...EXPECT_CALL(file, Stat(_)).Times(1);Frobber::Start();
}
问题点:
- 测试里对
File
类的Stat
方法做了精确调用次数断言EXPECT_CALL(file, Stat(_)).Times(1)
; - 这种写法对
File
类的内部实现非常敏感,如果File
类重构,比如改用StatWithOptions
或改变调用细节,测试会失败; - 这导致测试对代码改动“脆弱”,无法容忍合理的重构和改进;
- 测试关注了细节实现而非行为或结果,使得测试维护成本高,容易频繁修改测试代码。
Gallant 推荐做法:
- 测试应关注行为和结果,避免对实现细节(如具体函数调用次数)做过多断言;
- 如果确实需要 Mock,尽量对外部接口行为做断言,而非内部实现;
- 保持 Mock 的抽象层次合适,防止对底层细节耦合过深。
改进思路示例:
TEST(MyTest, FSUsage) {// 断言文件操作最终效果或状态变化,而非具体调用Frobber::Start();EXPECT_TRUE(CheckFileStatus()); // 用行为结果替代调用次数断言
}
中文总结:
问题类型 | Goofus 写法 | Gallant 写法 |
---|---|---|
测试对实现细节依赖 | 断言具体函数调用及次数 | 关注行为效果,不依赖内部调用细节 |
测试维护难度 | 高,代码稍改即需修改测试 | 低,允许代码重构,测试依然有效 |
测试稳定性 | 脆弱,代码改动引起大量测试失败 | 稳定,测试仅在功能变更时失败 |
这段是对测试写作的总结和目标梳理,明确了编写好测试的关键原则,同时用“别做 Goofus”的方式提醒避免常见错误。
测试写作的目标 Recap
目标编号 | 目标描述 | 说明 |
---|---|---|
0 | 写测试 | 先有测试,才有保障 |
1 | 写能测到你想测内容的测试 | 测试聚焦于正确的目标,别跑偏 |
2 | 写可读的测试,能通过审查判断测试是否正确 | 测试代码简洁清晰,别人一看就懂测试意图 |
3 | 写完整的测试,覆盖所有边界和极端情况 | 不放过任何可能的边界情况,保证测试全面 |
4 | 写演示性的测试,展示如何正确使用 API | 测试本身也是 API 使用的示范文档 |
5 | 写有韧性的测试,测试是封闭的,只有行为不合规时才失败 | 测试稳定可靠,不会轻易因为无关改动而失败 |
额外提醒:
- Don’t be Goofus
避免那些写错测试、写不完整、写脆弱、写混乱、写依赖细节、写不可读的“Goofus”式的错误做法。