用于积极测试体验的 8 个自动化测试最佳实践
已发表: 2022-03-11难怪许多开发人员将测试视为浪费时间和精力的必要之恶:测试可能是乏味的、低效的,而且过于复杂。
我的第一次测试体验很糟糕。 我在一个对代码覆盖率有严格要求的团队工作。 工作流程是:实现一个特性,调试它,然后编写测试以确保完整的代码覆盖率。 该团队没有集成测试,只有带有大量手动初始化模拟的单元测试,并且大多数单元测试在使用库执行自动映射时测试了琐碎的手动映射。 每个测试都试图断言每个可用的属性,因此每次更改都会破坏数十个测试。
我不喜欢使用测试,因为它们被认为是一种耗时的负担。 然而,我没有放弃。 每次小改动后的置信度测试和自动化检查都激起了我的兴趣。 我开始阅读和练习,并了解到测试如果做得好,既有益又有趣。
在本文中,我分享了八个我希望从一开始就知道的自动化测试最佳实践。
为什么需要自动化测试策略
自动化测试通常着眼于未来,但是当您正确实施它时,您会立即受益。 使用可以帮助您更好地完成工作的工具可以节省时间并使您的工作更加愉快。
想象一下,您正在开发一个系统,该系统从公司的 ERP 中检索采购订单并将这些订单发送给供应商。 您在 ERP 中有以前订购的商品的价格,但当前价格可能不同。 您想控制是否以更低或更高的价格下订单。 您存储了用户偏好,并且您正在编写代码来处理价格波动。
您将如何检查代码是否按预期工作? 你可能会:
- 在开发人员的 ERP 实例中创建一个虚拟订单(假设您事先设置了它)。
- 运行您的应用程序。
- 选择该订单并开始下订单过程。
- 从 ERP 的数据库中收集数据。
- 从供应商的 API 请求当前价格。
- 覆盖代码中的价格以创建特定条件。
您在断点处停下来,可以一步一步地查看一个场景会发生什么,但是有很多可能的场景:
| 优先 | ERP价格 | 供应商价格 | 我们应该下订单吗? | |
|---|---|---|---|---|
| 允许更高的价格 | 允许更低的价格 | |||
| 错误的 | 错误的 | 10 | 10 | 真的 |
| (这里会有另外三种偏好组合,但是价格是一样的,所以结果是一样的。) | ||||
| 真的 | 错误的 | 10 | 11 | 真的 |
| 真的 | 错误的 | 10 | 9 | 错误的 |
| 错误的 | 真的 | 10 | 11 | 错误的 |
| 错误的 | 真的 | 10 | 9 | 真的 |
| 真的 | 真的 | 10 | 11 | 真的 |
| 真的 | 真的 | 10 | 9 | 真的 |
如果出现错误,公司可能会赔钱,损害其声誉,或两者兼而有之。 您需要检查多个场景并多次重复检查循环。 手动这样做会很乏味。 但是测试可以提供帮助!
测试让您无需调用不稳定的 API 即可创建任何上下文。 它们消除了重复点击旧的和缓慢的界面的需要,这些界面在传统的 ERP 系统中太常见了。 您所要做的就是定义单元或子系统的上下文,然后任何调试、故障排除或场景探索都会立即发生——您运行测试并返回您的代码。 我的偏好是在我的 IDE 中设置一个键绑定,以重复我之前的测试运行,在我进行更改时提供即时、自动的反馈。
1.保持正确的态度
与手动调试和自测相比,自动化测试从一开始就更有效率,甚至在提交任何测试代码之前。 在检查代码的行为是否符合预期之后——通过手动测试,或者对于更复杂的模块,在测试期间使用调试器逐步完成它——你可以使用断言来定义你对任何输入参数组合的期望。
测试通过后,您几乎可以提交了,但还没有完全准备好。 准备重构您的代码,因为第一个工作版本通常并不优雅。 你会在没有测试的情况下执行重构吗? 这是有问题的,因为您必须再次完成所有手动步骤,这可能会降低您的热情。
未来呢? 在执行任何重构、优化或功能添加时,测试有助于确保模块在您更改后仍能按预期运行,从而灌输持久的信心,让开发人员感到更有能力应对即将到来的工作。
将测试视为负担或仅使代码审查员或领导感到高兴的事情会适得其反。 测试是我们作为开发人员从中受益的工具。 我们喜欢我们的代码正常工作,我们不喜欢花时间在重复的操作或修复代码以解决错误上。
最近,我致力于重构我的代码库,并要求我的 IDE 清理未使用的using指令。 令我惊讶的是,测试显示我的电子邮件报告系统出现了几次故障。 然而,这是一个有效的失败——清理过程在我的电子邮件模板的 Razor (HTML + C#) 代码中删除了一些using指令,因此模板引擎无法构建有效的 HTML。 我没想到这么小的操作会破坏电子邮件报告。 测试帮助我避免了在应用发布前花费数小时在应用程序上发现错误,当时我认为一切都会正常工作。
当然,你必须知道如何使用工具,而不是像谚语所说的那样割伤你的手指。 定义上下文似乎很乏味,并且比运行应用程序更难,测试需要太多维护以避免变得陈旧和无用。 这些都是有效的观点,我们将解决它们。
2. 选择正确的测试类型
开发人员经常变得不喜欢自动化测试,因为他们试图模拟十几个依赖项只是为了检查它们是否被代码调用。 或者,开发人员遇到高级测试并尝试重现每个应用程序状态以检查小模块中的所有变化。 这些模式既无效率又乏味,但我们可以通过使用不同的测试类型来避免它们。 (毕竟,测试应该是实用和愉快的!)
读者需要知道什么是单元测试以及如何编写它们,并熟悉集成测试——如果没有,这里值得停下来快速了解一下。
有几十种测试类型,但是这五种常见的类型是非常有效的组合:
- 单元测试用于通过直接调用其方法来测试隔离模块。 依赖项没有被测试,因此,它们被嘲笑。
- 集成测试用于测试子系统。 您仍然使用对模块自己的方法的直接调用,但这里我们关心依赖关系,所以不要使用模拟依赖关系——只有真正的(生产)依赖模块。 您仍然可以使用内存数据库或模拟 Web 服务器,因为它们是基础设施的模拟。
- 功能测试是针对整个应用程序的测试,也称为端到端 (E2E) 测试。 您不使用直接呼叫。 相反,所有交互都通过 API 或用户界面进行——这些是从最终用户角度进行的测试。 但是,基础设施仍然受到嘲笑。
- Canary 测试类似于功能测试,但具有生产基础设施和一组较小的操作。 它们用于确保新部署的应用程序正常工作。
- 负载测试类似于金丝雀测试,但具有真实的登台基础设施和更小的操作集,这些操作会重复很多次。
并不总是需要从一开始就使用所有五种测试类型。 在大多数情况下,您可以通过前三个测试走很长的路。
我们将简要检查每种类型的用例,以帮助您选择适合您需求的用例。
单元测试
回想一下具有不同价格和处理偏好的示例。 它是单元测试的一个很好的候选者,因为我们只关心模块内部发生的事情,并且结果具有重要的业务影响。
该模块有很多不同的输入参数组合,我们希望为每个有效参数组合获得一个有效的返回值。 单元测试擅长确保有效性,因为它们提供了对函数或方法的输入参数的直接访问,并且您不必编写数十种测试方法来涵盖每种组合。 在许多语言中,您可以通过定义一个方法来避免重复测试方法,该方法接受您的代码和预期结果所需的参数。 然后,您可以使用您的测试工具为该参数化方法提供不同的值集和期望值。
集成测试
当您对模块如何与其依赖项、其他模块或基础设施交互感兴趣时,集成测试非常适合。 您仍然使用直接方法调用,但无法访问子模块,因此尝试测试所有子模块的所有输入法的所有场景是不切实际的。
通常,我更喜欢每个模块有一个成功场景和一个失败场景。
我喜欢使用集成测试来检查依赖注入容器是否构建成功,处理或计算管道是否返回预期结果,或者是否从数据库或第三方 API 正确读取和转换复杂数据。
功能或 E2E 测试
这些测试让您对您的应用程序正常工作充满信心,因为它们验证您的应用程序至少可以在没有运行时错误的情况下启动。 在不直接访问其类的情况下开始测试您的代码需要做更多的工作,但是一旦您理解并编写了前几个测试,您会发现这并不太难。
如果需要,通过使用命令行参数启动进程来运行应用程序,然后像潜在客户一样使用应用程序:调用 API 端点或按下按钮。 这并不难,即使是在 UI 测试的情况下:每个主要平台都有一个工具可以在 UI 中找到视觉元素。
金丝雀测试
功能测试让您知道您的应用程序是否在测试环境中工作,但生产环境呢? 假设您正在使用多个第三方 API,并且您想要拥有它们状态的仪表板,或者想要查看您的应用程序如何处理传入的请求。 这些是金丝雀测试的常见用例。
它们通过简单地作用于工作系统来运行,而不会对第三方系统造成副作用。 例如,您无需下订单即可注册新用户或检查产品可用性。
金丝雀测试的目的是确保所有主要组件在生产环境中协同工作,不会因为凭证问题而失败。
负载测试
负载测试揭示了当大量人开始使用您的应用程序时,它是否会继续工作。 它们类似于金丝雀和功能测试,但不在本地或生产环境中进行。 通常会使用特殊的暂存环境,类似于生产环境。
需要注意的是,这些测试不使用真正的第三方服务,这可能会对其生产服务的外部负载测试不满意,因此可能会收取额外费用。
3. 保持测试类型分开
在设计自动化测试计划时,应将每种类型的测试分开,以便能够独立运行。 虽然这需要额外的组织,但这是值得的,因为混合测试会产生问题。
这些测试有不同:
- 意图和基本概念(因此将它们分开为下一个查看代码的人提供了一个很好的先例,包括“未来的你”)。
- 执行时间(因此首先运行单元测试可以在测试失败时加快测试周期)。
- 依赖项(因此仅加载测试类型中需要的那些会更有效)。
- 所需的基础设施。
- 编程语言(在某些情况下)。
- 在持续集成 (CI) 管道中或之外的位置。
重要的是要注意,对于大多数语言和技术堆栈,您可以将所有单元测试与以功能模块命名的子文件夹组合在一起。 这很方便,减少了创建新功能模块时的摩擦,更容易自动构建,减少混乱,并且是简化测试的另一种方法。
4. 自动运行你的测试
想象一下这样一种情况,您已经编写了一些测试,但在几周后提取您的 repo 后,您注意到这些测试不再通过。
这是一个令人不快的提醒,即测试是代码,并且与任何其他代码一样,它们需要维护。 最好的时间是在您认为自己已经完成工作并想看看一切是否仍按预期运行之前。 您拥有所需的所有上下文,并且可以比在不同子系统上工作的同事更轻松地修复代码或更改失败的测试。 但是这一刻只存在于你的脑海中,所以最常见的运行测试的方式是在推送到开发分支或创建拉取请求之后自动运行。

这样,您的主分支将始终处于有效状态,或者您至少可以清楚地指示其状态。 自动化构建和测试管道(或 CI 管道)有助于:
- 确保代码是可构建的。
- 消除潜在的“它适用于我的机器”问题。
- 提供有关如何准备开发环境的可运行说明。
配置此管道需要时间,但即使您是唯一的开发人员,该管道也可以在用户或客户面前发现一系列问题。
一旦运行,CI 还会在新问题有机会扩大范围之前揭示它们。 因此,我更喜欢在编写第一个测试后立即设置它。 您可以将代码托管在 GitHub 上的私有存储库中并设置 GitHub Actions。 如果你的 repo 是公开的,你有比 GitHub Actions 更多的选择。 例如,我的自动化测试计划在 AppVeyor 上运行,用于具有数据库和三种类型测试的项目。
我更喜欢为生产项目构建我的管道,如下所示:
- 编译或转译
- 单元测试:它们很快并且不需要依赖项
- 数据库或其他服务的设置和初始化
- 集成测试:它们在您的代码之外具有依赖关系,但它们比功能测试更快
- 功能测试:当其他步骤成功完成后,运行整个应用程序
没有金丝雀测试或负载测试。 由于它们的具体情况和要求,它们应该手动启动。
5. 只写必要的测试
为所有代码编写单元测试是一种常见的策略,但有时这会浪费时间和精力,并且不会给您任何信心。 如果您熟悉“测试金字塔”的概念,您可能会认为您的所有代码都必须被单元测试覆盖,只有一部分被其他更高级别的测试覆盖。
我认为没有必要编写单元测试来确保以所需的顺序调用多个模拟依赖项。 这样做需要设置几个模拟并验证所有调用,但这仍然不能让我确信模块正在工作。 通常,我只编写一个使用真正依赖项并只检查结果的集成测试; 这让我确信测试模块中的管道工作正常。
一般来说,我编写的测试可以让我的生活更轻松,同时实现功能并在以后支持它。
对于大多数应用程序而言,以 100% 的代码覆盖率为目标会增加大量繁琐的工作,并且通常会消除使用测试和编程的乐趣。 正如 Martin Fowler 的测试覆盖率所说:
测试覆盖率是查找代码库中未测试部分的有用工具。 测试覆盖率作为测试有多好的数字声明几乎没有用。
因此,我建议您在编写一些测试后安装并运行覆盖率分析器。 带有突出显示的代码行的报告将帮助您更好地了解其执行路径并找到应覆盖的未发现位置。 此外,查看您的 getter、setter 和外观,您会明白为什么 100% 的覆盖率并不有趣。
6. 玩乐高
有时,我会看到诸如“如何测试私有方法?”之类的问题。 你没有。 如果你问过这个问题,那么事情已经出了问题。 通常,这意味着您违反了单一职责原则,并且您的模块没有正确执行某些操作。
重构这个模块并将你认为重要的逻辑拉到一个单独的模块中。 增加文件数量没有问题,这将导致代码结构为乐高积木:非常易读、可维护、可替换和可测试。
正确构建代码说起来容易做起来难。 这里有两个建议:
函数式编程
函数式编程的原理和思想值得学习。 大多数主流语言,如 C、C++、C#、Java、Assembly、JavaScript 和 Python,都强制你为机器编写程序。 函数式编程更适合人脑。
起初这似乎违反直觉,但请考虑一下:如果您将所有代码放在一个方法中,使用共享内存块存储临时值,并使用相当数量的跳转指令,那么计算机会很好。 此外,优化阶段的编译器有时会这样做。 然而,人脑并不容易处理这种方法。
函数式编程迫使您以富有表现力的方式编写没有副作用、具有强类型的纯函数。 这样就更容易推理一个函数,因为它产生的唯一东西就是它的返回值。 Programming Throwdown 播客插曲与 Adam Gordon Bell 的函数式编程将帮助您获得基本的理解,您可以继续与 Philip Wadler 合作的 Corecursive 插曲 God's Programming Language 和 Bartosz Milewski 的类别理论。 最后两个大大丰富了我对编程的认知。
测试驱动开发
我建议掌握 TDD。 最好的学习方法是练习。 字符串计算器 Kata 是练习代码 kata 的好方法。 掌握 kata 需要时间,但最终会让您完全吸收 TDD 的思想,这将帮助您创建结构良好的代码,使用起来很愉快并且可测试。
需要注意的一点:有时您会看到 TDD 纯粹主义者声称 TDD 是唯一正确的编程方式。 在我看来,它只是您工具箱中的另一个有用工具,仅此而已。
有时,您需要了解如何调整模块和流程之间的关系,但不知道要使用哪些数据和签名。 在这种情况下,编写代码直到它编译,然后编写测试来解决和调试功能。
在其他情况下,您知道所需的输入和输出,但由于复杂的逻辑,不知道如何正确编写实现。 对于这些情况,开始遵循 TDD 过程并逐步构建代码比花时间考虑完美的实现更容易。
7. 保持测试简单而集中
很高兴在一个组织整齐的代码环境中工作,没有不必要的干扰。 这就是为什么将 SOLID、KISS 和 DRY 原则应用于测试很重要——在需要时使用重构。
有时我会听到这样的评论,“我讨厌在经过大量测试的代码库中工作,因为每次更改都需要我修复数十个测试。” 这是由于测试不集中并试图测试太多而导致的高维护问题。 “做好一件事”的原则也适用于测试:“做好一件事”; 每个测试应该相对较短,只测试一个概念。 “很好地测试一件事”并不意味着每次测试只能使用一个断言:如果要测试非平凡且重要的数据映射,则可以使用数十个断言。
这个重点不限于一个特定的测试或测试类型。 想象一下处理您使用单元测试测试的复杂逻辑,例如将数据从 ERP 系统映射到您的结构,并且您有一个访问模拟 ERP API 并返回结果的集成测试。 在这种情况下,记住单元测试已经涵盖的内容很重要,这样您就不会在集成测试中再次测试映射。 通常,确保结果具有正确的标识字段就足够了。
使用像乐高积木和集中测试这样的代码结构,对业务逻辑的更改应该不会很痛苦。 如果更改是激进的,您只需删除文件及其相关测试,并使用新测试进行新实现。 如果发生较小的更改,您通常会更改一到三个测试以满足新要求并更改逻辑。 更改测试很好; 您可以将这种做法视为复式簿记。
其他实现简单的方法包括:
- 提出测试文件结构、测试内容结构(通常是 Arrange-Act-Assert 结构)和测试命名的约定; 然后,最重要的是,始终如一地遵循这些规则。
- 将大代码块提取到“准备请求”等方法,并为重复操作制作辅助函数。
- 将构建器模式应用于测试数据配置。
- 使用(在集成测试中)您在主应用程序中使用的相同 DI 容器,因此每次实例化都将与
TestServices.Get()一样简单,无需手动创建依赖项。 这样就可以很容易地阅读、维护和编写新的测试,因为你已经有了有用的助手。
如果您觉得测试变得过于复杂,只需停下来想一想。 模块或您的测试都需要重构。
8.使用工具让你的生活更轻松
测试时您将面临许多繁琐的任务。 例如,设置测试环境或数据对象,为依赖项配置存根和模拟,等等。 幸运的是,每个成熟的技术堆栈都包含多种工具,可以让这些任务变得不那么乏味。
如果你还没有写过你的前一百个测试,我建议你先写一些测试,然后花一些时间来识别重复性任务,并为你的技术栈学习与测试相关的工具。
为了获得灵感,您可以使用以下工具:
- 测试跑步者。 寻找简洁的语法和易用性。 根据我的经验,对于 .NET,我推荐 xUnit(尽管 NUnit 也是一个不错的选择)。 对于 JavaScript 或 TypeScript,我选择 Jest。 尝试找到最适合您的任务和思维方式的方式,因为工具和挑战会不断发展。
- 模拟库。 可能有代码依赖项的低级模拟,如接口,但也有 Web API 或数据库的高级模拟。 对于 JavaScript 和 TypeScript,Jest 中包含的低级模拟是可以的。 对于.NET。 我使用最小起订量,虽然 NSubstitute 也很棒。 至于 Web API 模拟,我喜欢使用 WireMock.NET。 它可以代替 API 用于对响应处理进行故障排除和调试。 它在自动化测试中也非常可靠和快速。 可以使用内存中的对应物来模拟数据库。 .NET 中的 EfCore 提供了这样一个选项。
- 数据生成库。 这些实用程序用随机数据填充您的数据对象。 例如,当您只关心来自大数据传输对象的几个字段(如果那样的话;也许您只想测试映射的正确性)时,它们很有用。 您可以将它们用于测试,也可以作为随机数据显示在表单上或填充数据库。 出于测试目的,我在 .NET 中使用 AutoFixture。
- UI 自动化库。 这些是用于自动化测试的自动化用户:他们可以运行您的应用程序、填写表单、单击按钮、阅读标签等。 要浏览应用程序的所有元素,您无需处理通过坐标单击或图像识别; 主要平台具有按类型、标识符或数据查找所需元素的工具,因此您无需在每次重新设计时更改测试。 它们很健壮,所以一旦你让它们为你和 CI 工作(有时你发现事情只在你的机器上工作),它们就会继续工作。 我喜欢将 FlaUI 用于 .NET,将 Cypress 用于 JavaScript 和 TypeScript。
- 断言库。 大多数测试运行程序都包含断言工具,但在某些情况下,独立工具可以帮助您使用更清晰、更易读的语法编写复杂的断言,例如 Fluent Assertions for .NET。 我特别喜欢断言集合相等的函数,而不管项目的顺序或其在内存中的地址如何。
愿流与你同在
幸福与《心流:最佳体验的心理学》一书中详细描述的所谓“心流”体验紧密相连。 要获得这种心流体验,您必须从事具有明确目标的活动,并且能够看到自己的进步。 任务应该产生即时反馈,自动化测试是理想的选择。 您还需要在挑战和技能之间取得平衡,这取决于每个人。 测试,尤其是在使用 TDD 时,可以帮助指导您并灌输信心。 它们帮助您设定具体目标,每个通过的测试都是您进步的指标。
正确的测试方法可以让你更快乐、更有效率,并且测试可以减少倦怠的机会。 关键是将测试视为一种工具(或工具集),它可以帮助您进行日常开发,而不是让您的代码适应未来的繁重步骤。
测试是编程的必要组成部分,它允许软件工程师改进他们的工作方式,提供最佳结果,并最佳地利用他们的时间。 也许更重要的是,测试可以帮助开发人员更享受他们的工作,从而提高他们的士气和动力。
