前篇:单元测试被高估了(1)
测试金字塔驱动的测试
为什么我们会决定将单元测试作为测试的主要方法呢?在很大程度上,这是因为更高级别的测试一直被认为太难、太慢和不可靠。
如果参考传统的测试金字塔,您会发现它建议测试的最重要部分应该在底层的单元测试。原因是,更高层次的测试认为是更慢且更复杂的,因此您需要将精力集中在金字塔的底部,以获得高效且可维护的测试:
金字塔模型旨在传达好的测试方法应该涉及许多不同的层。专注于极端可能会导致测试太慢,或者无法为软件开发带来任何信心。也就是说,人们往往以为单元测试的投资回报率最高。
更高层次的测试尽管为软件开发带来更多的信心,但通常最终会变得缓慢、难以维护或过于广泛而无法包含在快节奏的开发流程中。这就是为什么在大多数情况下,此类测试由专门的 QA 专家单独维护,编写它们通常不被认为是开发人员的工作。
集成测试是介于单元测试和完整端到端测试之间的部分,通常被完全忽略:因为不清楚在哪个级别上进行集成,以及如何构建和组织这样的测试,或者担心它们可能会失控。许多开发人员宁愿避免它们,而倾向于选择更明确的单元测试。
由于这些原因,在开发期间完成的所有测试通常都位于金字塔的最底部。事实上,这种情况非常普遍,以至于测试和单元测试现在实际上是彼此的同义词。而且这种概念的混淆会通过会议演讲、博客文章、书籍甚至一些 IDE 进一步传播。
在大多数开发人员的眼中,测试金字塔看起来有点像下面这样:
虽然测试金字塔作为解决软件测试问题是一个伟大的尝试,但这种模型显然存在许多问题。它所依赖的假设可能并不适用于所有情况,尤其是高层次的测试将使测试变得缓慢或困难这个前提。
人类自然地倾向于依赖那些更有经验的人传递的信息,这样我们就可以从过去几代人的知识中受益,并将我们的第二思维应用到更有用的事情上。这个重要的进化特征,使我们的物种非常适合生存。
然而,当我们将经验作为指导方针时,我们容易不加思考地就认为它们本身就是好的,而忘记了它们与环境的相关性。现实情况是,环境会发生变化,曾经完全合理的结论(或最佳实践)可能不再适用。
如果我们回头看,很明显,在 2000 年甚至 2009 年,做高层次的自动化测试很困难,但现在已经是 2020 年了。事实上,我们不生活在过去而是生活在未来。技术和软件设计的进步使曾经阻碍进行高层次的自动化测试的原因不复存在了。
如今,大多数现代应用程序框架都提供了某种用于测试的单独的 API 层,您可以在与真实环境非常接近的模拟环境中运行应用程序。像 Docker 这样的虚拟化工具还可以执行依赖于实际基础设施的测试,并且同时保证速度与准确性。
我们有像Mountebank、WireMock、GreenMail、Appium、Selenium、Cypress和无数其他的解决方案,它们简化了曾经被认为无法实现的高级测试的不同方面。除非您正在为 Windows 开发桌面应用程序并且被 UIAutomation 框架所困扰,不然会有很多可用的选项。
在我之前的一个 Web 项目,它在系统边界运行近百个行为测试,并行运行只需不到 10 秒。当然,单元测试执行得会更快,但考虑到他们为软件开发带来的信心,前者显得不费吹灰之力。
然而,执行速度慢并不是测试金字塔唯一的错误假设。将大部分测试集中在单元级别只有在这些测试真正有用的情况下才奏效,这取决于被测代码中包含多少业务逻辑。
有些应用程序可能有很多业务逻辑(例如工资系统),有些可能几乎没有(例如 CRUD 应用程序),大多数介于两者之间。我个人从事的大多数项目都不必进行广泛的单测覆盖,但另一方面,项目中往往有很多不同的组件,故相比单元测试,集成测试会更有帮助。
理想中,人们会评估项目的背景并提出最适合手头工作的测试方法。然而,实际上,大多数开发人员根本没有时间或不会去仔细考虑它,而是盲目地按照最佳实践一头扎进堆积成山的单元测试。
总体来说,测试金字塔提供的模型过于简单化。从测试金字塔底部到顶部,测试所带来的对软件开发的信心越多,测试的可维护性与速度的损耗也越多。单单考虑测试金字塔最底部与最顶部两个极端,结论可能是正确的,但对于介于两者之间的部分,就不一定如此了。
它也没有说明这样一个事实,即隔离本身是有代价的,而不是能够通过“避免”外部交互来实现。考虑到编写和维护 Mock 需要付出巨大的努力,一个不太孤立的测试可能更容易实现测试目标,而且为软件开发带来更多的信心,尽管运行速度稍慢。
如果进一步考虑,测试的代价和速度可能与集成规模不是线性的,并且投资回报率最高的点位于接近中间的某个地方,而不是通常认为的单元测试:
总而言之,当您尝试为项目建立一个高效的测试集时,测试金字塔并不是您一定要遵循的最佳指南。考虑与软件开发上下文有关的实际情况会更有意义,而不是依赖所谓的“最佳实践”。
用户行为驱动的测试
从根本上来说,测试的价值体现在测试能否确定软件正常工作。我们对测试越有信心,在变更代码时就越不需要依靠自己进行回归测试来发现潜在的缺陷,因为我们相信测试会为我们做到这一点。
反过来,这种对测试的信心取决于测试是否与实际用户行为相似。在不了解任何内部细节的情况下,在系统边界运行的测试场景必然会比在较低级别运行的测试为我们带来更多的信心。
从本质上讲,我们从测试中获得的软件开发的信心是衡量测试价值的主要指标。我们的目标也是尽可能的提高该项指标。
当然,也需要考虑其他因素的作用,例如成本、速度、并行化执行的能力等等,这些都很重要。测试金字塔对这些因素如何相互关联做出了强有力的假设,但这些假设并不普遍存在。
此外,这些因素对于增强信心没那么重要。一个测试需要很长时间才能运行完成,但提供了很多对代码的信心,仍然比一个非常快速和简单但什么都没做的单元测试有用得多。
出于这个原因,我发现应该尽可能地编写高度集成的测试,并且同时保证速度和复杂性在合理范围内。
这是否意味着我们编写的每个测试都应该是端到端的测试?答案是否定的,但我们应该尽可能朝着这个方向努力,同时将不利因素保持在可接受的水平。
可接受的水平应视具体项目开发上下文而定。归根结底,这些测试由开发人员编写并在开发过程中使用,这意味着它们不应该成为维护的负担,并且应该可以在本地构建和 CI 上运行。
这样做意味着您最终用例集中可能会有一批集成规模不同的测试用例,并且似乎没有清晰的代码结构。在单元测试中不会遇到这个问题,因为每个测试都与特定的方法或函数耦合,因此单元测试的结构通常映射了代码本身的结构。
需要说明的是,有没有清晰的结构并不重要,因为是否按类或模块组织测试并不重要,清晰的结构只是单元测试的副作用。相反,测试应该按照它们要验证的实际面向用户的功能进行划分。
此类测试基于软件的功能需求,描述软件具有哪些功能以及它们如何工作,因此通常被称为功能测试。功能测试不是测试金字塔中的某一层,而是与之正交的一个概念。
与流行的观点相反,编写功能测试不需要您使用 Gherkin 或 BDD 框架,它与单元测试使用相同的工具。例如,下面重写文章开头的示例,以便围绕用户行为而不是代码单元构建测试:
public class SolarTimesSpecs
{
[Fact]
public async Task User_can_get_solar_times_automatically_for_their_location() { /* ... */ }
[Fact]
public async Task User_can_get_solar_times_during_periods_of_midnight_sun() { /* ... */ }
[Fact]
public async Task User_can_get_solar_times_if_their_location_cannot_be_resolved() { /* ... */ }
}
注意,虽然测试被标记为[Fact]
但实际上是功能测试,故这里隐藏了测试的实际实现。需要强调的是,测试及其结构是由需求驱动的,理论上功能测试可能位于测试金字塔的任何位置。
根据需求而不是类来命名测试还有一个额外的好处,那就是消除不必要的耦合。例如,现在我们决定重命名SolarCalculator
为其他名称或将其移动到不同的目录,功能测试中则无需更新用例名称来反映这一点。
通过使用这种结构,用例所构成的不仅仅是测试集同时也是一份文档。例如,这是在CliWrap中组织测试集的方式(下划线被xUnit替换为空格):
只要软件在完成一些事情,总是会有功能需求。需求可以是正式的(规范文件、用户故事等)或非正式的(口头约定、承诺等)
将非正式规范转变为功能测试通常很困难,因为它需要我们从外部观察,让自己从用户的角度思考。在我的开源项目中,我认为对实践有帮助的是,首先创建一个自述文件,其中列出了一堆相关的使用示例,然后将它们编码到测试中。
总而言之,我们可以得出结论,应该尽可能根据用户行为而不是代码的内部结构来划分测试。
将上述两条指导方针结合起来就形成了一个思维框架,为我们提供编写测试的明确目标和良好的组织方式,同时这个框架不依赖任何假设。我们可以使用它为项目建立一个专注于用户价值的测试集,然后根据当前开发任务的优先级和限制对其进行扩展。
我们不必再纠结单元测试或集成范围,而是应该面向用户功能构建测试集,同时尽可能准确地覆盖该功能。
{测试窝原创译文,译者lukeaxu}