下面的内容是我在作为一名程序员入职之前阅读的由Gergely Orosz写的The Software Engineer’s Guidebook。我将将阅读时得到的重要的信息总结成中文以供大家分享。
软件测试是一种健康的软件工程实践,其重要性足以在本书中占据单独的一章。
如何确保一个软件按预期工作?答案是测试。广义上讲,测试可以分为三种方式:
测试是任何科技公司工程文化的核心部分。问题不在于工程团队是否进行测试,而在于他们如何测试,以及使用哪些测试方法。
单元测试是最简单的自动化测试。它们用于测试一个独立的组件,即”单元”。大多数移动端单元测试针对的是一个类、一个方法或一个类的行为。
随着代码库的增长,对具有多个依赖项的类进行单元测试变得具有挑战性且耗时,除非在代码库中引入依赖注入。虽然一开始这可能看起来有些繁琐,但它使代码库变得可单元测试,值得投入。同时,它还能使类的依赖关系更加明确,应用程序的架构更加模块化。
代码库越大,单元测试具备以下特征就越重要:
单元测试代码带来的好处是累积的,随着系统和开发团队的成长而增加。测试有助于验证代码变更,迫使开发者分离关注点,并帮助记录代码行为。它们还有助于减少代码库中的意外回归,在重构代码时充当安全网。
我曾在一个团队工作,其中一位最资深的开发人员不相信编写单元测试的必要性。我们暂且称他为”Sam”。他是一位C++大师,在游戏行业工作了十年,参与过多个大型热门游戏的开发,负责编写游戏引擎中最复杂的部分。Sam坚称他的代码几乎从不出现bug,即便出现,单元测试也无法检测到。基于这一点,Sam声称任何形式的单元测试都是浪费时间,他应该把精力放在更有生产力的任务上。
这导致我们的团队分成两派:一派编写单元测试,另一派则认同Sam的观点,认为单元测试不值得花时间。
一切正常,直到Sam对代码库进行了一次大规模重构。当他提交更改时,一切都乱了套。应用程序到处出现故障,花了两天时间才修复所有问题。Sam声称这些故障是不可避免的,并非他的错;实际上,他的代码变更暴露了已存在的问题。尽管如此,更多人开始质疑他”我的代码不会出bug”的说法。
单元测试失败是因为Sam的更改引入了几个回归问题。当Jess和Sam一起分析这些变更的原因时,每一个问题都源于Sam的代码修改。Sam不情愿地一一修复了这些回归问题,并在测试套件通过后提交了代码。Jess向Sam指出了她得出的明显结论:“看来你确实会在代码中犯错,而单元测试确实能捕捉到这些错误。前提是要编写单元测试。”
这次事件之后,Jess建立了一个运行单元测试套件的CI系统,团队决定工程师们要为自己的代码编写单元测试。由于Jess推动了整个团队的质量提升,bug数量显著减少,Sam的更改也不再经常破坏主分支,Jess很快被提升为团队领导。
我从这次经历中吸取了两个教训。首先,没有单元测试也可以开发出优秀的软件。Sam确实在没有测试的情况下成功发布了十年的游戏。然而,在游戏开发工作室,有手动测试人员和其他特定于游戏的高级测试方法,比如AI玩游戏。基本上,在某些环境中,单元测试的意义较小,回归问题通过其他方式被捕获。
第二个收获是,没有单元测试会使重构大型代码库变得困难,容易引入bug。如果Sam从一开始就编写单元测试,并在重构时也这样做,就会有一个安全网来验证代码是否按预期工作。这样可以节省大量时间,避免不必要的挫折。
集成测试是对多个单元进行同时测试的方法。它比单元测试更复杂,通常涉及多个类,测试它们如何协同工作。
集成测试包括测试两个或更多”单元”的协同工作,例如同时测试业务逻辑”单元”和数据库”单元”。它也可能意味着测试两个服务之间的正确交互,同时模拟这些服务之外的其他依赖项。
集成测试介于非常简单的单元测试和更复杂的端到端测试之间。因此,一些团队使用与单元测试相同的库来编写集成测试,只是他们不会模拟测试的所有依赖项。而有些团队则使用端到端测试中也会用到的框架来进行集成测试,但他们会为测试模拟一些组件。
UI测试(通常称为”端到端”测试)会启动应用程序,然后模拟用户输入来测试应用。这种测试不使用任何模拟;测试会运行网页或移动应用程序,并使用UI自动化来模拟用户输入,如点击、文本输入和其他操作。
UI测试的最大优点是它最接近模拟应用程序的真实使用情况,通过经历用户体验的相同流程。然而,这种更强大的测试能力也有一个权衡:测试更容易出错,且速度较慢。
测试更容易出错:在单元测试和集成测试中,UI测试往往最容易出错。这是因为端到端测试可能会因为一些小问题而失败,比如按钮文本的改变导致测试无法定位它。
速度慢:由于需要等待网络响应,这些测试的延迟更高。此外,某些场景可能难以模拟;例如,让服务器返回特定的错误消息。
一些工程师会构建不完全端到端的测试,模拟网络层。这样做是为了加快测试速度,并使测试特殊网络响应的边缘情况变得更容易。有些团队将这种测试称为集成测试,而其他团队仍然使用UI测试的名称。我的观点是,名称不是最重要的,关键是团队对集成测试和UI测试的含义达成共识。
以下是单元测试、集成测试和端到端测试的常见特征:

需要注意的是,这是一个概括,具体情况可能因平台、语言和环境而异。例如,某些平台上有一些端到端测试框架,使编写和维护端到端测试变得更容易,在某些环境中可能与编写或维护单元测试相当。
假设表格是准确的,那么单元测试、集成测试和端到端测试的理想比例是多少?这里有两个流行的思维模型。

测试金字塔:一个建议编写更简单、易于维护的测试(如单元测试)的思维模型
测试金字塔由Mike Cohn在其2009年的著作《敏捷成功之道》中首次提出。测试金字塔的理念是尽可能多地用单元测试覆盖测试表面,尽可能少地使用UI测试,而集成测试则介于两者之间。这个模型很快被采纳,成为软件工程行业最常见的测试模型。
对于几乎没有UI的后端系统,测试金字塔方法通常效果很好。对于一些难以进行端到端测试和集成测试的原生移动应用程序,它也很适用。
测试金字塔在前端开发中的用处较小。在构建前端时,单元测试的作用往往较小,而集成测试的作用更大。
测试奖杯是软件工程师Kent C. Dodds在2019年提出的概念。这个想法受到Vercel创始人兼CEO Guillermo Rauch的见解启发:“编写测试。不要太多。主要是集成测试。”
它看起来是这样的:

为什么会出现测试奖杯?这是因为自测试金字塔出现以来,许多事情发生了变化,尤其是在前端开发方面:
对于前端开发来说,集成测试确实能在编写时间和覆盖范围方面提供最佳的”投资回报”。对于全栈应用程序来说,这一点也越来越正确。你可以在Kent C. Dodds的文章《测试奖杯和测试分类》中阅读更多关于测试奖杯的内容。
没有一种单一的最佳方法来投资测试。测试金字塔和测试奖杯是思考自动化测试类别的思维模型。但是,与其试图让你的测试方法适应一个模型,不如反过来做。
思考什么样的自动化测试对你的系统最有利。然后选择你的方法并不断完善。如果它不符合某个模型,也不用担心。自动化测试实现其目标 - 即使在团队快速行动的情况下也能确保质量 - 比符合某个思维模型更重要。
在寻求测试方法指导时,与处于相似阶段或相似行业的公司的工程师交流可能会有所帮助。例如,Meta是一家历来较少投资自动化测试,但更多投资于监控和自动化部署的公司,这得益于其数十亿用户使用其产品。银行和更传统的公司发布频率较低,往往更多投资于手动测试,而像Google或Uber这样的大型科技公司则大量投资于单元测试和集成测试。
除了单元测试、集成测试和UI测试这些通用类别外,还有几种更专业的自动化测试,在特定情况下可能会很有用。
这些测试用于衡量系统的延迟或响应能力。性能测试可以用于多种情况,例如验证代码更改:
做好自动化性能测试很棘手,因为在捕获应用程序性能时有很多细微差别,比如其他进程影响被测目标代码的性能,非确定性事件影响测量,或者测量在不同机器上运行导致结果难以比较。
负载测试确保系统在特定负载下能够正常运行。例如,一家电商公司知道在黑色星期五时流量会是平时的10倍,想要测试其后端系统是否能以合理的延迟响应。这时就需要进行负载测试。
负载测试专用于后端系统,有几种方法可以进行:
大约在2008年,Netflix将其架构从单体设置转变为分布在数百个小型服务上的架构。运行如此多的服务有助于减少单点故障,但它导致了看似随机的中断,其中一个小服务的异常或宕机会导致其他看似不相关的系统部分也出现故障。
Netflix工程团队想出了一种非常规的方法来模拟这些中断。2010年的一篇博客文章解释道:
“我们工程师在AWS中构建的第一个系统之一被称为’混沌猴子’。混沌猴子的工作是随机杀死我们架构中的实例和服务。如果我们不不断测试我们在失败情况下成功的能力,那么在最需要时 - 意外中断发生时 - 它可能就无法正常工作。”
Netflix后来开源了其混沌猴子实现,这在运行大量服务的公司中很受欢迎。基础设施团队采用类似的方法,关闭服务或故意降低其性能,以观察系统如何响应。
快照测试将测试输出与预先记录的输出进行比较。它们在网页和移动开发中最为常见,“快照”通常是屏幕的图像。测试会比较网页或移动应用是否与快照完全一致。
快照测试在移动应用的UI验证中特别流行。与移动UI测试相比,它们编写成本低、运行速度快。当测试失败时,可以直接比较测试生成的图像和参考图像,很容易进行调试。
然而,使用快照测试套件进行UI验证的一个主要缺点是,用于比较的参考图像可能很快变得太大,无法与测试代码存储在同一个代码库中。对于拥有大量快照测试的公司来说,将参考图像存储在代码库之外是很常见的做法。
对于移动应用和网页应用,应用的大小或初始加载的包的大小可能是一个关注点。一些团队会设置监控,当移动应用或网页包的大小超过给定大小时发出警报。
“冒烟测试”这个术语来源于电子硬件测试。在Cem Kaner、James Bach和Brett Pettichord合著的《软件测试中的经验教训》一书中,他们是这样定义这个术语的起源的:
“‘冒烟测试’这个短语来自硬件测试。你插入一块新的电路板并打开电源。如果你看到电路板冒烟,就立即关闭电源。”
电路板冒烟是不好的,因为这意味着电路板正在熔化。冒烟测试的理念是运行简单的测试(几乎总是自动化的),以验证产品是否存在明显的问题。
冒烟测试是完整测试套件的一个子集,旨在频繁执行,并在任何生产发布之前执行。以下是一些常见的冒烟测试示例:
健全性测试是一组手动测试,应在每次主要发布前运行,以确认应用按预期工作。这些测试可能由工程团队或专门的质量保证团队执行。
健全性测试通常有详细的执行指南和预期输出说明。有了这些详细说明,当团队有更多资源投入自动化时,这些测试可以成为部分或完全自动化为UI测试或端到端测试的首选候选。
但为什么不是所有的健全性测试都被自动化呢?可能是因为团队还没来得及这么做。在某些情况下,自动化可能不切实际 - 例如,如果某些健全性测试很少运行,团队可能认为构建和维护自动化测试不值得。还有一些测试可能难以自动化,比如查看用户界面并确认布局看起来是否合适。
自动化测试是一个不断发展的领域。最重要的是自动化测试能够帮助验证系统的正确功能。测试的名称不那么重要;重要的是它们能够测试系统的功能。
其他一些类型的自动化测试包括:
长期以来,自动化和手动测试套件都是在专门的测试环境中运行的,比如暂存环境或用户验收测试(UAT)环境。
如今,越来越流行的做法是在终端用户使用的生产环境中进行软件测试。虽然这种方法风险较高,但如果操作得当,相比在专用环境中测试,它具有显著的优势。
如何安全地在生产环境中进行测试?以下是几种方法:
在生产环境中测试的最大优势:
然而,在生产环境中进行测试也存在许多挑战:
编写自动化测试需要相当大的时间投入。那么,它对团队有哪些好处呢?以下是最常见的几点:
验证正确性。任何自动化测试的直接好处是验证代码按照测试指定的方式工作。如果在编写代码之前编写测试(即测试驱动开发,TDD),那么预期会被提前指定,而代码则被编写以满足测试要求。
捕捉回归。通过自动化测试,可以及早发现回归问题。如果自动化测试套件集成到CI系统中,这可以在代码合并之前发生。如果集成到CD系统中,则可以在将代码部署到生产环境之前捕捉到回归问题。
文档。测试可以帮助确立代码的预期功能,以及在边缘情况下的行为。但文档可能会过时,因此测试套件需要保持最新,否则测试将会失败。
契约。测试可以作为验证正式契约的一种方式,比如接口或API应该如何表现,以确保它们完全按照向用户描述的方式运行。
进行大规模更改时的安全网。拥有全面自动化测试套件的代码库为工程师提供了额外的安全保障。由于有测试套件的存在,大规模的代码更改(如主要重构)可以更有信心地进行。
自动化测试也有缺点:
编写时间。最大、最明显的缺点是测试需要时间来编写,这可能让人觉得在假定已经正确的代码上浪费时间。当然,测试的好处不仅仅是验证你的工作。另一个好处是测试可以捕捉到回归问题。
测试速度慢。由于大量的自动化测试或测试运行速度慢,测试套件可能会随着时间的推移变得运行缓慢。缓慢的测试套件可能会减慢开发的节奏。
不稳定的测试。某些类型的测试更容易出现不稳定性,在应用程序正常工作时也会失败。例如,当出现网络延迟时,UI测试可能会失败,但应用程序实际上运行正常。不稳定的测试会引入噪音并降低测试套件的实用性。
维护成本。当更改代码时,相关的测试也必须更新。对于简单的测试(如单元测试)来说,这是直接的。但对于更复杂的测试,使其按预期工作可能比编写代码本身需要更多的努力!
测试是软件工程的核心部分,自软件开发的早期以来就一直如此。编写代码只是开发的第一部分;验证其工作方式、将其部署到生产环境以及维护代码都是后续步骤。自动化测试在代码编写后的所有阶段都有帮助。
对于可维护且能在较长时间内得到支持的软件来说,自动化测试是一个基本要求,也有助于加快对代码库的迭代。因此,应该拥抱测试并尝试各种方法。这样,你就可以建立一个广泛的测试工具集,并为当前项目使用最佳类型的测试。