Thoughtworks德国的顾问Ham Vocke发表在Martin Fowler网站上的这篇文章完整介绍了测试金字塔的概念,并以通过实例介绍了如何应用测试金字塔。文中涉及到很多软件测试和质量工程最佳实践,可以帮助读者建立起正确的软件测试理念。原文:The Practical Test Pyramid[1]
“测试金字塔”是一种比喻,告诉我们如何根据测试的不同粒度进行分组,以及在每一组中应该包含多少测试。尽管测试金字塔的概念已经提出了一段时间,但团队仍然需要付出努力才能在实践中适当运用。本文回顾了测试金字塔最初的概念,并展示了如何将其付诸实践,包括在金字塔的不同级别中应该覆盖哪些类型的测试,并给出了如何实现这些测试的实例。
软件在投入生产环境之前需要进行测试,随着软件开发方法的成熟,软件测试方法也日趋成熟。开发团队不再需要大量手工测试人员,而是将测试工作的大部分自动化。自动化测试可以让团队在几秒或几分钟内就可以知道软件是否出了问题,而不需要等上几天或几个星期。
自动化测试大大缩短了反馈循环,这与敏捷开发实践、持续交付和DevOps文化密切相关,拥有有效的软件测试方法可以让团队快速且充满信心的进行测试。
本文探讨了全面的测试组合应该是什么样的,从而无论我们是在构建微服务架构、移动应用程序还是物联网生态系统,都能够提供快速响应、高可靠性和可维护性。我们还将深入探讨构建高效且可读的自动化测试的细节。
(测试)自动化的重要性
软件已经成为我们生活的世界的重要组成部分,其发展早已超出了早期的单一目标,即提高商业效率。如今,许多公司都在想方设法成为一流的数字公司。作为用户,我们每个人每天都要与越来越多的软件进行交互。创新的车轮转得越来越快了。
如果我们想要跟上创新的速度,就必须寻找在不牺牲软件质量的情况下更快交付软件的方法。持续交付(Continuous delivery)就是这样一种实践,帮助我们以自动化的方式确保软件可以在任何时候发布到生产环境中。在持续交付实践中,我们通过构建流水线(build pipeline)来自动化的测试软件,并将其部署到测试和生产环境中。
手动构建、测试和部署数量不断增加的软件越来越不可能了,除非我们愿意把所有时间都花在手动重复的工作上,而不是专注于交付可工作的软件。自动化一切——从构建到测试,部署和基础设施——是我们前进的唯一途径。
传统软件测试是纯手工作业,将应用部署到测试环境中,然后执行一些黑盒测试,例如通过点击用户界面来查看是否有什么问题。这些测试通常由测试脚本指定,以确保测试人员执行一致的检查。
很明显,手工测试所有更改既耗时又重复、乏味。重复就会无聊,而无聊会导致错误,促使你在每个周末都想要找一份不同的工作。
幸运的是,对于重复性任务有一个补救办法:自动化。
作为软件开发人员,自动化重复的测试可能会对我们的生活产生重大影响。将这些测试自动化,就不再需要盲目遵循点击协议来检查软件是否仍然正常工作,就可以更轻松的更改代码。如果你曾经尝试过在没有适当测试套件的情况下进行大规模重构,我敢打赌你一定知道这种经历有多可怕。我们怎么知道有没有在重构过程中改坏了什么东西?当然,我们可以执行所有手工测试用例,这就是个办法。但说实话,会有人真的喜欢这样吗?如果我们可以在做出大规模变更的时候,在喝杯咖啡的时间里就能知道有没有破坏什么东西,会怎样呢?要我说,这听起来更有趣。
测试金字塔
如果你想认真对待软件的自动化测试,有一个关键的概念你应该知道:测试金字塔。Mike Cohn在他的《敏捷的成功(Succeeding with Agile)》一书中提出了这个概念。这是一个很好的隐喻,告诉我们要考虑不同的测试层级,以及在每个层级上要做多少测试。
Mike Cohn最初提出的测试金字塔由三层组成,从下到上分别是:
- 单元测试
- 服务测试
- UI测试
不幸的是,如果仔细观察,你会发现金字塔测试的概念有些不足。有些人认为,Mike Cohn的测试金字塔的命名或某些概念不甚理想,我同意这个观点。从现代观点来看,测试金字塔似乎过于简单,因此可能具有误导性。
尽管如此,由于其简单性,当涉及到建立自己的测试套件时,测试金字塔本质上是一个很好的经验法则,最好记住Cohn最初在测试金字塔中提出的两个观点:
- 用不同粒度编写测试
- 层级越高,应该做的测试就越少
坚持用金字塔的形状来构建健康、快速和可维护的测试套件,首先编写大量小而快速的单元测试,其次编写一些更粗粒度的测试以及较少的对应用程序进行端到端测试的高级测试。注意,不要以冰激凌蛋筒测试[2]结束,那将会使得测试的维护和运行花费大量的时间,最后会成为一场噩梦。
不要过于执着于Cohn测试金字塔中每一层的名字。事实上,它们很容易引起误解,服务测试是一个很难理解的术语(Cohn自己也提到过,许多开发人员完全忽略了这一层[3])。很明显,在react、angular、ember.js等单页应用框架出现以后,UI测试就不必出现在金字塔的最高层了,因为完全可以在这些框架中对UI进行单元测试。
考虑到最初命名的缺点,只要在代码库和团队讨论中保持一致,完全可以用其他的名字命名测试层级。
我们用的库和工具
- JUnit[4]: 测试执行器
- Mockito[5]: 模拟依赖关系
- Wiremock[6]: 对外部服务打桩
- Pact[7]: 编写CDC测试
- Selenium[8]: 编写UI驱动的端到端测试
- REST-assured[9]: 编写REST API驱动的端到端测试
示例应用程序
我编写了一个简单的包含测试套件的微服务[10],其中包含针对测试金字塔不同层级的测试。
示例应用程序展现了典型微服务的特征,提供了REST接口,与数据库交互,并从第三方REST服务获取信息。该程序基于Spring Boot[11]实现,即使你从未用过Spring Boot,也应该可以理解。
请务必在Github上查看代码[10],readme含有在计算机上执行应用程序及其自动化测试所需的说明。
功能
应用程序的功能很简单,提供了一个有三个endpoint的REST接口:
GET /hello # 总是返回“Hello World”。
GET /hello/{lastname} # 用提供的lastname查找某人,如果找到,则返回“Hello {Firstname} {Lastname}”。
GET /weather # 返回德国汉堡当前的天气状况。
概要架构
从外部观察,该系统具有以下架构:
我们的微服务提供了一个可以通过HTTP调用的REST接口。对于某些endpoint,服务从数据库中获取信息。在其他情况下,服务将通过HTTP调用外部天气API[12]来获取和显示当前的天气状况。
内部架构
在内部,Spring Service是典型的Spring架构:
-
Controller
类提供REST endpoint并且处理HTTP请求/响应 -
Repository
类提供数据库接口并负责向持久化存储写入和读取数据 -
Client
类与其他API通信,在我们的例子中,它通过HTTPS从darksky.net的天气API获取JSON响应 -
Domain
类反映我们的域模型[13],包括域逻辑(实话实说,在我们的例子中,域逻辑相当琐碎)。
有经验的Spring开发人员可能会注意到这里缺了一个常用的层:受领域驱动设计(Domain-Driven Design)[14]的启发,许多开发人员会构建一个由服务(service)类组成的服务层(service layer)。在这个应用程序中,我决定不包含服务层,一是这个应用程序足够简单,服务层将成为不必要的间接层,二是我认为人们在服务层上做得太多了。我经常遇到这样的代码库:整个业务逻辑都反映在服务类中,领域模型仅仅成为数据层,而不是行为层(贫血领域模型[15])。对于重要的应用程序来说,这将浪费大量潜在资源来保持代码的良好结构和可测试性,并且没有充分利用面向对象的能力。
我们的存储库很直接,并提供简单的CRUD功能。为了保持代码简单,我们使用了Spring Data[16]。Spring Data提供了简单通用的CRUD存储库实现,可以直接使用,而不用自己造轮子。它还为测试提供了一个内存数据库,而不需要使用生产环境中那样真正的PostgreSQL数据库。
看看代码库,熟悉一下内部架构,这对接下来对应用程序的测试非常有用!
单元测试
测试套件的基础由单元测试组成,单元测试确保代码库的某个单元是按预期工作的。单元测试的范围是测试套件中所有测试中最窄的,而数量将大大超过任何其他类型的测试。
什么是单元测试?
如果向三个不同的人询问“单元(unit)”在单元测试上下文中的意思,可能会得到四个不同的、略有差别的答案。在某种程度上,这是自己定义的问题,没有标准答案也没关系。
如果使用函数式语言,那么单元很可能是一个单一的函数。单元测试将调用具有不同参数的函数,并确保它返回预期的值。在面向对象语言中,单元可以是单一的方法,也可以是完整的类。
依赖和独立(Sociable and Solitary)
有些人认为,被测单元的所有依赖(例如,被测试类调用的其他类)都应该用mock或stub替换,以实现完美的隔离,并避免副作用和复杂的测试设置。其他人则认为,只有那些速度慢或有较大副作用的依赖(例如,访问数据库或进行网络调用的类)才应该被mock或stub。
有时候[17],人们会将这两种测试称为独立单元测试(solitary unit tests,对于所有依赖都stub的测试)和依赖单元测试(sociable unit tests,允许与真正的依赖交互的测试)(Jay Fields的《有效地使用单元测试》[18]创造了这些术语)。如果有空的话,你可以深入阅读更多关于这两种不同思想流派的利弊[19]。
其实,进行独立单元测试还是依赖单元测试并不重要,重要的是要编写自动化测试。就我个人而言,两种方法都会用。如果使用真正的依赖很不方便,那就使用mock和stub。如果想让真正的依赖方参与进来,让测试更有信心,那么就只保留服务的最外层。
模拟和打桩(Mocking and Stubbing)
mock和stub是两种不同类型的模拟(Test Double[2])(不止这两种)。许多人交替使用术语Mock和Stub,我认为最好还是精确一点,记住它们的特定属性。测试模拟器可以帮助我们在测试过程中替换在生产中将会使用的对象。
简而言之,这意味着用一个模拟版本替换真实的东西(例如类、模块或函数)。模拟版本的外观和行为与真版本相似(响应相同的方法调用),但其响应是在单元测试开始时定义的伪造响应。
并不只有单元测试会使用模拟,更复杂的模拟器可以用于以受控的方式模拟系统的整个部分。然而,在单元测试中,很可能会遇到许多mock和stub(取决于使用依赖单元测试还是独立单元测试),许多现代语言和库使得设置mock和stub变得越来越方便。
无论选择何种技术,语言的标准库或流行的第三方库都很有可能提供了优雅的方法来设置mock。即使从头编写自己的mock,也只是编写一个与真实的类/模块/函数具有相同签名的模拟实现,并在测试中设置这个模拟对象。
单元测试将会执行得非常快,在普通机器上,可以期望在几分钟内运行数千个单元测试。独立测试代码库的一小部分,避免访问数据库、文件系统或触发HTTP查询(通过对这些部分使用mock和stub),从而保证测试速度。
一旦掌握了编写单元测试的诀窍,就会越来越熟练。模拟外部依赖,设置输入数据,调用测试对象,检查返回值是否符合预期。研究测试驱动开发(Test-Driven Development)[21],让单元测试指导开发。如果正确应用,TDD可以帮助我们遵循优秀的流程,并做出良好和可维护的设计,同时自动生成全面的完全自动化的测试套件。 不过,这并不是银弹。 给它一个真正的机会,看看对你是否适合。
测试什么?
单元测试的好处是,可以为所有的生产代码类编写单元测试,而不用考虑它们的功能或属于内部结构中的哪个层。我们可以对控制器进行单元测试,就像可以对存储库、域类或文件阅读器进行单元测试一样。只要坚持每个生产类一个测试类的经验法则,就有了一个良好的开端。
单元测试类至少应该测试类的public
接口,私有方法因为无法从测试类调用,因此无法被测试。从测试类可以访问protected
或package-private
接口(假设测试类的包结构与生产类相同),但是测试这些方法可能已经太过了。
在编写单元测试时,有一个很好的界限:它们应该确保测试了所有重要的代码路径(包括主路径和边缘情况),同时不应该与实现过于紧密的耦合。
为什么?
过于接近产品代码的测试很快就会变得令人讨厌。一旦重构生产代码(快速回顾一下:重构意味着在不改变外部可见行为的情况下改变代码的内部结构),单元测试就会失败。
这就失去了单元测试的一大好处:充当代码更改的安全网。你很快就会厌倦那些每次重构时都会失败的愚蠢测试,这导致了更多的工作而不是提供帮助,你会问这些愚蠢的测试是谁的主意?
那怎么办呢?不要在单元测试中反映代码的内部结构,测试可观察到的行为。想一下:
如果输入值x和y,结果会是z吗?
而不是:
如果输入x和y,方法会先调用类A,然后调用类B,然后返回类A的结果加上类B的结果吗?
私有方法通常被视为实现细节,这就是为什么你甚至不应该有测试它们的冲动。
我经常听到单元测试(TDD)的反对者争辩说,编写单元测试是毫无意义的工作,因为必须测试所有的方法,以获得较高的测试覆盖率。他们经常引用这样的场景:过于急切的团队领导迫使他们为getter和setter以及所有其他琐碎的代码编写单元测试,以达到100%的测试覆盖率。
这是完全错误的。
是的,你应该测试公共接口。然而,更重要的是,不要测试琐碎的代码。别担心,Kent Beck说过这没事儿[23]。你不会从测试简单的getter或setter或其他琐碎的实现(例如,没有任何条件逻辑)中获得任何东西。节省下来的时间又可以参加一个会议了,万岁!
但我真的需要测试私有方法
如果您发现自己非常需要测试私有方法,那应该后退一步,问问自己为什么。
我很确定这更多是一个设计问题,而不是测试范围问题。你很可能觉得有必要测试私有方法,因为它很复杂,并且通过类的公共接口测试这个方法需要进行大量笨拙的设置。
每当我发现自己处于这种情况时,通常会得出这样的结论:正在测试的类已经太复杂了。它做得太多,违反了单一责任原则——SOLID[22]五原则中的S原则。
对我来说,通常有效的解决方案是将原始类分成两个类。通常只需要思考一到两分钟,就能找到一个好方法,把大类分成两个单一职责的小类。我将私有方法(我迫切希望测试的方法)移到新类中,并让旧类调用新方法。太棒了,那个难以测试的私有方法现在是public的,可以很容易测试。除此之外,我还通过坚持单一职责原则改进了代码结构。
测试结构
对于所有测试(不限于单元测试)来说,一个好的结构是:
- 设置测试数据
- 调用被测方法
- 断言返回预期的结果
有个很好的助记方法可以记住这个结构:“Arrange, Act, Assert[24]”。另一个方法来自BDD,即"given, when, then[25]"三元组,其中given反映了设置,when是方法调用,then是断言部分。
此模式还可以应用于其他更高级的测试。在每种情况下,它们都确保测试保持易于阅读和一致。此外,这种结构编写的测试往往更短,更有表现力。
实现单元测试
现在我们已经知道了测试什么以及如何构造单元测试,终于可以看到一个实际的示例了。
我们以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));
}
}
我们基于JUnit(Java测试框架事实标准)编写单元测试,利用Mockito将真正的PersonRepository
类替换为测试的stub,从而在测试中定义stub方法返回伪造响应。stub使测试更简单、更可预测,可以轻松设置测试数据。
按照arrange、act、assert结构,我们编写了两个单元测试:一个正常情况测试和一个无法搜索到人的测试。 第一个正常测试用例创建了新的person对象,并告诉模拟的存储库当以“Pan”作为lastName
参数调用时返回该对象。 测试接着调用被测方法,并在最后断言响应等于预期响应。
第二个测试的工作方式与此类似,但测试的是无法根据给定参数找到人的场景。
专业的测试助手
我们可以为整个代码库编写单元测试,而不管处于应用程序架构的哪个层级,这太棒了。这个例子展示了控制器的简单单元测试。不幸的是,对于Spring控制器来说,这种方法有一个缺点:Spring MVC控制器大量使用注释来声明侦听的路径、要处理的HTTP操作、从URL路径或查询参数解析的参数等等。简单的在单元测试中调用控制器的方法并不能测试所有这些关键的部分。幸运的是,Spring的人提供了一个很好的测试助手,可以用它编写更好的控制器测试。这个工具是MockMVC[60],提供了很好的DSL,可以用来触发针对控制器的模拟请求,并检查一切是否正常。示例代码库中包含有一个示例[61]。很多框架都提供了测试助手,帮助我们更好的测试代码库的特定部分。请查看所选择的框架的文档,并查看它们是否为自动化测试提供了任何有用的帮助。
集成测试
所有重要的应用程序都将与其他部分集成(数据库、文件系统、对其他应用程序的网络调用)。在编写单元测试时,为了实现更好的隔离和更快的测试,通常不会考虑这些部分。尽管如此,仍然需要测试应用程序与其他部分的交互。这时就需要集成测试[26],它们负责测试应用程序与应用程序之外的所有部分的集成。
对于自动化测试,这意味着不仅需要运行应用程序,还需要运行与之集成的组件。如果测试与数据库的集成,则在运行测试时需要运行数据库。对于从磁盘读取文件的测试,需要将文件保存到磁盘并在集成测试中加载。
之前提到过,“单元测试”这个术语比较模糊,对于“集成测试”更是如此。对某些人来说,集成测试意味着测试连接到系统中其他应用程序的整个应用程序栈。而我喜欢更狭义的集成测试,每次测试一个集成点,用测试模拟器替换单独的服务和数据库。结合契约测试和运行针对测试模拟器的契约测试,以及真正的实现,可以构建出更快、更独立、通常更容易推理的集成测试。
狭义集成测试位于服务的边界,从概念上讲,它们总是关于触发一个导致与外部部分(文件系统、数据库、独立服务)集成的操作。数据库集成测试就像这样:
- 启动数据库
- 将应用连接到数据库
- 在代码中触发将数据写入数据库的函数调用
- 从数据库读取数据,检查预期的数据是否已写入数据库
另一个例子是通过REST API测试我们的服务与另一个独立服务的集成,如下所示:
- 启动应用
- 启动独立服务的一个实例(或具有相同接口的测试模拟器)
- 在代码中触发从独立服务的API读取数据的函数调用
- 检查应用程序是否能够正确解析响应
集成测试(类似于单元测试)有可能是相当白盒化的。有些框架允许启动应用程序的同时仍然能够模拟应用程序的其他部分,以便检查是否发生了正确的交互。
为序列化或反序列化数据的所有代码块编写集成测试,这种情况比想象的更常见。思考下面的情况:
- 调用服务的REST API
- 读取和写入数据库
- 调用其他应用的API
- 读取和写入队列
- 写入文件系统
围绕这些边界编写集成测试可以确保向这些外部依赖读取/写入数据的操作都能正常工作。
当编写狭义集成测试(narrow integration tests)时,应该着眼于本地运行外部依赖:启动本地MySQL数据库,测试本地ext4文件系统。如果要与单独的服务集成,可以在本地运行该服务的实例,或者构建并运行模仿真实服务行为的模拟版本。
如果无法在本地运行第三方服务,则应选择运行专用测试实例,并在运行集成测试时指向该测试实例,避免在自动化测试中与实际的生产系统集成。在生产系统上处理成千上万的测试请求,肯定会把别人气疯,因为你弄乱了他们的日志(这是最好的情况),甚至破坏了他们的服务(在最坏的情况下)。通过网络与服务集成是广义集成测试(broad integration test )的典型特征,这会使测试变慢,并且通常难以编写。
在测试金字塔上,集成测试比单元测试位于更高的层次。集成文件系统和数据库这样缓慢的部分往往要比使用这些部分的stub运行单元测试慢得多。它们也可能比小型和隔离的单元测试更难编写,毕竟必须将外部依赖作为测试的一部分来处理。尽管如此,这些测试的优点是使我们确信应用程序能够正确的与需要通信的所有外部依赖一起工作,而单元测试无法解决这个问题。
数据库集成
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使用内存数据库。当它在classpath
上找到H2时,就在运行测试时使用H2。
当使用int
配置文件运行真正的应用程序时(例如,通过设置SPRING_PROFILES_ACTIVE=int
作为环境变量),它会连接到在application-int.properties
中定义的PostgreSQL数据库。
我知道,有大量的可怕的Spring细节需要了解和理解。要做到这一点,必须查看大量文档[27]。生成的代码看起来很简单,但如果不了解Spring的详细信息,就很难理解。
除此之外,使用内存数据库是有风险的。毕竟,集成测试针对的是与生产中不同类型的数据库。继续前进,然后自己决定是否喜欢Spring的魔法和简单的代码,还是更显式的、冗长的实现。
已经解释得够多了,下面是一个简单的集成测试,它将Person保存到数据库中,并根据其姓氏找到它:
@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)));
}
}
可以看到,集成测试遵循与单元测试相同的arrange, act, assert结构,之前说过这是一个普遍的概念!
与独立服务的集成
我们的微服务与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));
}
}
我们在固定端口(8089)上实例化了一个WireMockRule
来使用Wiremock。我们可以通过DSL设置Wiremock服务,定义应该侦听的endpoint,并设置应该返回的响应。
然后调用想要测试的方法,这个方法会调用第三方服务并检查结果是否被正确解析。
重要的是要理解测试如何知道应该调用模拟的Wiremock服务器,而不是真正的darksky API。秘密就在src/test/resources
中的application.properties
文件,这是Spring在运行测试时加载的属性文件。在这个文件中,我们用符合测试目的的值覆盖了配置,如API keys和URL,从而让测试调用模拟的Wiremock服务器而不是真正的服务器:
weather.url = http://localhost:8089
注意,这里定义的端口必须与我们在测试中实例化WireMockRule时定义的端口相同。在我们的测试中,用模拟天气API的URL替换真实的天气API的URL可以通过注入WeatherClient类的构造函数来实现:
@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
- 通过队列构建事件驱动体系架构
对于每个接口,都涉及两方:生产者(provider)和消费者(consumer)。生产者向消费者提供数据,消费者处理从生产者获得的数据。在REST中,生产者构建包含所有必需endpoint的REST API,消费者调用这个REST API来获取数据或触发其他服务中的更改。在异步的、事件驱动的架构中,生产者(通常称为发布者publisher)将数据发布到队列中,消费者(通常称为订阅者subscriber)订阅这些队列并读取和处理数据。
如果将服务的消费和生产分散到不同的团队中,就必须明确指定这些服务之间的接口(所谓的契约contract)。传统上,公司处理这个问题的方式如下:
- 编写一个长而详细的接口规范(契约)
- 根据定义的契约实现提供服务
- 将接口规范扔给消费团队
- 等他们实现使用接口的部分
- 运行一些大规模手动系统测试,看看是否一切正常
- 希望这两个团队永远坚持接口定义,不要搞砸了
更现代的软件开发团队取代了步骤5、6,转而使用一些更自动化的东西:通过自动化的契约测试[28]确保消费者和生产者的实现遵循已定义的契约。这是一组很好的回归测试套件,可以确保尽早发现契约的偏差。
在更敏捷的组织中,应该采取更有效并且浪费更少的路径。我们在同一个组织中构建应用,应该可以方便的和其他服务的开发人员直接交谈,而不是只把塞满细节的文档扔过去。毕竟都是同事,不是只能通过客户支持或其他合同规定的合法途径与之沟通的第三方供应商。
消费者驱动的契约测试(CDC tests,Consumer-Driven Contract tests)让消费者驱动契约的实现[29]。通过CDC,接口的消费者可以编写测试,检查他们从接口中所需要获取的数据。然后,消费团队发布这些测试,以便生产者团队可以轻松获取并执行这些测试。提供服务的团队现在可以通过运行CDC测试来开发API,一旦所有测试通过,就知道已经实现了消费团队需要的东西。
这种方法允许生产者团队只实现真正需要的东西(保持简单,YAGNI(You ain't gonna need it)等等)。提供接口的团队应该不断获取并运行这些CDC测试(在构建流水线中),以便可以立即发现任何破坏性的变更。如果接口被破坏,CDC测试就会失败,从而阻止有问题的变更的执行。只要测试保持绿色状态,团队就可以做出任何更改,而不必担心其他团队。消费者驱动的契约方法通常遵循如下过程:
- 消费团队根据所有消费者的期望编写自动化测试
- 将测试发布给生产者团队
- 生产者团队不断运行CDC测试,并使它们保持绿色
- CDC测试一旦中断,双方就会互相沟通
如果组织采用微服务架构,那么CDC测试是向建立自治团队迈出的一大步。CDC测试是一种促进团队沟通的自动化方式,确保团队之间的接口在任何时候都能工作。失败的CDC测试是一种很好的指示,告诉我们应该直接跟受影响的团队沟通,告诉他们即将到来的API变更,并搞清楚我们应该如何推进。
简单的CDC测试实现可以像针对API发出请求并断言响应需要包含的内容一样简单,然后这些测试可以打包为可执行文件(.gem、.jar、.sh),并将其上传到其他团队可以获取的地方(例如,像Artifactory[30]这样的工件存储库)。
在过去的几年中,CDC方法变得越来越流行,并且已经构建了一些可以简化编写和发布的工具。
Pact[31]可能是这些工具中最突出的一个,它提供了一种为消费者和生产者编写测试的复杂方法,提供开箱即用的独立服务stub,并支持与其他团队交换CDC测试。Pact已经被移植到很多平台上,可以与JVM语言、Ruby、.NET、JavaScript等一起使用。
如果想要尝试CDC,但又不知道如何开始,那么Pact是一个明智的选择。阅读文档[32]可能在一开始会让人不知所措,保持耐心,这会有助于我们对CDC有明确的了解,从而帮助我们在与其他团队合作时更容易提倡使用CDC。
消费者驱动的契约测试能够真正改变游戏规则,从而建立起行动迅速且充满信心的自治团队。建议你好好研究一下这个概念,然后试一试。一套可靠的CDC测试对于在不破坏其他服务的情况下快速进行测试是非常宝贵的。
消费者测试(我们团队)
由于我们的微服务使用了天气API,因此我们有责任编写消费者测试,定义我们的微服务和天气服务之间的合约(API)的契约。
首先,我们在build.gradle
中包含了用于编写pact消费者测试的库:
testCompile('au.com.dius:pact-jvm-consumer-junit_2.11:3.5.5')
有了这个库,我们就可以实现消费者测试,并使用pact的mock服务:
@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
非常相似。这次我们没有使用Wiremock作为服务mock,而是使用Pact。实际上,消费者测试的工作方式与集成测试完全一样,我们用stub替换真正的第三方服务,定义预期响应,并检查客户端是否能够正确解析响应。从这个意义上说,WeatherClientConsumerTest
本身就是一个狭义集成测试。与基于wiremock的测试相比,这个测试的优点是每次运行时都会生成一个pact文件(可以在target/pact /<pact-name>.json
中找到),这个文件以一种特殊的JSON格式描述了对契约的期望,可以用来验证stub服务的行为是否与真实服务相似。我们可以将契约文件交给提供接口的团队,他们可以基于其中定义的契约编写生产者测试,这样就可以测试他们的API是否满足了我们所有的期望。
可以看到,这就是CDC的消费者驱动部分的来源。用户通过描述他们的期望来驱动接口的实现,生产者必须确保满足并实现了所有的期望。没有花哨的东西,没有YAGNI之类的东西。
可以采用多种方式将契约文件提交给生产者团队,一个简单的方式是将它们签入版本控制系统,并告诉生产者团队获取契约文件的最新版本。更先进的方法是使用工件存储库、Amazon的S3之类的服务或协议代理。不过我们可以先从简单的开始,然后根据需求做出选择。
在真实应用中,不需要同时进行集成测试和客户端的消费者测试。示例代码库包含了这两种方法,以便展示如何使用其中任何一种测试。如果想基于pact编写CDC测试,建议你可以坚持使用后者。编写测试的工作是相同的,使用pact的好处是,可以自动获得具有契约期望的pact文件,其他团队可以使用该文件轻松实现生产者测试。当然,这只有在能够说服其他团队也使用契约时才有意义。如果这不起作用,使用集成测试和Wiremock组合是个不错的plan B。
生产者测试(其他团队)
生产者测试必须由提供天气API的人来实现。我们正在使用darksky.net提供的公共API。理论上,darksky团队将在他们那端实现生产者测试,以检查是否违反了他们的应用和我们的服务之间的契约。
很明显,他们并不关心我们的小型测试应用,也不会为我们实现CDC测试。这就是面向公众的API和采用微服务的组织之间的巨大区别。面向公众的API没法考虑到每一个消费者,否则他们就无法发展。在组织内,应用很可能只面向少数用户,最多可能是几十个用户,为了保持系统稳定,可以很好的为这些接口编写生产者测试。
生产者团队获得契约文件,并根据所提供的服务运行,从而实现了一个生产者测试。该测试读取契约文件,s模拟测试数据,并针对契约文件中定义的期望运行服务。
pact已经为实现生产者测试编写了几个库,在GitHub仓库[33]里提供了很好的概述,可以选择一个最适合当前技术栈的库。
为方便起见,我们假设darksky API也是在Spring Boot中实现的。在这种情况下,可以使用Spring pact provider[34],它可以方便的挂到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"));
}
}
可以看到,生产者测试所要做的就是加载pact文件(例如,使用@PactFolder注释来加载之前下载的pact文件),然后定义如何提供预定义状态的测试数据(例如,使用Mockito模拟)。不需要实现定制测试,所有这些都来自于契约文件。生产者测试必须与消费者测试中声明的生产者名称和状态匹配,这一点很重要。
生产者测试(我们团队)
我们已经了解了如何测试我们的服务和天气提供商之间的契约。通过这个接口,我们的服务充当消费者,天气服务充当生产者。再进一步思考下,我们的服务也充当了其他人的生产者:我们提供了REST API,该API提供了几个可供他人使用的endpoint。
因为我们刚刚了解到契约测试非常流行,所以当然也为这个契约编写了契约测试。幸运的是,我们使用的是消费者驱动的契约,因此所有的消费团队都向我们发送了契约,我们可以使用这些契约来实现REST API的生产者测试。
让我们先把Spring的Pact provider程序库添加到项目中:
testCompile('au.com.dius:pact-jvm-provider-spring_2.12:3.5.5')
实现生产者测试,我们遵循与前面描述的相同的模式。简单起见,我们只将消费者[35]提供的契约文件检入存储库,这样可以更容易实现目的。在实际场景中,可能会使用更复杂的机制来分发pact文件。
@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文件提供状态,就这么简单。一旦我们运行生产者测试,Pact将获取该pact文件并针对我们的服务发出HTTP请求,然后根据设置的状态进行响应。
UI测试
大多数应用程序都有某种用户界面。一般来说,在web应用的上下文中讨论的是web界面。人们经常忘记,REST API或命令行界面和花哨的web用户界面一样,都是用户界面。
UI测试用来测试应用的用户界面是否正确工作。用户输入应该触发正确的操作,数据应该呈现给用户,UI状态应该按照预期改变。
UI测试和端到端测试有时被认为是一回事(就像Mike Cohn的例子)。对我来说,这合并了两个正交的概念。
是的,端到端测试应用程序通常意味着通过用户界面驱动测试。然而,反过来就不正确了。
测试用户界面并不一定要以端到端方式完成。根据使用的技术,测试用户界面就像为前端javascript代码编写单元测试一样简单。
在传统web应用中,用户界面测试可以通过像Selenium[8]这样的工具来实现。 如果REST API就是用户界面,那么应该针对API编写适当的集成测试。
对于web界面,可能需要在多个方面对UI进行测试:行为、布局、可用性以及是否符合公司设计,而这些只是其中的一小部分。
幸运的是,测试用户界面的行为非常简单。在这里单击一下,在那里输入数据,并希望用户界面的状态相应改变。现代的单页应用框架(react[36]、vue.js[37]、Angular[38]等)通常都有自己的工具和助手类,允许我们以低级方式(单元测试)对交互进行彻底的测试。即使我们使用原生javascript实现自己的前端,也可以使用Jasmine[39]或Mocha等常规测试工具。对于更传统的、服务器渲染的应用,基于Selenium的测试将是最佳选择。
测试web应用的布局是否完整有点困难,根据应用和用户需求,我们可能想要确保代码变更不会破坏网站的布局。
问题在于,计算机在检查某件东西是否“看起来不错”方面是出了名的糟糕(也许未来某种聪明的机器学习算法可以改变这一点)。
如果想在构建过程中自动检查web应用的设计,可以尝试一些工具。这些工具大多基于Selenium以不同的浏览器和格式打开web应用,截图并将其与之前的截图进行比较。如果新旧截图有意料之外的差别,工具就会报告出来。
Galen[41]就是这些工具之一。但是,如果特殊需求,即使自己开发解决方案也不是很困难。为了实现类似目标,我工作过的一些团队构建了lineup[42]及其Java版本jlineup[43],这两种工具都采用了之前描述过的基于Selenium的方法。
一旦想测试可用性和“看起来不错”的因素,就离开了自动化测试的领域,应该依靠探索性测试[44]、可用性测试(这甚至可以像走廊测试(hallway testing)[45]一样简单),或者向用户展示,看看他们是否喜欢使用这个产品,是否可以使用所有功能而不会感到沮丧或烦恼。
端到端测试
通过用户界面驱动测试已部署的应用程序是一种端到端测试应用的方式,前面描述的webdriver驱动的UI测试就是端到端测试的好例子。
当我们需要判断软件是否工作时,端到端测试(也称为Board Stack Tests[46])能带给我们最大的信心。Selenium[8]和WebDriver协议[47]允许通过(无头)浏览器自动驱动部署的服务,执行点击、输入数据和检查用户界面状态等操作。我们可以直接使用Selenium或使用构建在它之上的工具,Nightwatch[48]就是其中之一。
端到端测试也有自己的问题。它们是出了名的脆弱,经常因为意想不到和无法预见的原因而失败,而这些失败通常误报。用户界面越复杂,测试就越不稳定。浏览器的怪癖、时序问题、动画和意外弹出的对话框等需要花费大量调试时间,而这些只是问题的一部分。
在微服务架构下,还有一个大问题是谁负责编写这些测试。因为跨越了多个服务(整个系统),所以没有一个团队负责编写端到端测试。
如果有一个集中的质量保证团队,他们看起来很适合。但是,一个集中的QA团队本身就是一个典型的反模式,不应该在DevOps世界中出现。在DevOps世界中,团队应该是真正的跨职能团队。因此谁应该负责端到端测试并没有简单的答案,也许组织内有一个实践社区或质量协会来处理这些问题,因此能否找到正确答案在很大程度上取决于你的组织。
此外,端到端测试需要大量的维护,并且运行速度非常慢。考虑一个有多个微服务的场景,甚至无法在本地运行端到端测试,因为这需要在本地启动所有的微服务。除非运气很好,否则在开发机上运行数百个应用程序肯定会把内存撑爆。
由于端到端测试的维护成本很高,应该力求将端到端测试的数量减少到最少。
思考什么才是用户与应用进行的高价值交互,尝试定义产品核心价值的用户旅程,并将这些用户旅程中最重要的步骤转化为自动化的端到端测试。
如果你正在建立电子商务网站,最有价值的客户旅程可能是用户搜索产品,放进购物篮,然后结账,就这么多。只要旅程还能继续,就不会有大麻烦。也许你还会发现一两个更重要的用户旅程,可以将其转换为端到端测试。再多的话可能造成的痛苦就会多于好处。
记住:在测试金字塔中有很多较低的层次,在那里我们已经测试了各种各样的边缘情况以及与系统其他部分的集成,没有必要在更高层次上重复这些测试。过高的维护成本和大量的误报会减缓我们的速度,并导致对测试失去信任,而这是迟早的事。
用户界面端到端测试
对于端到端测试,Selenium[8]和WebDriver协议[47]是许多开发人员的首选工具。基于Selenium,我们可以选择喜欢的浏览器,让它自动调用网站,这儿点点那儿点点,输入数据,并检查用户界面的内容变化。
Selenium需要可以启动并用于运行测试的浏览器。对于不同的浏览器,可以使用不同的“驱动”。选择一个[49](或多个)并将其添加到build.gradle
中。无论选择哪种浏览器,都需要确保团队和CI服务器的所有开发人员都在本地安装了浏览器的正确版本。保持同步可能会非常痛苦,对于Java来说,有一个不错的小工具webdrivermanager[50],可以自动下载并设置想使用的浏览器的正确版本。将这两个依赖项添加到build.gradle
,这样就准备好了:
testCompile('org.seleniumhq.selenium:selenium-chrome-driver:2.53.1')
testCompile('io.github.bonigarcia:webdrivermanager:1.7.2')
在测试套件中运行一个成熟的浏览器可能会很麻烦,特别是在实践持续交付时,运行流水线的服务器可能无法启动浏览器和用户界面(例如,因为没有可用的X-Server)。可以通过启动xvfb[51]这样的虚拟X-Server来解决这个问题。
最近流行的方式是使用无头(headless)浏览器(即没有用户界面的浏览器)来运行webdriver测试。直到最近,PhantomJS[52]还是用于自动化测试的无头浏览器的领先者,自从Chromium[53]和Firefox[54]宣布在浏览器中实现了无头模式后,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!"));
}
}
请注意,如果在运行此测试的系统上安装了Chrome,则此测试将只能在这个系统上运行(你的本地机器,或者CI服务器)。
测试很简单,使用@SpringBootTest
在一个随机端口上启动整个应用程序,然后实例化一个新的Chrome webdriver,导航到我们的微服务的/hello
endpoint,并检查浏览器窗口上是否打印了“hello World!”。超酷的!
REST API端到端测试
在测试应用程序时,避免使用图形用户界面是一个好主意,这样的测试比完整的端到端测试更可靠,同时仍然能够覆盖应用程序的大部分。当通过应用的web界面进行测试特别困难时,这就可以派上用场。也许甚至没有web UI,而只提供REST API(因为有单页应用与这个API通信,或者只是因为不喜欢这些漂亮的东西)。无论哪种方式,通过Subcutaneous Test[55]在图形用户界面之下进行测试,可以让我们有信心往前走得更远。对于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[9]库非常有帮助,它提供了很好的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”说“hello”时,可以收到问候。很神奇!如果我们没有web界面,那么端到端测试就足够了。
验收测试——你的特性工作正常吗?
在测试金字塔中移动得越高,就越有可能进入从用户的角度测试所构建的功能是否正确工作的领域。在这里可以将应用视为一个黑盒,并将测试的重点从
当我输入值x和y时,返回值应该是z
变成
假设(given)用户已经登录
并且(and)有"自行车"产品
当(when)用户导航到“自行车”的详细信息页面时
并且(and)点击“添加到购物篮”按钮
那么(then)“自行车”应该在购物篮里
有时我们会听到功能测试[56]或验收测试[57]这类术语,有些人认为功能测试和验收测试是不同的东西,有些人觉得都是一回事。人们会无休止的争论措辞和定义,这种讨论通常给人带来相当大的困扰。
事情应该这样:我们应该确保自己的软件从用户的角度能够正确工作,而不仅仅是从技术的角度。怎么叫这些测试其实并没有那么重要,选择一个术语,保持一致,编写测试。
这也是人们谈论BDD并且以BDD的方式实现测试工具的时候。BDD或BDD风格的测试编写方法可以很好的将思维从实现细节转向用户需求。去试一试吧。
甚至不需要采用像Cucumber[58]这样成熟的BDD工具(尽管也可以)。有些断言库(如chai.js[59])允许使用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)
验收测试可以有不同的粒度级别。大多数情况下,会出于相当高的层级,并通过用户界面测试服务。然而,从技术上讲,没有必要在测试金字塔的最高级别编写验收测试。如果应用的设计和场景允许我们在较低的层级编写验收测试,那么就这样做,进行低级测试比进行高级测试要好。验收测试的概念——证明你的特性对用户来说是正确的——与测试金字塔是完全正交的。
探索性测试
即使是最勤奋的自动化测试工作也无法做到完美,有时我们会在自动化测试中忽略某些边缘情况,有时通过编写单元测试来检测特定的bug几乎不可能,某些质量问题甚至不会在自动化测试中出现(考虑到设计或可用性)。尽管我们希望测试能够自动化,但手动测试仍然是个好主意。
探索性测试[44]是一种手工测试方法,强调测试人员在发现运行系统的质量问题时的自由和创造性。只需按照常规计划花些时间,撸起袖子加油干,尝试破坏被测应用,用一种破坏性的心态,想出办法在应用程序中引发问题和错误。将发现的一切记录下来,以备今后使用。注意bug、设计问题、缓慢的响应时间、丢失或误导性的错误消息以及其他任何会让你作为软件用户感到烦恼的东西。
好消息是,我们可以愉快的使用自动化测试自动化大部分发现。为发现的bug编写自动化测试可以确保将来不会再次出现同样的bug。此外,还可以帮助我们在修复bug的过程中缩小问题范围。
在探索性测试期间,可以发现在构建流水线中未被注意到的问题。不要沮丧。这是对构建流水线成熟度的很好的反馈。与任何反馈一样,确保采取行动:想想能做些什么来避免将来出现这类问题,也许我们错过了一组自动化测试,也许只是在此迭代中草率的进行了自动化测试,并需要在未来进行更彻底的测试,也许有新的工具或方法可以让我们在今后的工作中避免这类问题。确保我们在这方面有所行动,帮助流水线和整个软件交付随着时间的推移变得更加成熟。
关于测试术语的困惑
谈论不同的测试分类总是很困难,我所说的单元测试与你的理解可能略有不同,在集成测试中,情况会更糟。对于一些人来说,集成测试是一个非常广泛的活动,需要测试整个系统的许多不同部分。对我来说,这是一件相当严格的事情,一次只测试一个外部依赖的集成。有些人称其为集成测试(integration test),有些人称其为组件测试(component test),有些人称其为服务测试(service test)。甚至有人会说,这三个术语是完全不同的东西。这里没有对错之分,软件开发社区还没有确定关于测试的定义良好的术语。
不要太执着于模棱两可的措辞,无论叫它端到端测试,还是Board Stack Test,还是功能测试。也许集成测试对你和其他公司的人来说意味着不同的东西,这也不重要。如果能有一些明确定义的术语并达成广泛共识,那就太好了。不幸的是,这并不现实。由于编写测试时存在诸多细微差别,所以它实际上更像是一个模糊的范围,而不是一堆离散的明确的分类,这使得很难取得一致的命名。
重要的是,找到适合你和你的团队的术语。想清楚编写的测试的不同类型,在团队中就命名达成一致,并就每种测试类型的范围达成一致。真正应该关心的是是否能够在团队(甚至是组织)中做到这一点。Simon Stewart[62]在描述他们在谷歌所使用的方法时做出了很好的总结,我认为这很好的说明了不值得过于纠结于名字和命名约定。
将测试集成到部署流水线
对于实践了持续集成或持续交付的团队,一定会有部署流水线[63],可以在每次软件发生变更时运行自动化测试。通常,流水线被分成几个阶段,这些阶段让我们更有信心将软件部署到生产环境中。那怎样将不同类型的测试放在部署流水线中呢?要回答这个问题,应该考虑持续交付的基本价值观(实际上是极限编程和敏捷软件开发的核心价值观[64]之一):快速反馈。
好的构建流水线会在我们把事情搞砸的时候尽快让我们知道,没人喜欢等一个小时以后才发现最新的更改破坏了一些简单的单元测试。如果流水线需要这么长时间才给反馈,很有可能你已经回家了。通过将快速运行的测试放在流水线的早期阶段,可以在几秒钟,甚至几分钟内获得反馈信息。相反,运行时间较长的测试——通常是具有更大范围的测试——可以放在后面的阶段,以避免拖延从快速运行的测试中获取反馈。可以看到,定义部署流水线的各个阶段不是由测试类型驱动的,而是由测试的速度和范围驱动的。记住,把一些范围较小、运行迅速的集成测试和单元测试放在同一阶段的决定非常合理,因为这能给我们更快的反馈,而无需考虑是否需要在测试类型中间画一条线。
避免重复测试
既然已经知道应该编写不同类型的测试,那么还有一个需要避免的陷阱:在金字塔的不同层级中重复测试。虽然直觉上可能会说,没有太多测试,但我向你保证,确实有。测试套件中的每一个测试都不是免费的,都需要额外的负担,编写和维护测试需要时间,阅读和理解他人的测试需要时间,当然,运行测试需要时间。
与产品代码一样,应该力求简单并避免重复。在执行测试金字塔的上下文中,应该记住两条经验法则:
- 如果更高级别的测试发现了一个错误,并且没有低级别的测试覆盖,那么需要编写一个更低级别的测试
- 将测试尽可能向下层推动
第一个规则很重要,因为低级别测试可以更好的缩小错误范围,并以独立的方式复现问题。在调试问题的时候可以运行得更快,也不会那么臃肿。它们将成为未来很好的回归测试。第二条规则对于保持测试套件的运行速度很重要。如果已经在较低级别的测试中自信的测试了所有条件,那么就没有必要在测试套件中保留较高级别的测试,因为这些测试并没有增加对于可工作的软件的信心。在日常工作中,重复的测试会变得很烦人,测试套件会越来越慢,并且更改代码的行为时,需要更改更多的测试。
让我们换一种说法:如果更高级别的测试让我们对应用程序的正确工作更有信心,那么就应该保留它们。为Controller
类编写单元测试有助于测试Controller本身的逻辑。但是没法告诉我们这个控制器提供的REST endpoint是否真正响应HTTP请求。因此,我们可以向上移动测试金字塔,并添加一个测试来精确的检查它,但也仅此而已。我们不必测试所有的条件逻辑和边缘情况,没必要在高级别测试中测试这些低级别测试已经覆盖的情况,确保高级别测试关注的是低级别测试不能覆盖的部分。
当涉及到消除不提供任何价值的测试时,我是严肃的,我会删除已经在较低级别覆盖的高级测试(因为它们不提供额外的价值)。如果可能的话,我将用较低级别的测试替换较高级别的测试。有时这很困难,特别是你知道想出一个测试有多么困难。小心沉没成本谬论,把多余的测试删掉。没有理由将更多宝贵的时间浪费在不再提供价值的测试上。
编写干净的测试代码
和编写代码一样,编写好的、干净的测试代码需要非常小心。下面是一些关于使用自动化测试套件之前如何编写可维护的测试代码的提示:
- 测试代码和生产代码一样重要,需要给予同等程度的关心和关注。“这只是测试代码”并不是对测试代码草率的正当理由
- 每次测试一个条件,这可以帮助保持测试的简短和易于理解
- “arrange, act, assert”或“given, when, then”是保持良好的测试结构的好方法
- 可读性很重要,不要过于追求DRY,如果能够提高可读性,就可以接受复制。尝试在DRY和DAMP(Descriptive And Meaningful Phrases)[65]代码之间找到一个平衡
- 在不确定的时候,基于"三规则"[66]来决定何时重构。在重用之前使用这条规则
结论
就这么多吧!我知道这篇解释为什么以及如何测试软件的文章又长又硬,不过好消息是,这些信息不受时间的限制,并且与软件类型无关。无论是微服务、物联网设备、移动应用还是web应用,本文的经验教训都可以应用到所有这些领域。
我希望这篇文章中有一些对你有用的东西。你可以继续查看示例代码,并将这里解释的一些概念引入到自己的测试组合中。拥有一个可靠的测试组合需要付出努力。相信我,从长远来看,它会给你带来回报,让你作为一名开发者的生活更加平静。
References:
[1] The Practical Test Pyramid: https://martinfowler.com/articles/practical-test-pyramid.html
[2] Testing Pyramids: https://watirmelon.blog/testing-pyramids/
[3] The forgotten layer of the test automation pyramid: https://www.mountaingoatsoftware.com/blog/the-forgotten-layer-of-the-test-automation-pyramid
[4] JUnit: http://junit.org/
[5] Mockito: http://site.mockito.org/
[6] Wiremock: http://wiremock.org/
[7] Pact: https://docs.pact.io/
[8] Selenium: http://docs.seleniumhq.org/
[9] REST-assured: https://github.com/rest-assured/rest-assured
[10] Sprint-testing: https://github.com/hamvocke/spring-testing
[11] Sprint Boot: https://projects.spring.io/spring-boot/
[12] Darksky: https://darksky.net/
[13] Domain Model: https://en.wikipedia.org/wiki/Domain_model
[14] Domain-Driven Design: https://en.wikipedia.org/wiki/Domain-driven_design
[15] Anemic Domain Model: https://en.wikipedia.org/wiki/Anemic_domain_model
[16] Sprint Data: http://projects.spring.io/spring-data/
[17] Unit Test: https://martinfowler.com/bliki/UnitTest.html
[18] Working Effectively with Unit Tests: https://leanpub.com/wewut
[19] Mocks aren't stubs: https://martinfowler.com/articles/mocksArentStubs.html
[20] Test Double: https://martinfowler.com/bliki/TestDouble.html
[21] Test-Driven Development: https://en.wikipedia.org/wiki/Test-driven_development
[22] SOLID: https://en.wikipedia.org/wiki/SOLID_(object-oriented_design)
[23] How deep are your unit tests: https://stackoverflow.com/questions/153234/how-deep-are-your-unit-tests/
[24] Arrange, Act, Assert: https://xp123.com/articles/3a-arrange-act-assert/
[25] Given, When, Then: https://martinfowler.com/bliki/GivenWhenThen.html
[26] Integration Test: https://martinfowler.com/bliki/IntegrationTest.html
[27] Boot features embeded database support: https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-sql.html#boot-features-embedded-database-support
[28] Contract Test: https://martinfowler.com/bliki/ContractTest.html
[29] Consumer Driven Contracts: https://martinfowler.com/articles/consumerDrivenContracts.html
[30] JFrog Artifactory: https://www.jfrog.com/artifactory/
[31] Pact: https://github.com/realestate-com-au/pact
[32] Pact Document: https://docs.pact.io/
[33] Pact-jvm: https://github.com/DiUS/pact-jvm
[34] Pact jvm provider Spring: https://github.com/DiUS/pact-jvm/tree/master/pact-jvm-provider-spring
[35] Spring Testing Consumer: https://github.com/hamvocke/spring-testing-consumer
[36] React: https://facebook.github.io/react/
[37] Vue.js: https://vuejs.org/
[38] Angular: https://angular.io/
[39] Jasmine: https://jasmine.github.io/
[40] Mocha: http://mochajs.org/
[41] Galen Framework: http://galenframework.com/
[42] lineup: https://github.com/otto-de/lineup
[43] jlineup: https://github.com/otto-de/jlineup
[44] Exploratory Testing: https://en.wikipedia.org/wiki/Exploratory_testing
[45] Hallway Testing: https://en.wikipedia.org/wiki/Usability_testing#Hallway_testing
[46] Board Stack Test: https://martinfowler.com/bliki/BroadStackTest.html
[47] WebDriver: https://www.w3.org/TR/webdriver/
[48] Nightwatch: http://nightwatchjs.org/
[49] Selenium Driver: https://www.mvnrepository.com/search?q=selenium+driver
[50] webdrivermanager: https://github.com/bonigarcia/webdrivermanager
[51] Xvfb: https://en.wikipedia.org/wiki/Xvfb
[52] PhantomJS: http://phantomjs.org/
[53] Headless Chrome: https://developers.google.com/web/updates/2017/04/headless-chrome
[54] Firefox Headless mode: https://developer.mozilla.org/en-US/Firefox/Headless_mode
[55] Subcutaneous Test: https://martinfowler.com/bliki/SubcutaneousTest.html
[56] Functional Testing: https://en.wikipedia.org/wiki/Functional_testing
[57] Acceptance tsting in extreme programming: https://en.wikipedia.org/wiki/Acceptance_testing#Acceptance_testing_in_extreme_programming
[58] Cucumber: https://cucumber.io/
[59] Chai.js: http://chaijs.com/guide/styles/#should
[60] Spring MVC Test Server: https://docs.spring.io/spring/docs/current/spring-framework-reference/testing.html#spring-mvc-test-server
[61] ExampleControllerAPITest: https://github.com/hamvocke/spring-testing/blob/master/src/test/java/example/ExampleControllerAPITest.java
[62] Test Sizes: https://testing.googleblog.com/2010/12/test-sizes.html
[63] Deployment Pipeline: https://martinfowler.com/bliki/DeploymentPipeline.html
[64] Values of XP: http://www.extremeprogramming.org/values.html
[65] What does DAMP not DRY mean when talkingabout unit tests: https://stackoverflow.com/questions/6453235/what-does-damp-not-dry-mean-when-talking-about-unit-tests
[66] Rule of Three: https://blog.codinghorror.com/rule-of-three/
你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。
微信公众号:DeepNoMind