翻译-实践测试金字塔

Posted by 孙继峰 on May 30, 2020

原文链接: https://martinfowler.com/articles/practical-test-pyramid.html

实践测试金字塔

“测试金字塔”是一个隐喻,它告诉我们将软件测试分到不同粒度的组中。 它还给出了在每个组中应该写多少测试。 尽管“测试金字塔”的概念已经存在了一段时间,但团队仍在努力将其正确实施。本文回顾了“测试金字塔”的原始概念,并展示了如何将其付诸实践。 它显示了应该在金字塔的不同级别中采用哪种测试,并提供了有关如何实现这些测试的实际示例。


img

软件在投入生产之前需要进行测试。随着软件开发学科的成熟,软件测试方法也已经成熟。开发团队不再拥有大量的手动软件测试人员,而是朝着使测试工作的最大一部分自动化的方向发展。通过自动化测试,团队可以在几秒钟和几分钟内(而不是几天又几周)知道软件是否已损坏。

自动化测试推动的反馈循环大大缩短,这与敏捷开发实践、持续交付和 DevOps 文化并驾齐驱。拥有有效的软件测试方法可使团队快速而自信地前进。

本文探讨了全面的测试产品组合应该具有响应性、可靠性和可维护性,无论你要构建的是微服务架构,移动应用还是物联网生态系统。我们将详细介绍构建有效且可读的自动化测试的细节。

自动化测试的重要性

软件已经成为我们赖以生存的世界的重要组成部分。它已经超出了其最初的旨在提高业务效率的唯一目的。今天,公司试图找到成为一流数字化公司的方法。作为用户,我们每个人每天与越来越多的软件进行交互。

如果要跟上步伐,就必须寻找在不牺牲软件质量的前提下更快交付软件的方法。持续交付(一种可以自动确保你的软件可以随时发布到生产环境)的实践可以为你提供帮助。通过持续交付,你可以使用构建流水线来自动测试软件,并将其部署到测试和生产环境。

如果使用手动构建,测试和部署数量不断增加的软件将会十分耗时与枯燥,除非你想将所有时间都花在手动重复性工作上,而不是交付可用的软件。所以必须要实现从构建到测试、部署和基础架构的所有过程实现自动化。

img

在以前,软件测试是手动测试,将应用程序部署到测试环境,然后再执行一些黑盒式测试,例如通过点击你的用户界面以查看是否有任何损坏。这些测试将由测试脚本指定,以确保测试人员进行一致的检查。

显而易见,手动测试所有更改非常耗时,重复且乏味。重复是无聊的,无聊会导致错误。

幸运的是,对于重复性任务有一种补救措施:自动化。

作为软件开发人员,自动化重复测试可能会改变你的生活。自动执行这些测试,你无需再通过点击就可以检查软件是否仍能正常运行。自动化测试,你就可以更改代码库而无需费力。如果你曾经尝试在没有适当的测试套件的情况下进行大规模重构,那么我敢打赌,你会知道这会是多么恐怖的经历。你怎么知道你重构的时候是否破坏了其他业务?好吧,你可以单击所有手动测试用例,就是这样。但是说实话:你真的喜欢吗?如何在进行大规模更改时,在几秒内知道是否破坏了其他业务呢?

测试金字塔

如果你想认真对待软件的自动化测试,则应该了解一个关键概念:测试金字塔。 迈克·科恩(Mike Cohn)在他的《敏捷的成功》一书中提出了这个概念。 这是一个很好的视觉隐喻,它告诉你考虑不同的测试层。 它还告诉你在每个层上要做多少测试。

img

迈克·科恩(Mike Cohn)最初的测试金字塔由测试套件应包括的三层组成(从下到上):

  1. 单元测试
  2. 服务测试
  3. 用户界面测试

不幸的是,如果你仔细看一下,测试金字塔的概念会有点不足。有人认为,麦克·科恩(Mike Cohn)的测试金字塔的命名或某些概念方面都不理想,我必须同意。从现代的角度来看,测试金字塔似乎过于简单,因此可能会产生误导。

尽管如此,由于其简单性,在建立自己的测试套件时,测试金字塔的本质仍是一个很好的经验法则。最好记住科恩最初的测试金字塔中的两件事:

  1. 编写不同粒度的测试
  2. 高级程度越高,你应该进行的测试就越少

坚持金字塔的形状,以提供健康、快速和可维护的测试套件:编写许多小型且快速的单元测试。编写一些更粗粒度的测试和很少的高级测试测试你的应用程序。请注意,你最好不要使用测试冰淇淋筒,这将是一场噩梦,难以维护,而且运行测试用例的时间会很长。

不要对科恩测试金字塔中各个图层的名称过于重视。实际上,它们可能会引起误解:服务测试是一个很难理解的术语(科恩本人谈到许多开发人员完全忽略了这一层的观察)。在单页面应用程序框架(例如react,angular,ember.js等)的时代,很明显,UI测试不必处于金字塔的最高级别,你完全可以对所有UI进行单元测试。

鉴于原始名称的不足,只要在代码库和团队讨论中保持一致,就可以为测试层提出其他名称。

将用到的工具库

  • JUnit: 测试运行框架
  • Mockito: 用于模拟依赖项
  • Wiremock: 用于存储外部服务
  • Pact: 用于编写 CDC 测试
  • Selenium: 用于编写 UI 驱动的端到端测试
  • REST-assured: 用于编写REST API驱动的端到端测试

应用演示

我编写了一个简单的微服务,其中包括一个测试套件,测试套件中包含了测试金字塔不同层的测试用例。

该示例展示了一个典型的微服务应用。 它提供了一个REST接口,与数据库交互,从第三方REST服务获取信息。 项目使用 Spring Boot 实现,即使你以前从未使用过 Spring Boot,也应该可以理解。

功能

该应用程序的功能很简单。 它提供三个 REST 接口:

接口名 请求方法 说明
/hello GET 返回 “Hello World”.
/hello/{lastname} GET 按照姓氏查找用户,如果认识此人,则返回 “Hello {Firstname} {Lastname}”*.
/weather GET 返回 Hamburg, Germany 当前的天气状况.

全局架构

在较高层次上,系统具有以下结构:

img

我们的微服务提供了 HTTP 调用的 REST 接口。 对于某些端点的调用,服务将从数据库中获取信息。 在其他情况下,服务将通过HTTP调用外部天气 API,以获取并显示当前天气状况。

内部架构

在内部,Spring Service 具有典型的 Spring 架构:

img

  • Controller 提供 REST 接口并处理 HTTP 请求和响应
  • Repository 负责与数据库进行交互,将数据写入数据库和从数据库取数据
  • Client 与其他 API 进行通信,在我们的情况下,它是通过Darksky.net weather API 发送 HTTPS 请求获取 JSON 数据的
  • Domain 我们的领域模型,其中包括领域逻辑(在我们的案例中这是微不足道的)。

经验丰富的 Spring 开发人员可能会注意到这里缺少一个常用层:受域驱动设计的启发,许多开发人员构建了一个由 Service 类组成的 Service 层。我决定在此应用程序中不包括 Service 层。一个原因是我们的应用程序足够简单,Service 层是不必要的间接级别。另一个是我认为开发人员会过度使用 Service 层。我经常能看到在整个Service类中捕获整个业务逻辑的代码。领域模型仅成为数据层,而不是行为层(Anemic 域模型)。对于每个应用程序,这浪费了大量时间来保持代码的良好结构和可测试性,并且无法充分利用面向对象的功能。

我们的 Repository 非常简单,提供简单的 CRUD 功能。为了简化代码,我使用了Spring Data。 Spring Data为我们提供了一个简单而通用的CRUD Repository 实现,我们可以使用它来代替自己的回滚。它还负责为我们的测试扩展内存数据库,而不是像在生产中那样使用真实的PostgreSQL数据库。

先看一下代码库,熟悉一下内部结构。这将对我们的下一步非常有用:测试应用程序!

单元测试

测试套件的基础将由单元测试组成。 你的单元测试确保代码库中的某个单元(你的测试对象)按预期工作。 在你的测试套件中,单元测试的范围最窄。 测试套件中的单元测试数量将大大超过任何其他类型的测试。

img

什么是单元测试

如果你问三个不同的人,在单元测试中“单元”是什么意思,你可能会收到四个不同的,细微差别的答案。 在某种程度上,这是你自己定义的问题,没有规范的回答也可以。

如果你使用的是函数式编程语言,则一个单元很可能是一个函数。 你的单元测试将调用具有不同参数的函数,并确保其能够返回期望的数据。 在面向对象的语言中,一个单元的范围可以从单个方法到整个类。

Sociable 与 Solitary

有人认为,应将被测主题的所有协作者(例如,被测试类调用的其他类)替换为 Mock 或 Stub,以实现完美的隔离,并避免产生副作用和复杂的测试设置。其他人则认为,只有慢速或具有较大副作用的协作者(例如访问数据库或进行网络调用的类)才应该打桩或模拟。

有时,人们将这两种类型的测试标记为对所有协作者进行 Stub 的测试的单独单元测试,以及对允许与真正的协作者交互测试的Sociable单元测试(Jay Fields的“有效使用单元测试工作”创造了这些术语)。如果你有闲暇时间,可以钻研一下,阅读更多有关不同思想流派的利弊的信息。

归根结底,决定是否要进行Solitary的或Sociable的单元测试并不重要,重要的是编写自动化测试。就个人而言,我发现自己一直都在使用这两种方法。如果使用真正的协作者不方便,我会很愿意使用模拟和存根。如果我希望让真正的合作者参与进来可以使我对测试更有信心,那么我只会在服务的最外部进行打桩。

Mock 与 Stub

模拟和打桩是两种不同的测试方法。很多人交替使用术语“模拟”和“存根”。我认为最好保持精确并牢记它们的特定属性。你可以使用双精度测试来替换你在生产中使用的对象,并使用有助于测试的实现。

用简单的话来说,这意味着你用假的东西替换了一个真实的东西(例如一个类,模块或函数)。虚假的外观和行为类似于真实的事物(对相同方法调用的回答),但在单元测试开始时定义了你自己定义的返回值。

使用双精度测试并不特定于单元测试。可以使用更细粒度的测试来以受控方式模拟系统的整个部分。但是,在单元测试中,你很可能会遇到很多模拟和存根(取决于你是Sociable还是Solitary开发人员),这仅仅是因为许多现代语言和库使得设置起来容易又适合模拟和存根。

无论你选择哪种技术,你的语言的标准库或某些流行的第三方库都有很大的机会为你提供优雅的方式来设置模拟。甚至从头开始编写自己的模拟只不过是编写一个具有与真实签名相同的签名的伪造类/模块/函数,并在测试中设置虚假行为。

你的单元测试将非常快地运行。在一台正式的服务器上,你可以在几分钟内运行数千个单元测试。单独测试代码库中的小片段,并避免命中数据库,文件系统或触发HTTP查询(通过对这些部分使用模拟和存根)来保持测试的快速。

一旦掌握了编写单元测试的知识,你将变得越来越流利地编写它们。找出外部协作者,设置一些输入数据,调用你的被测对象,并检查返回的值是否符合你的期望。研究测试驱动的开发,让你的单元测试指导你的开发;如果正确应用,它可以帮助你进入一个很好的流程,并提出一个良好且可维护的设计,同时产出一个全面而全自动的测试套件。不过,这不是灵丹妙药,可以给它一次机会,看看它是否适合你。

要测试什么

关于单元测试的好处是,你可以为所有生产代码类编写它们,而不论它们的功能或它们属于内部结构的哪一层。你可以对控制器进行单元测试,就像可以对存储库,领域类或文件操作类进行单元测试一样。只需遵循每个生产类经验法则的一个测试类,你就可以开始一个良好的开端。

单元测试类至少应测试该类的公共接口。无论如何都无法测试私有方法,因为你根本无法从其他测试类中调用它们。可以从测试类访问受保护的或私有的程序包(假定你的测试类的程序包结构与生产类的程序包结构相同),但是测试这些方法可能已经太过复杂了。

在编写单元测试时,有一条明确的路线:他们应确保测试所有重要的代码路径(包括HappyCase和边缘情况)。同时,它们不应与你的实现紧密联系。

为什么?

过于接近生产代码的测试很快变得令人讨厌。重构生产代码后(快速回顾:重构意味着更改代码的内部结构而不更改外部可见的行为),单元测试将中断。

这样,你将失去单元测试的一大好处:充当代码更改的安全网。每次重构都会有执行失败的愚蠢的测试用例,从而导致工作量多于好处。这个愚蠢的测试东西到底是谁的主意?

你该怎么办?不要在单元测试中反映你的内部代码结构。而是测试可观察到的行为。想一想

如果我输入值x和y,结果将是z吗?

代替

如果我输入x和y,该方法将首先调用类A,然后调用类B,然后返回类A的结果加上类B的结果吗?

私有方法通常应被视为实现细节。这就是为什么你甚至不希望测试它们的原因。

我经常听到反对单元测试(或TDD)的争论,认为编写单元测试变得毫无意义,在这种情况下,你必须测试所有方法才能得出较高的测试覆盖率。他们经常引用一个场景,即一个过于渴望的团队带领他们迫使他们为getter和setter以及所有其他种类的琐碎代码编写单元测试,以实现100%的测试覆盖率。

这里有很多误区。

是的,你应该测试公共接口。但是,更重要的是,你无需测试琐碎的代码。不用担心,肯特·贝克说没关系。通过测试简单的getter或setter或其他琐碎的实现(例如,没有任何条件逻辑),你将不会有任何收获。节省时间,这样你又可以多参加一次会议,鼓掌!

测试结构

一个适合所有测试(不限于单元测试)的结构是:

  1. 设置测试数据
  2. 调用你的被测方法
  3. 断言返回了预期的结果

有一个很好的助记符可以记住此结构:“Arrange, Act, Assert”。 你可以使用的另一个灵感来自BDD。 它是”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));
    }
}

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?"));
    }
}

我们使用Java的事实上的标准测试框架JUnit编写单元测试。 我们使用Mockito用存根替换真实的PersonRepository类以进行测试。 此存根允许我们定义存根方法应在此测试中返回的固定响应。 存根使我们的测试更加简单,可预测,并使我们能够轻松设置测试数据。

按照Arrange,Act,Assert结构,我们编写了两个单元测试-一个肯定的案例和一个找不到被搜索者的案例。 第一个肯定的测试用例创建一个新的person对象,并告诉该模拟存储库在使用“ Pan”作为lastName参数的值调用该对象时返回该对象。 然后测试继续调用应测试的方法。 最后,它断言该响应等于预期的响应。

第二项测试的工作原理类似,但是测试了这种情况,即所测试的方法找不到给定参数的人。

集成测试

所有应用程序都将与其他一些部分集成(数据库,文件系统,对其他应用程序的网络调用)。编写单元测试时,通常会省略这些部分,以提供更好的隔离和更快的测试。尽管如此,你的应用程序仍将与其他部分交互,这需要进行测试。集成测试可以为你提供帮助。他们测试应用程序与应用程序外部所有部分的集成。

对于自动化测试,这意味着你不仅需要运行自己的应用程序,还需要运行与之集成的组件。如果要测试与数据库的集成,则在运行测试时需要运行数据库。为了测试你可以从磁盘读取文件,需要将文件保存到磁盘并在集成测试中加载它。

我之前提到“单元测试”是一个模糊的术语,对于“集成测试”更是如此。对于某些人来说,集成测试意味着对连接到系统中与其他应用程序进行整个应用程序测试。我喜欢更狭义地对待集成测试,并通过用双精度测试替换单独的服务和数据库来一次测试一个集成点。结合contract测试和双精度测试运行合同测试以及实际的实现,你可以提出更快,更独立且通常更易于推理的集成测试。

狭义集成测试存在于你服务的边界。从概念上讲,它们始终与触发导致与外部部分(文件系统,数据库,单独的服务)集成的操作有关。数据库集成测试如下所示:

img

  1. 启动数据库
  2. 将你的应用程序连接到数据库
  3. 在代码中触发将数据写入数据库的函数
  4. 通过从数据库中读取数据来检查是否已将预期数据写入数据库

再举一个例子,测试你的服务是否通过REST API与单独的服务集成:

img

  1. 启动应用
  2. 启动单独服务的实例(或具有相同接口的测试双重对象)
  3. 在代码中触发从单独服务的API读取的函数
  4. 检查你的应用程序可以正确解析响应

你的集成测试(如单元测试)可以算是白盒。一些框架允许你启动应用程序,同时仍然能够模拟应用程序的其他部分,以便你可以检查是否发生了正确的交互。

为你对数据进行序列化或反序列化的所有代码编写集成测试。这种情况比你想像的更多。想一想:

  • 调用服务的REST API
  • 从数据库读取和写入
  • 调用其他应用程序的API
  • 从队列读取和写入
  • 写入文件系统

围绕这些边界编写集成测试可确保将数据写入这些外部协作者或从其中读取数据。

在编写狭义的集成测试时,你应该旨在在本地运行外部依赖项:启动本地MySQL数据库,针对本地ext4文件系统进行测试。如果要与单独的服务集成,需要在本地运行该服务的实例,或者构建并运行模拟真实服务行为的虚假对象。

如果无法在本地运行第三方服务,则应选择运行专用的测试实例,并在运行集成测试时指向该测试实例。避免在自动化测试中与实际生产系统集成。对生产系统进行成千上万个测试请求是让不可取的,因为你正在混乱他们的日志(在最佳情况下),甚至是DoS为其服务(在最坏的情况下)。通过网络与服务集成是广泛集成测试的典型特征,它会使你的测试变慢并且通常更难编写。

关于测试金字塔,集成测试的级别比单元测试的级别更高。集成文件系统和数据库之类的速度较慢的部分往往比运行包含这些部分的单元测试要慢得多。它们也比小型孤立的单元测试更难编写,毕竟,你必须注意将外部部分作为测试的一部分。仍然,它们的优点是使你对程序充满信心,使你的应用程序可以正确处理需要与之会话的所有外部部件。单元测试无法帮助你。

数据库集成

PersonRepository是代码库中唯一的存储库类。 它依赖于Spring Data,并且没有实际的实现。 它只是扩展了CrudRepository接口,并提供了一个方法标头。 剩下的就交给 Spring 了。

public interface PersonRepository extends CrudRepository<Person, String> {
    Optional<Person> findByLastName(String lastName);
}

Spring Boot通过CrudRepository接口提供功能齐全的CRUD存储库,其中包括findOne,findAll,保存,更新和删除方法。我们的自定义方法定义(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和简单的代码,而不是显式但更冗长的实现。

已经有足够的解释,这是一个简单的集成测试,该测试将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)));
    }
}

你可以看到我们的集成测试遵循与单元测试相同的安排,操作,断言结构。 告诉你这是一个普遍的概念!

服务集成

我们的微服务与weather REST API darksky.net进行对话。 我们要确保我们的服务发送请求并正确解析响应。

我们希望避免在运行自动化测试时碰到真正的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时定义的端口相同。通过将URL注入我们的WeatherClient类的构造函数中,可以在测试中用伪造的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是否可以解析假服务器发送的响应。这是一个开始,但非常脆弱。使用端到端测试并针对真实服务的测试实例运行测试,而不是使用伪造的服务可以解决此问题,但会使我们依赖于测试服务的可用性。幸运的是,有一个更好的解决方案:针对虚假和真实服务器运行合同测试,以确保我们在集成测试中使用的虚假对象是忠实的双精度测试。

Contract 测试

越来越多的现代软件开发组织已经找到了通过在不同团队之间分布系统开发来扩展其开发工作的方法。各个团队可以构建各自的,松散耦合的服务,而不会互相踩踏,而是将这些服务集成到一个庞大的,有凝聚力的系统中。最近关于微服务的热议正是针对这一点。

将你的系统分为许多小型服务通常意味着这些服务需要通过某些(希望是定义良好,有时是偶然增长的)接口相互通信。

不同应用程序之间的接口可以采用不同的形状和技术。常见的是

  • 通过HTTPS的REST和JSON
  • 使用gRPC之类的RPC
  • 使用队列构建事件驱动的体系结构

对于每个接口,都有两个参与方:提供者和消费者。提供者将数据提供给消费者。消费者处理从提供者获得的数据。在REST环境中,提供程序使用所有必需的端点构建REST API。使用者调用此REST API来获取数据或触发其他服务中的更改。在异步的,事件驱动的世界中,提供程序(通常称为发布者)将数据发布到队列中。使用者(通常称为订户)订阅这些队列并读取和处理数据。

img

当你经常将消费和提供服务分布在不同的团队中时,你会发现自己必须明确指定这些服务之间的接口(所谓的合同)。传统上,公司通过以下方式解决此问题:

  • 编写详细的接口规范(合同)
  • 根据定义的合同实施提供服务
  • 将接口规范扔给消费团队
  • 等到他们实现使用接口的部分
  • 运行一些大规模的手动系统测试,看一切是否正常
  • 希望两个团队永远坚持接口定义,不要搞砸

越来越多的现代软件开发团队已将步骤5和6.替换为更具自动化的内容:自动化的合同测试可确保消费者和提供者方面的实现仍遵守所定义的合同。它们是一个很好的回归测试套件,可确保及早发现合同违约情况。

在一个更敏捷的组织中,你应该采用更高效和更少浪费的途径。你可以在同一组织内构建应用程序。直接与其他服务的开发人员交谈,而不是将过多的详细文档扔在栅栏上,确实不难。毕竟,他们是你的同事,而不是你只能通过客户支持或法律上防弹合同与之交谈的第三方供应商。

消费者驱动的合同测试(CDC测试)使消费者可以推动合同的实施。使用CDC,接口的使用者编写测试,以检查接口是否需要该接口中的所有数据。然后,使用方团队发布这些测试,以便发布团队可以轻松获取并执行这些测试。提供团队现在可以通过运行CDC测试来开发其API。一旦所有测试通过,他们就会知道他们已经实现了消费团队所需的一切。

img

这种方法允许提供团队仅实施真正必要的事情(保持简单,YAGNI等)。提供接口的团队应连续(在其构建管道中)获取并运行这些CDC测试,以立即发现任何重大更改。如果他们破坏了接口,它们的CDC测试将失败,从而阻止破坏性的更改上线。只要测试保持绿色,团队就可以进行所需的任何更改,而不必担心其他团队。以消费者为主导的合同方法将使你的过程看起来像这样:

  • 消费团队编写了符合所有消费者期望的自动化测试
  • 他们为提供团队发布测试
  • 提供团队持续运行CDC测试并保持绿色
  • CDC测试中断后,两个团队互相交谈

如果你的组织采用微服务方法,那么进行CDC测试是朝着建立自治团队迈出的一大步。 CDC测试是促进团队沟通的自动化方法。他们确保团队之间的接口随时可用。 CDC测试失败是一个很好的指示,它表明你应该转到受影响的团队,就任何即将发生的API更改进行聊天,并弄清楚你要如何进行。

CDC测试的简单实施可以简单地针对API发出请求,并断言响应包含你所需的一切。然后,你将这些测试打包为可执行文件(.gem,.jar,.sh),然后将其上传到其他团队可以获取的位置(例如Artifactory之类的工件存储库)。

在过去的几年中,CDC方法变得越来越流行,并且已经构建了一些工具来简化编写和交换它们。

公约可能是当今最突出的公约。它具有为消费者和提供者编写测试的复杂方法,为你提供了开箱即用的单独服务存根,并允许你与其他团队交换CDC测试。 Pact已被移植到许多平台,并且可以与JVM语言,Ruby,.NET,JavaScript等一起使用。

如果你想开始使用CDC而又不知道该怎么做,那么Pact可能是一个明智的选择。首先,文档可能不胜枚举。要有耐心并通过它来工作。它有助于深入了解CDC,从而使你在与其他团队合作时更容易提倡使用CDC。

消费者驱动的合同测试可以真正改变游戏规则,从而建立可以快速,自信地行动的自治团队。帮自己一个忙,继续阅读该概念,然后尝试一下。一套可靠的CDC测试套件非常宝贵,因为它能够快速移动而不会破坏其他服务,并给其他团队带来很多挫败感。

Consumer 测试 (组内)

我们的微服务使用天气API。 因此,编写一个消费者测试来定义我们对微服务和天气服务之间的合同(API)的期望是我们的责任。

首先,我们在build.gradle中包含一个用于编写契约消费者测试的库:

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中)。该协议文件以特殊的JSON格式描述了我们对合同的期望。然后可以使用该pact文件来验证我们的存根服务器的行为类似于真实服务器。我们可以获取协议文件并将其交给提供接口的团队。他们获取此协定文件,并使用其中定义的期望编写提供程序测试。通过这种方式,他们可以测试自己的API是否满足我们的所有期望。

你会看到这是CDC的消费者驱动部分的来源。消费者通过描述他们的期望来驱动接口的实现。提供者必须确保它们满足所有期望并且已经完成。没有镀金,没有YAGNI和其他东西。

将契约文件提供给提供团队可能有多种方式。一种简单的方法是将其检入版本控制,并告知提供者团队始终获取pact文件的最新版本。一种更先进的方法是使用工件存储库,即Amazon S3或契约代理之类的服务。从简单开始,根据需要成长。

在你的实际应用程序中,你不需要客户端类的集成测试和使用者测试。该示例代码库包含这两个示例,以向你展示如何使用其中任何一个。如果你想使用协议编写CDC测试,我建议你坚持使用后者。编写测试的工作是相同的。使用pact的好处是,你可以自动获得一个带有合同期望的pact文件,其他团队可以使用该文件轻松实现其提供者测试。当然,这只有在你可以说服其他团队也使用条约的情况下才有意义。如果这不起作用,则使用集成测试和Wiremock组合是一个不错的计划。

Provider 测试 (组外)

提供者测试必须由提供天气API的人员来实施。我们正在使用darksky.net提供的公共API。从理论上讲,darksky团队将在其末端实施提供者测试,以确保他们没有违反其应用程序与我们的服务之间的契约。

显然,他们不在乎我们微薄的示例应用程序,也不会为我们实施CDC测试。这是面向公众的API与采用微服务的组织之间的最大区别。面向公众的API无法考虑到每个消费者,否则他们将无法前进。在你自己的组织中,你可以而且应该。你的应用很可能会为少数几个用户提供服务,最多可能有几十个用户。你会为这些接口编写提供程序测试,以保持系统稳定。

提供团队获取协定文件并针对其提供服务运行该文件。为此,他们实施了提供程序测试,该测试程序读取pact文件,对一些测试数据进行存根并针对其服务运行pact文件中定义的期望。

契约人已经编写了几个用于实施提供程序测试的库。他们的主要GitHub存储库为你很好地概述了哪些使用者和可用的提供程序库。选择最适合你的技术堆栈的那一款。

为简单起见,我们假设Darksky API也已在Spring Boot中实现。在这种情况下,他们可以使用Spring pact提供程序,该提供程序很好地与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模拟) )。 没有要实施的自定义测试。 这些都是从pact文件派生的。 提供者测试必须具有与使用者测试中声明的提供者名称和状态相匹配的对应项,这一点很重要。

Provider 测试 (组内)

我们已经看到了如何测试我们的服务与天气提供商之间的合同。通过此界面,我们的服务充当消费者,气象服务充当提供者。再想一想,我们将看到我们的服务还充当其他服务的提供者:我们提供了REST API,该API提供了几个可供其他人使用的端点。

正如我们刚刚了解的那样,合同测试非常流行,我们当然也为此合同编写了一个合同测试。幸运的是,我们使用的是消费者驱动的合同,因此,所有消费团队都向我们发送其契约,我们可以使用它们为REST API实施提供者测试。

首先,将Spring的Pact提供程序库添加到我们的项目中:

testCompile(’au.com.dius:pact-jvm-provider-spring_2.12:3.5.5’)

实施提供者测试的方式与前面所述相同。为了简单起见,我只是将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文件并针对我们的服务触发HTTP请求,然后根据我们所设置的状态进行响应。