实用测试金字塔
“测试金字塔”是一个比喻,它告诉我们按不同粒度将软件测试分组。它还给出了在这些组中的每一个组中我们应该有多少个测试。尽管测试金字塔的概念已经存在了一段时间,但团队仍然难以将其正确付诸实践。本文重新审视了测试金字塔的原始概念,并展示了如何将其付诸实践。它展示了您应该在金字塔的不同级别中寻找哪些类型的测试,并给出了如何实施这些测试的实际示例。
2018 年 2 月 26 日
生产就绪软件在投入生产之前需要进行测试。随着软件开发学科的成熟,软件测试方法也随之成熟。开发团队不再拥有无数的手动软件测试人员,而是朝着自动化其大部分测试工作迈进。自动化他们的测试使团队能够在几秒钟和几分钟内知道他们的软件是否损坏,而不是几天和几周。
由自动化测试推动的反馈回路大幅缩短,与敏捷开发实践、持续交付和 DevOps 文化齐头并进。拥有有效的软件测试方法使团队能够快速而自信地行动。
本文探讨了一个全面的测试组合应该是什么样子,以使其具有响应性、可靠性和可维护性——无论您是构建微服务架构、移动应用程序还是物联网生态系统。我们还将深入了解构建有效且可读的自动化测试的细节。
(测试) 自动化的重要性
软件已成为我们生活世界中不可或缺的一部分。它已经超越了其早期的唯一目的,即提高企业的效率。如今,公司试图找到方法成为一流的数字公司。作为用户,我们每个人每天都会与数量不断增加的软件进行交互。创新的车轮正在加速转动。
如果您想跟上步伐,就必须研究如何在不牺牲软件质量的情况下更快地交付软件。持续交付是一种做法,您可以通过该做法自动确保您的软件可以随时发布到生产环境中,它可以帮助您实现这一目标。通过持续交付,您可以使用构建管道自动测试您的软件并将其部署到测试和生产环境中。
手动构建、测试和部署数量不断增加的软件很快就会变得不可能——除非您想将所有时间花在手动重复工作上,而不是交付可用的软件。自动化一切——从构建到测试、部署和基础设施——是您前进的唯一途径。
图 1:使用构建管道自动可靠地将您的软件投入生产
传统上,软件测试是通过将您的应用程序部署到测试环境,然后执行一些黑盒风格的测试(例如,单击您的用户界面以查看是否有任何损坏)来完成的过度手动工作。通常,这些测试将由测试脚本指定,以确保测试人员进行一致的检查。
显然,手动测试所有更改既耗时又重复又乏味。重复是无聊的,无聊会导致错误,并使您在本周末寻找一份不同的工作。
幸运的是,重复性任务有一个补救措施:自动化。
作为一名软件开发人员,自动化重复性测试可以极大地改变您的生活。自动化这些测试,您不再需要无意识地遵循点击协议来检查您的软件是否仍然正常工作。自动化您的测试,您可以在毫不犹豫的情况下更改您的代码库。如果您曾经尝试过在没有适当测试套件的情况下进行大规模重构,我敢打赌您知道这会是一种多么可怕的经历。您如何知道您是否在过程中意外破坏了东西?好吧,您单击所有手动测试用例,就是这样。但说实话:您真的喜欢这样做吗?在悠闲地喝一口咖啡的同时,进行大规模更改并知道您是否在几秒钟内破坏了东西怎么样?如果您问我,听起来更令人愉快。
测试金字塔
如果您想认真对待软件的自动化测试,那么您应该了解一个关键概念:测试金字塔。Mike Cohn 在他的书Succeeding with Agile 中提出了这个概念。这是一个很好的视觉隐喻,告诉您思考不同的测试层。它还告诉您在每一层上要进行多少测试。
图 2:测试金字塔
Mike Cohn 的原始测试金字塔由您的测试套件应该包含的三层组成(从下到上)
- 单元测试
- 服务测试
- 用户界面测试
不幸的是,如果你仔细观察,测试金字塔的概念会略显不足。有些人认为,迈克·科恩测试金字塔的命名或某些概念方面并不理想,我对此表示同意。从现代的角度来看,测试金字塔似乎过于简单,因此可能会产生误导。
尽管如此,由于其简单性,测试金字塔的本质在建立你自己的测试套件时可作为一条良好的经验法则。你最好的办法是记住科恩原始测试金字塔中的两件事
- 编写具有不同粒度的测试
- 级别越高,你应该拥有的测试就越少
坚持金字塔形状,以提出一个健康、快速且可维护的测试套件:编写大量小而快的单元测试。编写一些更粗粒度的测试和极少的高级测试,从头到尾测试你的应用程序。注意不要以测试冰淇淋蛋筒结尾,这将成为维护的噩梦,并且运行时间太长。
不要过分执着于科恩测试金字塔中各个层级的名称。事实上,它们可能会产生误导:服务测试是一个难以理解的术语(科恩本人谈到了一个观察,即许多开发人员完全忽略了这一层)。在像 React、Angular、Ember.js 等单页面应用程序框架的时代,很明显UI 测试不必在金字塔的最顶层——你完全可以在所有这些框架中对 UI 进行单元测试。
鉴于原始名称的缺点,为你的测试层想出其他名称完全可以,只要在你的代码库和团队讨论中保持一致即可。
我们将要了解的工具和库
示例应用程序
我编写了一个简单微服务,其中包括一个测试套件,用于测试测试金字塔的不同层级。
示例应用程序展示了典型微服务的特征。它提供了一个 REST 接口,与数据库通信,并从第三方 REST 服务获取信息。它在Spring Boot 中实现,即使你以前从未使用过 Spring Boot,也应该可以理解。
务必查看Github 上的代码。自述文件中包含在你的机器上运行应用程序及其自动化测试所需的说明。
功能
应用程序的功能很简单。它提供了一个具有三个端点的 REST 接口
GET /hello | 始终返回“Hello World”。 |
GET /hello/{lastname} | 查找具有所提供姓氏的人。如果知道此人,则返回“Hello {Firstname} {Lastname}”。 |
GET /weather | 返回德国汉堡的当前天气状况。 |
高级结构
系统在高层级的结构如下
图 3:微服务系统的层次结构
我们的微服务提供了一个可以通过 HTTP 调用的 REST 接口。对于某些端点,该服务会从数据库中获取信息。在其他情况下,该服务会通过 HTTP 调用外部天气 API来获取和显示当前天气状况。
内部架构
在内部,Spring 服务具有典型的 Spring 架构
图 4:微服务的内部结构
Controller
类提供REST 端点,并处理HTTP 请求和响应Repository
类与数据库交互,并负责将数据写入/从持久性存储中读取数据Client
类与其他 API 通信,在我们的案例中,它通过HTTPS 从 darksky.net 天气 API 中获取JSONDomain
类捕获我们的领域模型,包括领域逻辑(公平地说,在我们的案例中非常简单)。
经验丰富的 Spring 开发人员可能会注意到这里缺少一个经常使用的层:受领域驱动设计的启发,许多开发人员构建了一个包含服务类的服务层。我决定不在此应用程序中包含服务层。原因之一是我们的应用程序足够简单,服务层将是一个不必要的间接级别。另一个原因是我认为人们对服务层做得太过火了。我经常遇到代码库,其中整个业务逻辑都在服务类中捕获。领域模型仅仅成为数据层,而不是行为层(贫血领域模型)。对于每个非平凡的应用程序,这都会浪费很多潜力来保持代码结构良好且可测试,并且没有充分利用面向对象的功能。
我们的存储库非常简单,并提供简单的CRUD功能。为了保持代码简单,我使用了Spring Data。Spring Data 为我们提供了一个简单且通用的 CRUD 存储库实现,我们可以使用它来代替自己动手。它还负责为我们的测试启动一个内存数据库,而不是像在生产中那样使用真正的 PostgreSQL 数据库。
查看代码库,并熟悉内部结构。这将有助于我们下一步:测试应用程序!
单元测试
测试套件的基础将由单元测试组成。单元测试确保代码库中的某个单元(被测对象)按预期工作。单元测试在测试套件中的所有测试中范围最窄。测试套件中的单元测试数量将远远超过任何其他类型的测试。
图 5:单元测试通常用测试替身替换外部协作者
什么是单元?
如果你问三个不同的人在单元测试的上下文中“单元”意味着什么,你可能会收到四个不同的、略有细微差别的答案。在某种程度上,这是你自己的定义问题,没有规范答案是可以的。
如果您使用的是函数式语言,则一个单元很可能是一个单独的函数。您的单元测试将使用不同的参数调用一个函数,并确保它返回预期的值。在面向对象语言中,一个单元可以从单个方法到整个类。
社交和独处
一些人认为,受测对象的所有协作者(例如,受测类调用的其他类)都应替换为模拟或存根,以实现完美的隔离并避免副作用和复杂的测试设置。另一些人则认为,只有速度慢或副作用较大的协作者(例如,访问数据库或进行网络调用的类)才应该被存根或模拟。
偶尔人们将这两种类型的测试标记为孤立单元测试(用于存根所有协作者的测试)和社交单元测试(允许与真实协作者对话的测试)(Jay Fields 的使用单元测试高效工作创造了这些术语)。如果您有一些空闲时间,您可以深入了解并阅读有关不同思想流派的利弊。
最终,决定采用孤立单元测试还是社交单元测试并不重要。编写自动化测试才是最重要的。就我个人而言,我发现自己一直在使用这两种方法。如果使用真实协作者变得很尴尬,我将慷慨地使用模拟和存根。如果我觉得让真实协作者参与会让我对测试更有信心,我只会存根服务的最外层部分。
模拟和存根
模拟和存根是两种不同类型的测试替身(不止这两种)。许多人将术语模拟和存根互换使用。我认为精确并牢记它们的特定属性很重要。您可以使用测试替身来替换您在生产中使用的对象,并使用有助于您进行测试的实现。用通俗的话来说,这意味着您用该事物的假版本替换真实事物(例如,类、模块或函数)。假版本看起来并像真实事物一样(对相同的方法调用做出响应),但使用您在单元测试开始时自己定义的罐头响应进行响应。
使用测试替身不仅仅适用于单元测试。更精细的测试替身可用于以受控方式模拟系统的整个部分。但是,在单元测试中,您最有可能遇到许多模拟和存根(取决于您是社交型还是孤立型开发人员),仅仅是因为许多现代语言和库使设置模拟和存根变得容易且方便。
无论您选择哪种技术,您的语言的标准库或一些流行的第三方库都很有可能为您提供设置模拟的优雅方法。甚至从头开始编写自己的模拟也只需要编写一个假类/模块/函数,其签名与真实类/模块/函数相同,并在测试中设置假类/模块/函数即可。
您的单元测试将运行得非常快。在性能良好的机器上,您可以在几分钟内运行数千个单元测试。在隔离中测试代码库的小部分,并避免访问数据库、文件系统或发出 HTTP 查询(通过对这些部分使用模拟和存根)以保持测试速度。
一旦你掌握了编写单元测试,你将变得越来越熟练。模拟外部协作者,设置一些输入数据,调用被测对象并检查返回的值是否是你所期望的。深入了解测试驱动开发,让你的单元测试指导你的开发;如果应用得当,它可以帮助你进入一个良好的流程,并提出一个良好且可维护的设计,同时自动生成一个全面且完全自动化的测试套件。不过,它并不是灵丹妙药。继续吧,给它一个真正机会,看看它是否适合你。
测试什么?
单元测试的好处是,你可以为所有生产代码类编写它们,无论它们的功能如何,或者它们属于内部结构中的哪一层。你可以像单元测试存储库、域类或文件读取器一样单元测试控制器。只需坚持每个生产类一个测试类的经验法则,你就可以有一个良好的开端。
单元测试类至少应该测试类的公共接口。私有方法无论如何都无法测试,因为你根本无法从不同的测试类中调用它们。受保护的或包私有的方法可以从测试类访问(假设你的测试类的包结构与生产类相同),但测试这些方法可能已经走得太远了。
在编写单元测试时有一个微妙的界限:它们应该确保所有非平凡的代码路径都经过测试(包括正常路径和边缘情况)。同时,它们不应该与你的实现联系得太紧密。
为什么呢?
与生产代码过于接近的测试很快就会变得讨厌。只要你重构生产代码(快速回顾:重构是指在不改变外部可见行为的情况下改变代码的内部结构),单元测试就会中断。
这样,你就会失去单元测试的一大好处:充当代码更改的安全网。你反而会厌倦这些愚蠢的测试,每次重构时都会失败,造成的工作量大于帮助;而且,是谁想出这种愚蠢的测试东西的?
你该怎么做?不要在单元测试中反映内部代码结构。而是测试可观察行为。考虑
如果我输入值x
和y
,结果会是z
吗?
而不是
如果我输入x
和y
,方法会首先调用类 A,然后调用类 B,再返回类 A 的结果加上类 B 的结果吗?
私有方法通常应被视为实现细节。这就是为什么你甚至不应该有测试它们的冲动。
我经常听到单元测试(或TDD)的反对者争论说,编写单元测试会变成毫无意义的工作,你必须测试所有方法才能获得高测试覆盖率。他们经常引用这样的场景:一个过于急切的团队负责人强迫他们为 getter 和 setter 以及所有其他琐碎代码编写单元测试,以获得 100% 的测试覆盖率。
这里有很多错误。
是的,你应该测试公共接口。然而,更重要的是,你不要测试琐碎的代码。别担心,Kent Beck 说这样做是可以的。你不会从测试简单的getter或setter或其他琐碎的实现(例如,没有任何条件逻辑)中获得任何好处。节省时间,这可是你可以参加的又一次会议,万岁!
测试结构
所有测试(这不仅限于单元测试)的一个好结构是
- 设置测试数据
- 调用要测试的方法
- 断言返回了预期的结果
有一个很好的助记符可以记住这个结构:“安排、执行、断言”。另一个你可以使用的助记符是从BDD中获得灵感。它是“给定”、“当”、“然后”三元组,其中给定反映了设置,当反映了方法调用,然后反映了断言部分。
此模式还可以应用于其他更高级别的测试。在每种情况下,它们都能确保你的测试保持易读且一致。最重要的是,牢记此结构编写的测试往往更短且更具表现力。
实施单元测试
现在我们知道了要测试的内容以及如何构建单元测试,我们终于可以看到一个真实的示例。
让我们采用 ExampleController
类的简化版本
@RestController public class ExampleController { private final PersonRepository personRepo; @Autowired public ExampleController(final PersonRepository personRepo) { this.personRepo = personRepo; } @GetMapping("/hello/{lastName}") public String hello(@PathVariable final String lastName) { Optional<Person> foundPerson = personRepo.findByLastName(lastName); return foundPerson .map(person -> String.format("Hello %s %s!", person.getFirstName(), person.getLastName())) .orElse(String.format("Who is this '%s' you're talking about?", lastName)); } }
针对 hello(lastname)
函数的单元测试可能如下所示
public class ExampleControllerTest { private ExampleController subject; @Mock private PersonRepository personRepo; @Before public void setUp() throws Exception { initMocks(this); subject = new ExampleController(personRepo); } @Test public void shouldReturnFullNameOfAPerson() throws Exception { Person peter = new Person("Peter", "Pan"); given(personRepo.findByLastName("Pan")) .willReturn(Optional.of(peter)); String greeting = subject.hello("Pan"); assertThat(greeting, is("Hello Peter Pan!")); } @Test public void shouldTellIfPersonIsUnknown() throws Exception { given(personRepo.findByLastName(anyString())) .willReturn(Optional.empty()); String greeting = subject.hello("Pan"); assertThat(greeting, is("Who is this 'Pan' you're talking about?")); } }
我们使用 JUnit 编写单元测试,JUnit 是 Java 的事实标准测试框架。我们使用 Mockito 用存根替换真正的 PersonRepository
类以进行测试。此存根允许我们定义罐头响应,存根函数应在此测试中返回。存根使我们的测试更加简单、可预测,并允许我们轻松设置测试数据。
遵循“安排、执行、断言”结构,我们编写两个单元测试 - 一个正例和一个搜索不到人员的情况。第一个正例测试用例创建一个新人员对象,并告知模拟存储库在使用 “Pan” 作为 lastName
参数的值调用它时返回此对象。然后,测试继续调用应测试的函数。最后,它断言响应等于预期响应。
第二个测试的工作方式类似,但测试的是测试函数未针对给定参数找到人员的情况。
集成测试
所有非平凡的应用程序都将与其他一些部分(数据库、文件系统、对其他应用程序的网络调用)集成。在编写单元测试时,这些通常是您为了获得更好的隔离和更快的测试而省略的部分。尽管如此,您的应用程序仍将与其他部分交互,并且需要对此进行测试。集成测试 可以提供帮助。它们测试您的应用程序与应用程序外部的所有部分的集成。
对于您的自动化测试,这意味着您不仅需要运行您自己的应用程序,还需要运行您要集成的组件。如果您要测试与数据库的集成,则需要在运行测试时运行数据库。要测试您可以从磁盘读取文件,您需要将文件保存到磁盘并在集成测试中加载它。
我之前提到过“单元测试”是一个模糊的术语,对于“集成测试”来说更是如此。对于某些人来说,集成测试意味着通过连接到系统中其他应用程序的整个应用程序堆栈进行测试。我喜欢更狭隘地对待集成测试,并通过用测试替身替换单独的服务和数据库来一次测试一个集成点。结合契约测试和针对测试替身以及真实实现运行契约测试,您可以提出更快速、更独立且通常更容易推理的集成测试。
狭义集成测试存在于服务的边界。从概念上讲,它们始终与触发导致与外部部分(文件系统、数据库、独立服务)集成的操作有关。数据库集成测试看起来像这样
图 6:数据库集成测试将代码与真实数据库集成
- 启动数据库
- 将应用程序连接到数据库
- 触发代码中将数据写入数据库的函数
- 通过从数据库中读取数据来检查预期的数据是否已写入数据库
另一个示例,测试服务通过 REST API 与独立服务集成的测试可能如下所示
图 7:此类集成测试检查应用程序是否可以与独立服务正确通信
- 启动应用程序
- 启动独立服务实例(或具有相同接口的测试替身)
- 触发代码中从独立服务 API 读取的函数
- 检查应用程序是否可以正确解析响应
集成测试(如单元测试)可以相当白盒。一些框架允许你在启动应用程序的同时仍然能够模拟应用程序的其他一些部分,以便你可以检查是否发生了正确的交互。
为所有代码段编写集成测试,在这些代码段中你“序列化”或“反序列化”数据。这比你想象的发生得更频繁。考虑
- 对服务的 REST API 的调用
- 从数据库中读取和写入
- 调用其他应用程序的 API
- 从队列中读取和写入
- 写入文件系统
围绕这些边界编写集成测试可确保向这些外部协作者写入数据和从这些外部协作者读取数据正常工作。
在编写“狭义集成测试”时,你应该尝试在本地运行外部依赖项:启动本地 MySQL 数据库,针对本地 ext4 文件系统进行测试。如果你要与独立服务集成,则在本地运行该服务的实例或构建并运行模拟真实服务行为的伪版本。
如果没有办法在本地运行第三方服务,你应该选择运行专用测试实例,并在运行集成测试时指向此测试实例。避免在自动化测试中与真正的生产系统集成。向生产系统发送数千个测试请求是激怒他人的一种可靠方法,因为你正在弄乱他们的日志(在最好的情况下)或甚至DoS 他们的服务(在最坏的情况下)。通过网络与服务集成是“广义集成测试”的典型特征,并且会使你的测试速度变慢,并且通常更难编写。
关于测试金字塔,集成测试高于单元测试。集成文件系统和数据库等慢速部分往往比使用存根替换这些部分来运行单元测试慢得多。它们也可能比小型且独立的单元测试更难编写,毕竟你必须在测试中负责启动外部部分。不过,它们具有给你信心,让你确信应用程序可以与需要对话的所有外部部分正确工作的优势。单元测试无法帮助你解决此问题。
数据库集成
PersonRepository
是代码库中唯一的存储库类。它依赖于 Spring Data,并且没有实际实现。它只是扩展了 CrudRepository
接口,并提供了一个单一的方法头。其余部分是 Spring 的魔力。
public interface PersonRepository extends CrudRepository<Person, String> { Optional<Person> findByLastName(String lastName); }
通过 CrudRepository
接口,Spring Boot 提供了一个功能齐全的 CRUD 存储库,其中包含 findOne
、findAll
、save
、update
和 delete
方法。我们的自定义方法定义 (findByLastName()
) 扩展了此基本功能,并为我们提供了一种按姓氏获取 Person
的方法。Spring Data 分析方法的返回类型及其方法名称,并根据命名约定检查方法名称,以找出它应该做什么。
尽管 Spring Data 在实现数据库存储库方面做了大量工作,但我仍然编写了一个数据库集成测试。你可能会认为这是在 测试框架,而我应该避免这样做,因为我们不是在测试我们的代码。不过,我相信至少在这里进行一次集成测试至关重要。首先,它测试了我们的自定义 findByLastName
方法是否按预期实际运行。其次,它证明了我们的存储库正确地使用了 Spring 的布线,并且可以连接到数据库。
为了让你更轻松地在你的机器上运行测试(无需安装 PostgreSQL 数据库),我们的测试连接到了一个内存中的 H2 数据库。
我在 build.gradle
文件中将 H2 定义为一个测试依赖项。测试目录中的 application.properties
没有定义任何 spring.datasource
属性。这告诉 Spring Data 使用内存数据库。由于它在类路径中找到了 H2,因此在运行我们的测试时它只会使用 H2。
在使用 int
配置文件运行实际应用程序时(例如,通过将 SPRING_PROFILES_ACTIVE=int
设置为环境变量),它会连接到 application-int.properties
中定义的 PostgreSQL 数据库。
我知道,这是很多需要了解和理解的 Spring 细节。为了做到这一点,你必须仔细阅读 大量文档。生成的代码很容易理解,但如果你不知道 Spring 的细节,就很难理解。
最重要的是,使用内存数据库是一件有风险的事情。毕竟,我们的集成测试针对的是不同类型的数据库,而不是生产中的数据库。继续,根据自己的喜好,决定是选择 Spring 的魔力与简单的代码,还是选择明确但更冗长的实现。
解释已经足够了,这里有一个简单的集成测试,它将一个人保存到数据库中,并按其姓氏查找
@RunWith(SpringRunner.class) @DataJpaTest public class PersonRepositoryIntegrationTest { @Autowired private PersonRepository subject; @After public void tearDown() throws Exception { subject.deleteAll(); } @Test public void shouldSaveAndFetchPerson() throws Exception { Person peter = new Person("Peter", "Pan"); subject.save(peter); Optional<Person> maybePeter = subject.findByLastName("Pan"); assertThat(maybePeter, is(Optional.of(peter))); } }
你可以看到,我们的集成测试遵循与单元测试相同的 安排、执行、断言 结构。告诉你,这是一个普遍的概念!
与独立服务的集成
我们的微服务与 darksky.net 通信,这是一个天气 REST API。当然,我们希望确保我们的服务正确发送请求并解析响应。
在运行自动化测试时,我们希望避免访问真正的 darksky 服务器。我们的免费计划的配额限制只是原因之一。真正的原因是 解耦。我们的测试应该独立于 darksky.net 上可爱的人们正在做什么。即使你的机器无法访问 darksky 服务器或 darksky 服务器因维护而关闭。
在运行集成测试时,我们可以通过运行我们自己的、假的 darksky 服务器来避免访问真正的 darksky 服务器。这听起来像一项艰巨的任务。感谢 Wiremock 等工具,这很容易。看这个
@RunWith(SpringRunner.class) @SpringBootTest public class WeatherClientIntegrationTest { @Autowired private WeatherClient subject; @Rule public WireMockRule wireMockRule = new WireMockRule(8089); @Test public void shouldCallWeatherService() throws Exception { wireMockRule.stubFor(get(urlPathEqualTo("/some-test-api-key/53.5511,9.9937")) .willReturn(aResponse() .withBody(FileLoader.read("classpath:weatherApiResponse.json")) .withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .withStatus(200))); Optional<WeatherResponse> weatherResponse = subject.fetchWeather(); Optional<WeatherResponse> expectedResponse = Optional.of(new WeatherResponse("Rain")); assertThat(weatherResponse, is(expectedResponse)); } }
要使用 Wiremock,我们在一个固定的端口 (8089
) 上实例化一个 WireMockRule
。使用 DSL,我们可以设置 Wiremock 服务器,定义它应该侦听的端点,并设置它应该响应的罐头响应。
接下来,我们调用我们要测试的方法,即调用第三方服务的方法,并检查结果是否被正确解析。
了解测试如何知道它应该调用伪造的 Wiremock 服务器,而不是真正的 darksky API 非常重要。秘密就在于我们包含在 src/test/resources
中的 application.properties
文件中。这是 Spring 在运行测试时加载的属性文件。在此文件中,我们使用适合于我们的测试目的的值覆盖了 API 密钥和 URL 等配置,例如调用伪造的 Wiremock 服务器,而不是真正的服务器
weather.url = http://localhost:8089
请注意,此处定义的端口必须与我们在测试中实例化 WireMockRule
时定义的端口相同。通过在 WeatherClient
类的构造函数中注入 URL,可以在测试中用伪造的 URL 替换真正的天气 API 的 URL
@Autowired public WeatherClient(final RestTemplate restTemplate, @Value("${weather.url}") final String weatherServiceUrl, @Value("${weather.api_key}") final String weatherServiceApiKey) { this.restTemplate = restTemplate; this.weatherServiceUrl = weatherServiceUrl; this.weatherServiceApiKey = weatherServiceApiKey; }
通过这种方式,我们告诉 WeatherClient
从我们在应用程序属性中定义的 weather.url
属性中读取 weatherUrl
参数的值。
使用 Wiremock 等工具为单独的服务编写窄集成测试非常容易。不幸的是,这种方法有一个缺点:我们如何确保我们设置的伪造服务器的行为与真正的服务器一样?使用当前实现,单独的服务可以更改其 API,而我们的测试仍然会通过。现在,我们只是在测试我们的 WeatherClient
是否可以解析伪造服务器发送的响应。这是一个开始,但它非常脆弱。使用端到端测试并针对真正的服务的测试实例运行测试,而不是使用伪造的服务,将解决此问题,但会使我们依赖于测试服务的可用性。幸运的是,对于这个难题,有一个更好的解决方案:针对伪造的和真正的服务器运行契约测试,以确保我们在集成测试中使用的伪造是忠实的测试替身。让我们看看接下来如何进行。
契约测试
更现代的软件开发组织已经找到通过将系统的开发分布在不同的团队中来扩展其开发工作的方法。各个团队构建各个松散耦合的服务,而不会相互影响,并将这些服务集成到一个庞大、有凝聚力的系统中。最近围绕微服务的炒作重点关注的就是这一点。
将系统拆分为许多小服务通常意味着这些服务需要通过某些(希望定义明确,有时意外增长)接口相互通信。
不同应用程序之间的接口可以采用不同的形状和技术。常见的有
- 通过 HTTPS 的 REST 和 JSON
- 使用类似 gRPC 的东西进行RPC
- 使用队列构建事件驱动的架构
对于每个接口,都有两个参与方:提供者和使用者。提供者向使用者提供数据。使用者处理从提供者获得的数据。在 REST 世界中,提供者构建具有所有必需端点的 REST API;使用者调用此 REST API 来获取数据或触发其他服务中的更改。在异步事件驱动的世界中,提供者(通常称为发布者)将数据发布到队列;使用者(通常称为订阅者)订阅这些队列并读取和处理数据。
图 8:每个接口都有一个提供(或发布)方和一个使用(或订阅)方。接口的规范可以被视为契约。
由于您经常将使用和提供服务分布在不同的团队中,因此您会发现自己处于必须明确指定这些服务之间的接口(所谓的契约)的情况下。传统上,公司以以下方式解决此问题
- 编写一个冗长且详细的接口规范(契约)
- 根据定义的契约实现提供服务
- 将接口规范抛给消费团队
- 等待他们实现消费接口的部分
- 运行一些大规模的手动系统测试,以查看一切是否正常
- 希望两个团队永远坚持接口定义,不要搞砸
更现代的软件开发团队已用更自动化的内容替换了步骤 5 和 6:自动化契约测试确保消费者和提供者方面的实现仍坚持定义的契约。它们用作良好的回归测试套件,并确保会及早发现与契约的偏差。
在更敏捷的组织中,你应采取更高效、更省力的途径。你在同一组织内构建自己的应用程序。直接与其他服务的开发人员交谈,而不是将过于详细的文档抛给对方,这真的不应该太难。毕竟,他们是你的同事,而不是你只能通过客户支持或具有法律效力的合同才能与之交谈的第三方供应商。
消费者驱动的契约测试 (CDC 测试) 让消费者推动契约的实现。使用 CDC,接口的消费者编写测试,以检查接口中他们需要的所有数据。然后,消费团队发布这些测试,以便发布团队可以轻松获取和执行这些测试。现在,提供团队可以通过运行 CDC 测试来开发其 API。一旦所有测试通过,他们就知道自己已实现消费团队所需的一切。
图 9:契约测试确保提供者和所有接口消费者坚持定义的接口契约。通过 CDC 测试,接口的消费者以自动化测试的形式发布其需求;提供者持续获取和执行这些测试
这种方法允许提供团队仅实现真正必要的内容(保持简单,YAGNI 等等)。提供接口的团队应持续获取和运行这些 CDC 测试(在他们的构建管道中),以立即发现任何重大更改。如果他们破坏了接口,他们的 CDC 测试将失败,从而防止重大更改生效。只要测试保持绿色,团队就可以进行任何他们喜欢的更改,而无需担心其他团队。消费者驱动的契约方法会让你拥有如下流程
- 消费团队编写包含所有消费者期望的自动化测试
- 他们为提供团队发布测试
- 提供团队持续运行 CDC 测试并保持它们为绿色
- 一旦 CDC 测试中断,两个团队就会互相交谈
如果你的组织采用微服务方法,那么拥有 CDC 测试将朝着建立自治团队迈出一大步。CDC 测试是促进团队沟通的自动化方式。它们确保团队之间的接口随时都能正常工作。失败的 CDC 测试是一个很好的指标,表明你应走到受影响的团队,聊聊即将进行的任何 API 更改,并弄清楚你希望如何继续前进。
CDC 测试的一个简单实现可以像向 API 发出请求并断言响应包含你需要的一切一样简单。然后,你将这些测试打包为可执行文件(.gem、.jar、.sh)并将其上传到其他团队可以获取它的某个位置(例如,像Artifactory这样的工件存储库)。
在过去几年中,CDC 方法变得越来越流行,并且已构建了多个工具来简化编写和交换它们。
Pact 可能是目前最著名的一个。它采用一种复杂的方法为消费者和提供者编写测试,开箱即用地为您提供单独服务的存根,并允许您与其他团队交换 CDC 测试。Pact 已移植到许多平台,并且可以与 JVM 语言、Ruby、.NET、JavaScript 以及更多语言一起使用。
如果您想开始使用 CDC,但不知道如何使用,Pact 可能是一个明智的选择。文档 起初可能让人不知所措。请耐心并仔细阅读。这有助于您对 CDC 有一个坚定的理解,进而使您在与其他团队合作时更容易提倡使用 CDC。
消费者驱动的契约测试可以真正改变游戏规则,以建立能够快速而自信地行动的自主团队。帮自己一个忙,阅读该概念并尝试一下。一套可靠的 CDC 测试对于快速行动而不中断其他服务并给其他团队带来很多挫败感是无价的。
消费者测试(我们的团队)
我们的微服务使用天气 API。因此,我们有责任编写一个消费者测试,该测试定义了我们对微服务和天气服务之间的契约(API)的期望。
首先,我们在 build.gradle
中包含一个用于编写 pact 消费者测试的库
testCompile('au.com.dius:pact-jvm-consumer-junit_2.11:3.5.5')
借助此库,我们可以实现消费者测试并使用 pact 的模拟服务
@RunWith(SpringRunner.class) @SpringBootTest public class WeatherClientConsumerTest { @Autowired private WeatherClient weatherClient; @Rule public PactProviderRuleMk2 weatherProvider = new PactProviderRuleMk2("weather_provider", "localhost", 8089, this); @Pact(consumer="test_consumer") public RequestResponsePact createPact(PactDslWithProvider builder) throws IOException { return builder .given("weather forecast data") .uponReceiving("a request for a weather request for Hamburg") .path("/some-test-api-key/53.5511,9.9937") .method("GET") .willRespondWith() .status(200) .body(FileLoader.read("classpath:weatherApiResponse.json"), ContentType.APPLICATION_JSON) .toPact(); } @Test @PactVerification("weather_provider") public void shouldFetchWeatherInformation() throws Exception { Optional<WeatherResponse> weatherResponse = weatherClient.fetchWeather(); assertThat(weatherResponse.isPresent(), is(true)); assertThat(weatherResponse.get().getSummary(), is("Rain")); } }
如果您仔细观察,您会看到 WeatherClientConsumerTest
与 WeatherClientIntegrationTest
非常相似。这次我们使用 Pact,而不是使用 Wiremock 作为服务器存根。事实上,消费者测试的工作方式与集成测试完全相同,我们将真实的第三方服务器替换为存根,定义预期的响应,并检查我们的客户端是否可以正确解析响应。从这个意义上说,WeatherClientConsumerTest
本身就是一个狭义的集成测试。与基于 Wiremock 的测试相比,此测试的优点在于每次运行时都会生成一个pact 文件(位于 target/pacts/&pact-name>.json
中)。此 pact 文件以一种特殊的 JSON 格式描述了我们对契约的期望。然后可以使用此 pact 文件来验证我们的存根服务器是否像真实服务器一样运行。我们可以获取 pact 文件并将其交给提供接口的团队。他们获取此 pact 文件并使用其中定义的期望编写提供者测试。通过这种方式,他们测试他们的 API 是否满足我们的所有期望。
您会看到,这就是 CDC 的消费者驱动部分的来源。消费者通过描述他们的期望来驱动接口的实现。提供者必须确保他们满足所有期望,并且他们已经完成。没有镀金,没有 YAGNI 等。
有多种方法可以将 pact 文件传送到提供团队。一种简单的方法是将它们检入版本控制,并告诉提供者团队始终获取 pact 文件的最新版本。更高级的方法是使用工件存储库、Amazon 的 S3 或 pact 代理等服务。从简单开始,根据需要逐渐增加。
在您的实际应用程序中,您不需要为客户端类同时使用集成测试和消费者测试。示例代码库包含两者,以向您展示如何使用其中任何一种。如果您想使用 pact 编写 CDC 测试,我建议坚持使用后者。编写测试的精力是相同的。使用 pact 的好处是您可以自动获得一个 pact 文件,其中包含对契约的期望,其他团队可以使用该文件轻松地实现其提供者测试。当然,只有当您说服其他团队也使用 pact 时,这才有意义。如果这不起作用,使用集成测试和 Wiremock 组合是一个不错的备用方案。
提供者测试(其他团队)
提供程序测试必须由提供天气 API 的人员来实现。我们正在使用 darksky.net 提供的公共 API。从理论上讲,darksky 团队会在其端实现提供程序测试,以检查他们是否破坏了其应用程序与我们服务之间的契约。
显然,他们不在乎我们微不足道的示例应用程序,也不会为我们实现 CDC 测试。这是面向公众的 API 与采用微服务的组织之间的巨大差异。面向公众的 API 无法考虑那里的每一个消费者,否则它们将无法继续前进。在您自己的组织内,您可以(并且应该)这样做。您的应用程序很可能只为少数消费者服务,最多可能只有几十个消费者。为了保持系统稳定,您可以为这些接口编写提供程序测试。
提供团队获取契约文件,并针对其提供服务运行该文件。为此,他们实现了一个提供程序测试,该测试读取契约文件,截断一些测试数据,并针对其服务运行契约文件中定义的期望。
契约人员已经编写了多个库来实现提供程序测试。他们的主要 GitHub 存储库 为您提供了很好的概述,介绍了哪些消费者和哪些提供程序库可用。选择最适合您的技术堆栈的那个。
为了简单起见,我们假设 darksky API 也在 Spring Boot 中实现。在这种情况下,他们可以使用 Spring 契约提供程序,该提供程序很好地连接到了 Spring 的 MockMVC 机制。darksky.net 团队将实现的假设提供程序测试可能如下所示
@RunWith(RestPactRunner.class) @Provider("weather_provider") // same as the "provider_name" in our clientConsumerTest @PactFolder("target/pacts") // tells pact where to load the pact files from public class WeatherProviderTest { @InjectMocks private ForecastController forecastController = new ForecastController(); @Mock private ForecastService forecastService; @TestTarget public final MockMvcTarget target = new MockMvcTarget(); @Before public void before() { initMocks(this); target.setControllers(forecastController); } @State("weather forecast data") // same as the "given()" in our clientConsumerTest public void weatherForecastData() { when(forecastService.fetchForecastFor(any(String.class), any(String.class))) .thenReturn(weatherForecast("Rain")); } }
您会看到,提供程序测试所要做的就是加载契约文件(例如,通过使用 @PactFolder
注解来加载以前下载的契约文件),然后定义如何提供预定义状态的测试数据(例如,使用 Mockito 模拟)。没有要实现的自定义测试。这些全部都源自契约文件。重要的是,提供程序测试具有与消费者测试中声明的提供程序名称和状态相匹配的对应项。
提供者测试(我们的团队)
我们已经了解了如何测试我们的服务与天气提供程序之间的契约。通过此界面,我们的服务充当消费者,天气服务充当提供程序。再深入思考一下,我们会看到我们的服务也充当其他人的提供程序:我们提供了一个 REST API,该 API 提供了一些端点,供其他人使用。
正如我们刚刚了解到的,契约测试非常流行,因此我们当然也为该契约编写契约测试。幸运的是,我们正在使用消费者驱动的契约,因此所有消费团队都会向我们发送其契约,我们可以使用这些契约来为我们的 REST API 实现提供程序测试。
让我们首先将 Spring 的契约提供程序库添加到我们的项目中
testCompile('au.com.dius:pact-jvm-provider-spring_2.12:3.5.5')
实现提供者测试遵循之前描述的相同模式。为了简单起见,我只需将我们的 简单消费者 中的契约文件检入到我们的服务存储库中。对于我们的目的而言,这更容易,在实际场景中,你可能会使用更复杂的机制来分发你的契约文件。
@RunWith(RestPactRunner.class) @Provider("person_provider")// same as in the "provider_name" part in our pact file @PactFolder("target/pacts") // tells pact where to load the pact files from public class ExampleProviderTest { @Mock private PersonRepository personRepository; @Mock private WeatherClient weatherClient; private ExampleController exampleController; @TestTarget public final MockMvcTarget target = new MockMvcTarget(); @Before public void before() { initMocks(this); exampleController = new ExampleController(personRepository, weatherClient); target.setControllers(exampleController); } @State("person data") // same as the "given()" part in our consumer test public void personData() { Person peterPan = new Person("Peter", "Pan"); when(personRepository.findByLastName("Pan")).thenReturn(Optional.of (peterPan)); } }
所示的 ExampleProviderTest
需要根据给定的契约文件提供状态,就是这样。一旦我们运行提供者测试,Pact 将选取契约文件并针对我们的服务发出 HTTP 请求,然后根据我们设置的状态做出响应。
UI 测试
大多数应用程序都具有一些用户界面。在 Web 应用程序的上下文中,我们通常讨论的是 Web 界面。人们常常忘记 REST API 或命令行界面与花哨的 Web 用户界面一样都是用户界面。
UI 测试 测试应用程序的用户界面是否正常工作。用户输入应触发正确的操作,应向用户呈现数据,UI 状态应按预期更改。
有时(如在 Mike Cohn 的案例中)UI 测试和端到端测试被认为是同一回事。对我来说,这混淆了两个相当正交的概念。
是的,端到端测试应用程序通常意味着通过用户界面驱动测试。然而,反过来则不然。
测试用户界面不必以端到端的方式进行。根据你使用的技术,测试用户界面可以像为你的前端 javascript 代码编写一些单元测试一样简单,同时将你的后端存根掉。
对于传统的 Web 应用程序,可以使用 Selenium 等工具测试用户界面。如果你认为 REST API 是你的用户界面,那么你应该通过围绕你的 API 编写适当的集成测试来获得所需的一切。
对于 Web 界面,你可能希望围绕你的 UI 测试多个方面:行为、布局、可用性或对公司设计的遵守只是其中几个方面。
幸运的是,测试用户界面的行为非常简单。你单击此处,在此处输入数据,并希望用户界面的状态相应更改。现代单页面应用程序框架(react、vue.js、Angular 等)通常带有自己的工具和帮助器,允许你以相当底层(单元测试)的方式彻底测试这些交互。即使你使用原生 javascript 编写自己的前端实现,你也可以使用常规测试工具,如 Jasmine 或 Mocha。对于更传统的服务器端呈现应用程序,基于 Selenium 的测试将是你的最佳选择。
测试 Web 应用程序的布局保持不变有点困难。根据你的应用程序和用户的需求,你可能希望确保代码更改不会意外破坏网站的布局。
问题在于计算机在检查某物是否“看起来不错”方面出了名的糟糕(也许一些聪明的机器学习算法将来可以改变这一点)。
如果你想在构建管道中自动检查 Web 应用程序的设计,有一些工具可以尝试。其中大多数工具利用 Selenium 在不同的浏览器和格式中打开你的 Web 应用程序,截取屏幕截图,并将这些屏幕截图与以前截取的屏幕截图进行比较。如果新旧屏幕截图以意外的方式不同,该工具会通知你。
Galen 是其中一种工具。但是,如果您有特殊要求,即使自己动手构建解决方案也并不难。我合作过的一些团队构建了 lineup 及其基于 Java 的表亲 jlineup 来实现类似的功能。这两种工具都采用了之前我描述的基于 Selenium 的方法。
一旦您想要测试可用性和“外观良好”的因素,您就离开了自动化测试的领域。这是您应该依靠 探索性测试、可用性测试(这甚至可以像 走廊测试 一样简单)以及与您的用户一起展示来了解他们是否喜欢使用您的产品,并且可以在不感到沮丧或烦恼的情况下使用所有功能。
端到端测试
通过其用户界面测试您已部署的应用程序是您可以测试应用程序的最端到端的方式。之前描述的由 Webdriver 驱动的 UI 测试就是端到端测试的一个好例子。
图 11:端到端测试测试您的整个完全集成的系统
端到端测试(也称为 Broad Stack 测试)在您需要决定您的软件是否正常工作时为您提供最大的信心。Selenium 和 WebDriver 协议 允许您通过自动驱动一个(无头)浏览器针对您的已部署服务来自动化您的测试,执行点击、输入数据和检查您的用户界面的状态。您可以直接使用 Selenium 或使用构建在其之上的工具,Nightwatch 就是其中之一。
端到端测试会带来它们自己类型的问题。它们出了名的不稳定,并且经常因意外和不可预见的原因而失败。它们的失败常常是误报。您的用户界面越复杂,测试往往变得越不稳定。浏览器怪癖、时序问题、动画和意外弹出对话框只是让我花费比我想承认的更多时间进行调试的一些原因。
在微服务世界中,还有一个大问题,那就是谁负责编写这些测试。由于它们跨越多个服务(您的整个系统),因此没有一个团队负责编写端到端测试。
如果您有一个集中的质量保证团队,他们看起来很合适。但另一方面,拥有一个集中的 QA 团队是一个很大的反模式,在您的团队应该真正跨职能的 DevOps 世界中不应该占有一席之地。谁应该拥有端到端测试,这个问题没有简单的答案。也许您的组织有一个实践社区或一个质量协会可以负责这些工作。找到正确的答案在很大程度上取决于您的组织。
此外,端到端测试需要大量的维护,并且运行得很慢。考虑到一个拥有不止几个微服务的环境,您甚至无法在本地运行端到端测试——因为这还需要在本地启动您的所有微服务。祝你好运,在您的开发机器上启动数百个应用程序而不会耗尽您的 RAM。
由于它们的高维护成本,您应该尽量减少端到端测试的数量。
考虑用户将与您的应用程序进行的高价值交互。尝试提出定义您产品核心价值的用户旅程,并将这些用户旅程中最重要的步骤转化为自动化的端到端测试。
如果您正在构建一个电子商务网站,您最有价值的客户旅程可能是用户搜索产品、将其放入购物车并进行结帐。就是这样。只要这段旅程仍然有效,您就不应该遇到太多麻烦。也许您会找到一两个可以转化为端到端测试的关键用户旅程。除此之外的所有内容都可能弊大于利。
请记住:在测试金字塔中,您有很多较低级别,您已在其中测试了各种边缘情况以及与系统其他部分的集成。无需在较高级别上重复这些测试。高维护工作量和大量误报会拖慢您的速度,并导致您迟早对测试失去信任。
用户界面端到端测试
对于端到端测试,Selenium 和 WebDriver 协议是许多开发人员的首选工具。使用 Selenium,您可以选择自己喜欢的浏览器,并让它自动调用您的网站,单击此处和那里,输入数据并检查用户界面中的内容是否发生变化。
Selenium 需要一个浏览器,它可以启动该浏览器并使用该浏览器来运行其测试。有多个所谓的“驱动程序”可用于不同的浏览器。选择一个(或多个)并将其添加到您的 build.gradle
中。无论您选择哪种浏览器,您都需要确保团队中的所有开发人员和您的 CI 服务器已在本地安装了正确版本的浏览器。保持同步可能非常痛苦。对于 Java,有一个名为 webdrivermanager 的不错的小型库,它可以自动下载并设置您想要使用的浏览器的正确版本。将这两个依赖项添加到您的 build.gradle
中,您就可以开始了
testCompile('org.seleniumhq.selenium:selenium-chrome-driver:2.53.1') testCompile('io.github.bonigarcia:webdrivermanager:1.7.2')
在您的测试套件中运行一个功能齐全的浏览器可能很麻烦。特别是当使用持续交付时,运行您的管道的服务器可能无法启动包括用户界面在内的浏览器(例如,因为没有可用的 X 服务器)。您可以通过启动虚拟 X 服务器(如 xvfb)来解决此问题。
一种更新的方法是使用无头浏览器(即没有用户界面的浏览器)来运行您的 webdriver 测试。直到最近,PhantomJS 都是用于浏览器自动化的领先无头浏览器。自 Chromium 和 Firefox 宣布已在其浏览器中实施无头模式以来,PhantomJS 突然变得过时。毕竟,最好使用用户实际使用的浏览器(如 Firefox 和 Chrome)来测试您的网站,而不是仅仅因为对您作为开发人员来说很方便而使用人工浏览器。
无头 Firefox 和 Chrome 都是全新的,尚未被广泛采用来实现 webdriver 测试。我们希望保持简单。不要忙于使用前沿无头模式,让我们坚持使用 Selenium 和常规浏览器的经典方式。一个简单的端到端测试,它启动 Chrome,导航到我们的服务并检查网站的内容如下所示
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class HelloE2ESeleniumTest { private WebDriver driver; @LocalServerPort private int port; @BeforeClass public static void setUpClass() throws Exception { ChromeDriverManager.getInstance().setup(); } @Before public void setUp() throws Exception { driver = new ChromeDriver(); } @After public void tearDown() { driver.close(); } @Test public void helloPageHasTextHelloWorld() { driver.get(String.format("http://127.0.0.1:%s/hello", port)); assertThat(driver.findElement(By.tagName("body")).getText(), containsString("Hello World!")); } }
请注意,此测试仅在您在运行此测试的系统(您的本地计算机、您的 CI 服务器)上安装了 Chrome 时才会在您的系统上运行。
测试很简单。它使用 @SpringBootTest
在随机端口上启动整个 Spring 应用程序。然后,我们实例化一个新的 Chrome webdriver,告诉它导航到我们微服务的 /hello
端点,并检查它在浏览器窗口上打印“Hello World!”。很酷的东西!
REST API 端到端测试
在测试应用程序时避免使用图形用户界面可能是提出一些想法的好办法,这些想法比完全端到端测试更少出错,同时仍然涵盖应用程序堆栈的广泛部分。当通过应用程序的 Web 界面进行测试特别困难时,这会派上用场。也许你甚至没有 Web UI,而是提供 REST API(因为你在某个地方有一个与该 API 通信的单页面应用程序,或者仅仅是因为你鄙视所有漂亮的东西)。无论哪种方式,一个皮下测试仅在图形用户界面下方进行测试,并且可以在不影响信心的情况下让你走得很远。如果你像我们在示例代码中那样提供 REST API,那就刚刚好
@RestController public class ExampleController { private final PersonRepository personRepository; // shortened for clarity @GetMapping("/hello/{lastName}") public String hello(@PathVariable final String lastName) { Optional<Person> foundPerson = personRepository.findByLastName(lastName); return foundPerson .map(person -> String.format("Hello %s %s!", person.getFirstName(), person.getLastName())) .orElse(String.format("Who is this '%s' you're talking about?", lastName)); } }
让我向你展示另一个在测试提供 REST API 的服务时派上用场的库。REST-assured是一个库,它为你提供了一个不错的 DSL,用于向 API 发出真正的 HTTP 请求并评估你收到的响应。
首先:将依赖项添加到你的 build.gradle
。
testCompile('io.rest-assured:rest-assured:3.0.3')
有了这个库,我们可以为我们的 REST API 实现一个端到端测试
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class HelloE2ERestTest { @Autowired private PersonRepository personRepository; @LocalServerPort private int port; @After public void tearDown() throws Exception { personRepository.deleteAll(); } @Test public void shouldReturnGreeting() throws Exception { Person peter = new Person("Peter", "Pan"); personRepository.save(peter); when() .get(String.format("http://localhost:%s/hello/Pan", port)) .then() .statusCode(is(200)) .body(containsString("Hello Peter Pan!")); } }
同样,我们使用 @SpringBootTest
启动整个 Spring 应用程序。在这种情况下,我们 @Autowire
的 PersonRepository
,以便我们可以轻松地将测试数据写入我们的数据库。当我们现在要求 REST API 向我们的朋友“Mr Pan”问候时,我们会收到一个友好的问候。太棒了!如果你甚至没有一个 Web 界面,那么这已经足够作为一个端到端测试了。
验收测试——您的功能是否正常工作?
你在测试金字塔中向上移动得越高,你就越有可能进入从用户的角度测试你正在构建的功能是否正常工作的领域。你可以将你的应用程序视为一个黑匣子,并将测试中的重点从
当我输入值 x
和 y
时,返回值应该是 z
转向
给定有一个已登录的用户
并且有一篇文章“自行车”
当用户导航到“自行车”文章的详细信息页面
并且单击“添加到购物篮”按钮
那么文章“自行车”应该在他们的购物篮中
有时你会听到术语功能测试或验收测试用于此类测试。有时人们会告诉你功能测试和验收测试是不同的东西。有时这些术语会被混为一谈。有时人们会无休止地争论措辞和定义。通常,这种讨论是造成极大困惑的一个非常重要的根源。
事情是这样的:在某个时候,你应该确保从用户的角度而不是仅仅从技术角度测试你的软件是否正常工作。你如何称呼这些测试并不重要。但是,进行这些测试很重要。选择一个术语,坚持下去,然后编写那些测试。
这也是人们谈论BDD和允许你以 BDD 方式实现测试的工具的时候。BDD 或 BDD 风格的编写测试方式可能是一个不错的技巧,可以将你的思维方式从实现细节转移到用户的需求上。继续尝试一下。
您甚至不需要采用 Cucumber 等成熟的 BDD 工具(尽管您可以采用)。某些断言库(如 chai.js)允许您使用 should
风格关键字编写断言,这可以使您的测试更像 BDD。即使您不使用提供此表示法的库,巧妙且经过良好编写的代码也允许您编写以用户行为为中心的测试。一些帮助程序方法/函数可以为您提供很大的帮助
# a sample acceptance test in Python def test_add_to_basket(): # given user = a_user_with_empty_basket() user.login() bicycle = article(name="bicycle", price=100) # when article_page.add_to_.basket(bicycle) # then assert user.basket.contains(bicycle)
验收测试可以具有不同级别的粒度。大多数情况下,它们将是相当高级别的,并通过用户界面测试您的服务。但是,了解在测试金字塔的最高级别编写验收测试在技术上没有必要是件好事。如果您的应用程序设计和手头的场景允许您在较低级别编写验收测试,那就去做吧。进行低级别测试比进行高级别测试更好。验收测试的概念 - 证明您的功能对用户正常工作 - 与您的测试金字塔完全正交。
探索性测试
即使是最勤奋的测试自动化工作也不是完美的。有时您会在自动化测试中错过某些边缘情况。有时通过编写单元测试几乎不可能检测到特定错误。某些质量问题甚至在您的自动化测试中都不会显现(考虑设计或可用性)。尽管您在测试自动化方面有最好的意图,但某种形式的手动测试仍然是一个好主意。
图 12:使用探索性测试找出构建管道未发现的所有质量问题
在您的测试组合中包含 探索性测试。这是一种手动测试方法,强调测试人员的自由和创造力,以便在运行的系统中发现质量问题。只需定期抽出一些时间,挽起袖子,尝试破坏您的应用程序。使用破坏性思维方式,想出激发应用程序中问题和错误的方法。记录您稍后找到的所有内容。注意错误、设计问题、响应时间慢、错误消息缺失或误导以及作为软件用户会让您感到烦恼的所有其他内容。
好消息是,您可以通过自动化测试轻松地自动化您的大多数发现。为发现的错误编写自动化测试可以确保将来不会出现该错误的任何回归。此外,它还可以帮助您在错误修复期间缩小该问题的根本原因。
在探索性测试期间,您会发现一些问题,这些问题会悄无声息地通过您的构建管道。不要沮丧。这是对您的构建管道成熟度的极好反馈。与任何反馈一样,请务必采取行动:考虑您可以做些什么来避免将来出现此类问题。也许您错过了一组特定的自动化测试。也许您只是在这个迭代中对自动化测试马虎了事,并且需要在未来进行更彻底的测试。也许有一个闪亮的新工具或方法,您可以在管道中使用它来避免将来出现这些问题。请务必采取行动,这样随着时间的推移,您的管道和整个软件交付将变得更加成熟。
关于测试术语的困惑
谈论不同的测试分类总是很困难。当我谈论单元测试时,我的意思可能与你的理解略有不同。对于集成测试来说,情况更糟。对一些人来说,集成测试是一项非常广泛的活动,它会测试整个系统中的许多不同部分。对我来说,它是一件相当狭窄的事情,一次只测试与一个外部部分的集成。有些人称之为集成测试,有些人称之为组件测试,有些人更喜欢服务测试这个术语。甚至还有其他人会争辩说,所有这三个术语都是完全不同的东西。没有正确或错误之分。软件开发社区只是尚未就围绕测试的明确定义术语达成共识。
不要过于拘泥于模棱两可的术语。无论你称之为端到端测试、广义堆栈测试还是功能测试都没有关系。你的集成测试对你的意义与其他公司的员工不同也没有关系。是的,如果我们的行业能够就一些明确定义的术语达成共识并坚持下去,那将非常棒。不幸的是,这种情况尚未发生。而且,由于在编写测试时存在许多细微差别,因此它实际上更像是一个范围,而不是一堆离散的存储桶,这使得一致的命名变得更加困难。
重要的收获是你应该找到适合你和你的团队的术语。明确你要编写的不同类型的测试。就团队中的命名达成一致,并就每种类型测试的范围达成共识。如果你在团队内部(甚至在组织内部)做到了一致,那么这就是你唯一应该关心的。当西蒙·斯图尔特描述他们在 Google 使用的方法时,很好地总结了这一点。我认为它完美地展示了过于纠结于名称和命名约定根本不值得麻烦。
将测试放入部署管道
如果你正在使用持续集成或持续交付,那么你将拥有一个部署管道,它将在你每次对软件进行更改时运行自动化测试。通常,此管道被分成几个阶段,这些阶段逐渐让你更有信心,认为你的软件已准备好部署到生产环境中。在了解所有这些不同类型的测试后,你可能会想知道应该如何将它们放在部署管道中。要回答这个问题,你只需考虑持续交付的一个非常基础的价值(实际上是极限编程和敏捷软件开发的核心价值之一):快速反馈。
一个好的构建管道会尽快告诉你出了问题。你不想等待一小时才能发现你最近的更改破坏了一些简单的单元测试。如果你的管道需要这么长时间才能给你反馈,那么你可能已经回家了。你可以通过将快速运行的测试放在管道的早期阶段,在几秒钟或几分钟内获取此信息。相反,你将运行时间较长的测试(通常是范围较广的测试)放在后期阶段,以不延迟快速运行测试的反馈。你看到,定义部署管道的阶段不是由测试类型驱动的,而是由它们的执行速度和范围驱动的。考虑到这一点,将一些范围非常窄且运行速度快的集成测试与单元测试放在同一阶段可能是一个非常合理的决定——仅仅是因为它们能给你更快的反馈,而不是因为你想沿着正式的测试类型划清界限。
避免测试重复
既然您知道应该编写不同类型的测试,那么还有一个陷阱需要避免:在金字塔的不同层中重复测试。虽然您的直觉可能会说没有太多测试,但我向您保证,有。测试套件中的每个测试都是额外的负担,并且不会免费提供。编写和维护测试需要时间。阅读和理解其他人的测试需要时间。当然,运行测试需要时间。
与生产代码一样,您应该力求简单并避免重复。在实现测试金字塔的上下文中,您应该牢记两条经验法则
- 如果高级测试发现错误,并且没有低级测试失败,则需要编写低级测试
- 尽可能将测试推到测试金字塔的底部
第一条规则很重要,因为低级测试可以让您更好地缩小错误范围,并以孤立的方式复制它们。当您调试手头的问题时,它们将运行得更快,并且不会那么臃肿。并且它们将作为未来的良好回归测试。第二条规则对于保持测试套件的快速运行很重要。如果您已在低级测试中自信地测试了所有条件,则无需在测试套件中保留高级测试。它只是没有增加一切正常工作的信心。在日常工作中,冗余测试会变得烦人。您的测试套件会变慢,并且在更改代码行为时需要更改更多测试。
让我们换一种说法:如果高级测试让您更有信心,表明您的应用程序工作正常,那么您应该这样做。为 Controller
类编写单元测试有助于测试 Controller 本身中的逻辑。不过,这并不能告诉您此 Controller 提供的 REST 端点是否实际上对 HTTP 请求做出响应。因此,您向上移动测试金字塔,并添加一个测试来检查是否完全如此 - 但仅此而已。您不会在高级测试中再次测试低级测试已涵盖的所有条件逻辑和边缘情况。确保高级测试专注于低级测试无法涵盖的部分。
在消除没有提供任何价值的测试时,我非常严格。我删除了低级已涵盖的高级测试(因为它们没有提供额外价值)。如果可能,我会用低级测试替换高级测试。有时这很难,特别是如果您知道想出一个测试是一项艰苦的工作。当心沉没成本谬误并按下删除键。没有理由在不再提供价值的测试上浪费更多宝贵时间。
编写干净的测试代码
与编写代码一样,想出良好而干净的测试代码需要非常小心。在您继续破解自动化测试套件之前,这里有一些提出可维护测试代码的更多提示
- 测试代码与生产代码一样重要。给予它同等程度的关怀和关注。“这只是测试代码”并不是证明代码粗糙的有效借口
- 每个测试只测试一个条件。这有助于保持测试简短且易于理解
- “安排、执行、断言”或“给定、何时、然后”是保持测试结构良好的良好助记符
- 可读性很重要。不要试图过于DRY。如果可读性得到改善,重复是可以的。尝试在DRY 和 DAMP 代码之间找到平衡
- 当有疑问时,使用三法则来决定何时重构。先使用再重用
结论
就是这样!我知道这是一个漫长而艰苦的阅读,解释了为什么以及如何测试您的软件。好消息是,这些信息是相当永恒的,并且与您正在构建的软件类型无关。无论您是在微服务环境、物联网设备、移动应用程序还是 Web 应用程序上工作,本文中的经验教训都可以应用于所有这些。
我希望本文中有一些有用的东西。现在继续查看示例代码,并将此处解释的一些概念纳入您的测试组合中。拥有一个可靠的测试组合需要付出一些努力。从长远来看,它会得到回报,并且会让您作为开发人员的生活更加平静,相信我。
致谢
感谢克莱尔·萨德伯里、克里斯·福特、玛莎·罗特、安德鲁·琼斯-魏斯、大卫·斯瓦洛、艾科·克洛斯特曼、巴斯蒂安·斯坦、塞巴斯蒂安·罗伊德和比吉塔·伯克勒为本文的早期草稿提供反馈和建议。感谢马丁·福勒的建议、见解和支持。
重大修订
2018 年 2 月 26 日:发布带有 UI 测试的分期
2018 年 2 月 22 日:发布带有契约测试的分期
2018 年 2 月 20 日:发布带有集成测试的分期
2018 年 2 月 15 日:发布带有单元测试的分期
2018 年 2 月 14 日:第一期,介绍金字塔和示例应用程序