跳转到主要内容
Chinese, Simplified

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

生产就绪的软件需要在投入生产之前进行测试。随着软件开发学科的成熟,软件测试方法也日趋成熟。开发团队不再拥有大量的手工软件测试人员,而是将测试工作的大部分自动化。自动化测试使团队能够在几秒钟或几分钟内而不是几天或几周内知道他们的软件是否被破坏。

自动化测试大大缩短了反馈循环,与敏捷开发实践、持续交付和DevOps文化密切相关。拥有一个有效的软件测试方法可以让团队快速而有信心地前进。

本文探讨了一个全面的测试组合应该是什么样子的,以保证响应性、可靠性和可维护性——无论您正在构建的是微服务体系结构、移动应用程序还是物联网生态系统。我们还将讨论构建有效且可读的自动化测试的细节。

 

(测试)自动化的重要性

软件已经成为我们生活的世界的重要组成部分。它已经超越了其早期的唯一目的——提高企业效率。如今,企业都在想方设法成为一流的数字企业。作为用户,我们每个人每天都在与越来越多的软件进行交互。创新的车轮正在加速转动。

如果你想跟上步伐,你就必须想办法在不牺牲软件质量的前提下更快地交付你的软件。持续交付(Continuous delivery)可以帮助您实现这一点,在这种实践中,您可以自动确保您的软件可以在任何时候发布到生产环境中。通过持续交付,您可以使用构建管道来自动测试您的软件,并将其部署到您的测试和生产环境中。

手工构建、测试和部署越来越多的软件很快就不可能了——除非您想把所有的时间都花在手工的、重复的工作上,而不是交付工作软件。自动化一切——从构建到测试、部署和基础设施——是您前进的唯一道路。

图1:使用构建管道自动可靠地将软件投入生产

传统上,软件测试是过度手工的工作,将应用程序部署到测试环境中,然后执行一些黑箱风格的测试,例如单击用户界面查看是否有损坏。通常这些测试将由测试脚本指定,以确保测试人员执行一致的检查。

很明显,手工测试所有更改是费时、重复和乏味的。重复性工作很无聊,无聊会导致错误,让你在周末之前找一份不同的工作。

幸运的是,对于重复性任务有一个补救方法:自动化。

作为一名软件开发人员,自动化您的重复测试可以极大地改变您的生活。自动化这些测试,您就不再需要盲目地遵循点击协议来检查您的软件是否仍然正常工作。自动化您的测试,您可以毫不费力地更改您的代码基。如果您曾经尝试过在没有适当测试套件的情况下进行大规模重构,我敢打赌您一定知道这是一种多么可怕的体验。如果你在路上不小心弄坏了东西,你怎么知道?好吧,您点击所有的手动测试用例,就是这样。但说实话:你真的喜欢那样吗?如果你能做出更大的改变,知道你是否在喝咖啡的几秒钟内就把东西弄坏了呢?如果你问我,听起来会更有趣。

 

测试金字塔

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

图2:测试金字塔

Mike Cohn最初的测试金字塔由三层组成,你的测试套件应该包括(从下到上):

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

不幸的是,如果你仔细看的话,测试金字塔的概念有点短。有些人认为迈克·科恩的测试金字塔的命名或某些概念方面并不理想,我不得不同意这一点。从现代的观点来看,测试金字塔似乎过于简单,因此可能具有误导性。

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

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

坚持使用金字塔形状来得到一个健康、快速和可维护的测试套件:编写大量小而快速的单元测试。编写一些更粗粒度的测试和很少的高级测试来从头到尾测试您的应用程序。小心,你不会得到一个测试冰淇淋甜筒,这将是一个噩梦,维护和运行太长时间。

不要过于依赖科恩测试金字塔中各个层的名称。事实上,它们可能非常具有误导性:服务测试是一个很难理解的术语(Cohn自己谈到了许多开发人员完全忽略这一层的观察结果)。在使用react、angular、ember.js等单页面应用程序框架的日子里,UI测试显然不必位于金字塔的最高层——你完全可以在所有这些框架中对UI进行单元测试。

考虑到原始名称的缺点,为测试层提供其他名称是完全可以的,只要在代码库和团队讨论中保持一致即可。

工具和库

  1. JUnit:我们的测试运行器
  2. mockkito:用于mock依赖项
  3. Wiremock:用于清除外部服务
  4. PACT :用于编写CDC测试
  5. Selenium:用于编写ui驱动的端到端测试
  6. REST-assured:用于编写REST api驱动的端到端测试

 

样例应用程序

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

示例应用程序显示了典型微服务的特征。它提供一个REST接口,与数据库对话并从第三方REST服务获取信息。它是在Spring Boot中实现的,即使您以前从未使用过Spring Boot,也应该能够理解它。

确保在Github上查看代码。readme包含在您的机器上运行应用程序及其自动化测试所需的说明。

功能

应用程序的功能很简单。它提供了一个有三个端点的REST接口:

GET /hello 返回“Hello World”。总是这样。
GET /hello/{lastname} 查找提供姓氏的人。如果这个人是已知的,返回“Hello {Firstname} {Lastname}”。
GET /weather 返回德国汉堡的当前天气状况。

获取/天气返回德国汉堡的当前天气状况。

高层结构

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

图3:我们的微服务系统的高层结构

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

内部架构

在内部,Spring服务有一个典型的Spring架构:

图4:我们的微服务的内部结构

  1. 控制器类提供REST端点并处理HTTP请求和响应
  2. Repository类与数据库接口,负责将数据写入或从持久存储中读取
  3. 客户机类与其他API通信,在我们的示例中,它通过HTTPS从darksky.net weather API获取JSON
  4. 域类捕获我们的域模型,包括域逻辑(公平地说,在我们的例子中,域逻辑非常简单)。

有经验的Spring开发人员可能会注意到这里缺少一个常用的层:受领域驱动设计的启发,许多开发人员构建了一个由服务类组成的服务层。我决定不在这个应用程序中包含服务层。一个原因是我们的应用程序足够简单,服务层可能是不必要的间接层。另一个原因是我认为人们在服务层上做得太多了。我经常遇到这样的代码库,其中整个业务逻辑都在服务类中捕获。域模型仅仅成为一个数据层,而不是行为层(一个贫血的域模型)。对于每个重要的应用程序,这都浪费了保持代码良好结构和可测试性的大量潜力,并且没有充分利用面向对象的功能。

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

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

单元测试

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

图5:单元测试通常用测试替身替换外部协作者

单位是什么?

如果你问三个不同的人“单元”在单元测试上下文中是什么意思,你可能会得到四个不同的,稍微有些细微差别的答案。在某种程度上,这取决于你自己的定义,没有标准答案也没关系。

如果您使用的是函数式语言,那么单元很可能是单个函数。您的单元测试将调用具有不同参数的函数,并确保它返回预期值。在面向对象的语言中,单元可以从单个方法到整个类。

善于交际和孤独的

一些人认为,所有被测试对象的合作者(例如,被测试类调用的其他类)都应该用mock或stub替换,以实现完美的隔离,并避免副作用和复杂的测试设置。其他人则认为,只有那些速度较慢或副作用较大的协作者(例如访问数据库或进行网络调用的类)才应该被忽略或嘲笑。

偶尔人们会将这两种测试标记为单独的单元测试,用于存根所有协作者的测试,以及用于允许与真正协作者对话的测试的社会性单元测试(Jay Fields有效地使用单元测试创造了这些术语)。如果你有一些空闲时间,你可以深入了解不同学派的优缺点。

在一天结束的时候,决定是进行单独的单元测试还是社交单元测试并不重要。编写自动化测试是重要的。就我个人而言,我发现自己一直在使用这两种方法。如果使用真正的协作者变得很困难,我将大量使用mock和stub。如果我想让真正的合作者参与到测试中来,这会让我在测试中更有信心,那么我只会将服务的最外层存根。

通过模拟和存根

mock和存根是两种不同的测试替身(不止这两种)。许多人将Mock和存根这两个术语互换使用。我认为精确地记住它们的特定性质是有好处的。您可以使用test double用一个帮助您进行测试的实现来替换您在生产中使用的对象。

简单地说,它的意思是用一个伪版本替换一个真实的东西(例如类、模块或函数)。假版本的外观和行为与真实版本类似(对相同方法调用的答案),但是答案包含在单元测试开始时定义的固定响应。

使用测试双精度并不特定于单元测试。可以使用更精细的测试替身以受控的方式模拟系统的整个部分。然而,在单元测试中,您很可能会遇到很多mock和存根(这取决于您是喜欢交际的开发人员还是喜欢独处的开发人员),原因很简单,因为许多现代语言和库使设置mock和存根变得简单和舒适。

无论您选择哪种技术,很有可能您的语言的标准库或一些流行的第三方库将为您提供设置模拟的优雅方法。甚至从头开始编写自己的模拟也只是编写一个具有与实际签名相同的伪类/模块/函数,并在测试中设置伪类/模块/函数。

您的单元测试将运行得非常快。在一台不错的机器上,您可以期望在几分钟内运行数千个单元测试。单独测试代码基的一小部分,避免攻击数据库、文件系统或触发HTTP查询(通过对这些部分使用mock和存根),以保持测试速度。

一旦你掌握了编写单元测试的窍门,你就会越来越熟练地编写它们。排除外部合作者,设置一些输入数据,调用被测试对象,并检查返回的值是否符合您的期望。研究测试驱动开发,让您的单元测试指导您的开发;如果应用正确,它可以帮助您进入一个伟大的流程,并提出一个良好的和可维护的设计,同时自动生成一个全面的和完全自动化的测试套件。不过,这并不是什么灵丹妙药。去吧,给它一个真正的机会,看看它是否适合你。

 

测试什么?

单元测试的好处是,您可以为所有的生产代码类编写它们,不管它们的功能是什么,也不管它们属于内部结构的哪个层。您可以对控制器进行单元测试,就像您可以对存储库、域类或文件读取器进行单元测试一样。简单地按照每个生产类的经验规则坚持一个测试类,您就有了一个良好的开端。

单元测试类至少应该测试类的公共接口。不能测试私有方法,因为您不能从不同的测试类调用它们。从测试类中可以访问Protected或package-private(假定您的测试类的包结构与生产类相同),但是测试这些方法可能已经走得太远了。

当涉及到编写单元测试时,有一条很好的线:它们应该确保测试了所有非平凡的代码路径(包括幸福路径和边缘用例)。同时,它们不应该与您的实现太紧密地联系在一起。

为什么?

过于接近生产代码的测试很快就会变得令人讨厌。一旦重构了生产代码(快速回顾:重构意味着在不更改外部可见行为的情况下更改代码的内部结构),单元测试就会崩溃。

这样,您就失去了单元测试的一大好处:充当代码更改的安全网。每次重构时,您都会对那些愚蠢的测试失败感到厌倦,导致更多的工作而不是提供帮助;这个愚蠢的测试是谁的主意?

你会怎么做呢?不要在单元测试中反映内部代码结构。测试可观察的行为。思考

如果我输入值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”三元组,其中given表示设置,when方法调用,然后是断言部分。

这种模式也可以应用于其他更高级的测试。在每种情况下,它们都确保您的测试保持简单和一致。最重要的是,用这种结构编写的测试往往更短,更有表现力。

实现单元测试

现在我们知道了要测试什么以及如何构建单元测试,我们终于可以看到一个真实的例子了。

让我们以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类替换为测试的存根。这个存根允许我们定义存根方法在这个测试中应该返回的固定响应。存根使我们的测试更加简单、可预测,并允许我们轻松地设置测试数据。

按照arrangement、act、assert结构,我们编写了两个单元测试——一个是阳性的情况,另一个是无法找到被搜索的人的情况。第一个测试用例创建了一个新的person对象,并告诉模拟存储库在调用该对象时返回该对象,并将“Pan”作为lastName参数的值。然后测试继续调用应该测试的方法。最后,它断言响应等于预期的响应。

第二个测试的工作原理类似,但是测试的方法没有为给定的参数找到person。

 

集成测试

所有重要的应用程序都将与其他部分集成(数据库、文件系统、对其他应用程序的网络调用)。在编写单元测试时,为了得到更好的隔离性和更快的测试,您通常会遗漏这些部分。不过,您的应用程序将与其他部分进行交互,这需要进行测试。集成测试可以提供帮助。它们测试应用程序与应用程序外部的所有部分的集成。

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

我之前提到过“单元测试”是一个模糊的术语,对于“集成测试”更是如此。对于某些人来说,集成测试意味着通过连接到系统中其他应用程序的整个应用程序堆栈进行测试。我喜欢更严格地对待集成测试,每次测试一个集成点,用测试替身替换单独的服务和数据库。结合契约测试和对测试加倍运行契约测试,以及实际实现,您可以得到更快、更独立、通常更容易推理的集成测试。

窄集成测试位于服务的边界。从概念上讲,它们总是触发导致与外部部分(文件系统、数据库、独立服务)集成的操作。数据库集成测试应该是这样的:

图6:数据库集成测试将您的代码与实际数据库集成

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

另一个例子,测试你的服务通过一个REST API集成到一个单独的服务可能是这样的:

图7:这种集成测试检查应用程序是否能够正确地与单独的服务通信

  1. 启动您的应用程序
  2. 启动独立服务的实例(或具有相同接口的测试double)
  3. 在代码中触发从独立服务的API读取的函数
  4. 检查应用程序能否正确解析响应

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

为序列化或反序列化数据的所有代码段编写集成测试。这种情况发生的频率比你想象的要高。思考:

  1. 调用服务的REST API
  2. 读取和写入数据库
  3. 调用其他应用程序的api
  4. 读取和写入队列
  5. 写入文件系统

围绕这些边界编写集成测试可以确保向这些外部协作者编写数据和从这些外部协作者读取数据的工作正常。

在编写窄范围集成测试时,您的目标应该是在本地运行外部依赖项:启动本地MySQL数据库,在本地ext4文件系统上进行测试。如果要与单独的服务集成,要么在本地运行该服务的实例,要么构建并运行一个模仿真实服务行为的伪版本。

如果无法在本地运行第三方服务,您应该选择运行专用的测试实例,并在运行集成测试时指向该测试实例。避免在自动化测试中与实际生产系统集成。对生产系统发出数千个测试请求肯定会引起人们的愤怒,因为您弄乱了他们的日志(在最好的情况下),甚至拒绝了他们的服务(在最坏的情况下)。通过网络与服务集成是广泛集成测试的一个典型特征,这会使您的测试速度变慢,并且通常更难编写。

关于测试金字塔,集成测试比单元测试处于更高的级别。集成较慢的部分(如文件系统和数据库)往往比运行单元测试时将这些部分去掉要慢得多。它们也比小型和独立的单元测试更难编写,毕竟您必须将外部部分作为测试的一部分来处理。尽管如此,它们的优点是让您相信您的应用程序可以正确地处理它需要与之通信的所有外部部分。单元测试在这方面帮不了你。

数据库集成

PersonRepository是代码库中惟一的repository类。它依赖于Spring数据,没有实际的实现。它只是扩展了CrudRepository接口并提供了一个方法头。剩下的就是春天的魔力。

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

通过CrudRepository接口,Spring Boot提供了一个功能齐全的CRUD存储库,其中包含findOne、findAll、save、update和delete方法。我们的自定义方法定义(findByLastName())扩展了这一基本功能,并为我们提供了一种按姓氏获取人员的方法。Spring Data分析方法的返回类型及其方法名,并根据命名约定检查方法名,以确定它应该做什么。

尽管Spring Data承担了实现数据库存储库的重任,但我仍然编写了一个数据库集成测试。您可能会认为这是在测试框架,我应该避免这样做,因为我们测试的不是我们的代码。尽管如此,我相信这里至少有一个集成测试是至关重要的。首先,它测试自定义findByLastName方法的实际行为是否符合预期。其次,证明了我们的存储库正确使用了Spring的连接,可以连接到数据库。

为了让您更容易地在您的机器上运行测试(无需安装PostgreSQL数据库),我们的测试连接到内存中的H2数据库。

我将H2定义为构建中的一个测试依赖项。gradle文件。应用程序。测试目录中的属性不定义任何spring。数据源的属性。这告诉Spring Data使用内存中的数据库。当它在类路径中找到H2时,它在运行我们的测试时只使用H2。

当运行带有int概要文件的实际应用程序时(例如,通过将SPRING_PROFILES_ACTIVE=int作为环境变量),它连接到应用程序int.properties中定义的PostgreSQL数据库。

我知道,有很多Spring的细节需要了解和理解。要做到这一点,你必须仔细阅读大量的文档。生成的代码看起来很简单,但是如果不了解Spring的详细信息,就很难理解。

除此之外,使用内存数据库是有风险的。毕竟,我们的集成测试运行在与生产环境不同的数据库类型上。您可以自己决定是否更喜欢Spring magic和简单的代码,而不是显式的更详细的实现。

已经有足够的解释了,下面是一个简单的集成测试,它将一个人保存到数据库中,并根据他的姓找到他:

@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中的属性文件。这是运行测试时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从天气中读取weatherUrl参数的值。我们在应用程序属性中定义url属性。

使用Wiremock之类的工具,为单独的服务编写窄范围的集成测试非常容易。不幸的是,这种方法有一个缺点:如何确保我们设置的假服务器的行为与真实服务器一样?使用当前的实现,单独的服务可以更改它的API,我们的测试仍然可以通过。现在,我们只是在测试我们的WeatherClient是否能够解析伪服务器发送的响应。这是一个开始,但它很脆弱。使用端到端测试并针对真实服务的测试实例运行测试,而不是使用虚假服务,可以解决这个问题,但是会使我们依赖于测试服务的可用性。幸运的是,对于这个困境有一个更好的解决方案:对伪服务器和真实服务器运行契约测试,确保我们在集成测试中使用的伪服务器是一个忠实的测试副本。接下来让我们看看它是如何工作的。

 

合同的测试

更现代的软件开发组织已经找到了通过将系统开发分散到不同的团队来扩展开发工作的方法。单独的团队构建单独的、松散耦合的服务,而不需要彼此介入,并将这些服务集成到一个大型的、内聚的系统中。最近围绕微服务的热议正聚焦于此。

将您的系统分割为许多小型服务通常意味着这些服务需要通过某些接口(希望定义良好,有时意外地增长)彼此通信。

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

  1. 通过HTTPS实现REST和JSON
  2. 使用诸如gRPC之类的东西的RPC
  3. 使用队列构建事件驱动的体系结构

对于每个接口,都涉及两个方面:提供者和使用者。提供者向使用者提供数据。使用者处理从提供者获得的数据。在REST世界中,提供者使用所有需要的端点构建一个REST API;使用者调用此REST API来获取数据或触发其他服务中的更改。在异步的、事件驱动的世界中,提供者(通常称为发布者)将数据发布到队列;使用者(通常称为订阅者)订阅这些队列并读取和处理数据。

图8:每个接口都有一个提供方(或发布方)和一个消费方(或订阅方)。接口的规范可以看作是契约。

由于您经常将消费和提供服务分散到不同的团队中,您会发现必须清楚地指定这些服务之间的接口(所谓的契约)。传统上,公司处理这个问题的方法如下:

  1. 编写一个长而详细的接口规范(合同)
  2. 根据定义的合同实现提供的服务
  3. 将接口规范抛给消费团队
  4. 等待,直到它们实现使用接口的那一部分
  5. 运行一些大型手动系统测试,看看是否一切正常
  6. 希望两个团队永远坚持接口定义,不要搞砸

更现代的软件开发团队已经取代了步骤5。和6。使用更自动化的东西:自动化契约测试确保消费者和提供者端上的实现仍然坚持已定义的契约。它们可以作为一个很好的回归测试套件,并确保尽早发现与契约的偏差。

在一个更敏捷的组织中,你应该采取更有效和更少浪费的方式。您在同一个组织中构建应用程序。与其他服务的开发人员直接对话,而不是将过于详细的文档抛到一边,应该不会太难。毕竟,他们是你的同事,而不是你只能通过客户支持或合法的防弹合同与之交谈的第三方供应商。

消费者驱动的契约测试(CDC测试)让消费者驱动契约的实现。使用CDC,接口的使用者编写测试,检查接口中所需的所有数据。然后,消费团队发布这些测试,以便发布团队能够轻松地获取和执行这些测试。现在,提供团队可以通过运行CDC测试来开发他们的API。一旦所有测试通过,他们就知道已经实现了消费团队所需的所有东西。

图9:契约测试确保接口的提供者和所有使用者都遵守定义的接口契约。在CDC测试中,接口的消费者以自动化测试的形式发布他们的需求;提供者不断地获取和执行这些测试

这种方法允许提供团队只实现真正需要的东西(保持事情简单,YAGNI等等)。提供接口的团队应该连续获取并运行这些CDC测试(在他们的构建管道中),以立即发现任何中断的更改。如果他们破坏了接口,他们的CDC测试就会失败,从而阻止破坏的更改生效。只要测试保持绿色,团队就可以进行他们喜欢的任何更改,而不必担心其他团队。消费者驱动的契约方法将使您的流程如下:

  1. 消费团队编写具有所有消费者期望的自动化测试
  2. 他们为提供团队发布测试
  3. 提供团队持续运行CDC测试并保持测试绿色
  4. 一旦CDC测试中断,两个团队就会互相交谈

如果您的组织采用微服务方法,那么进行CDC测试是建立自治团队的重要一步。CDC测试是一种促进团队沟通的自动化方法。它们确保团队之间的接口在任何时候都能正常工作。失败的CDC测试是一个很好的指示器,说明您应该走到受影响的团队,与他们讨论任何即将发生的API更改,并确定您希望如何继续前进。

CDC测试的简单实现可以简单到针对API发出请求并断言响应包含所需的所有内容。然后将这些测试打包为可执行文件(。并将其上传到其他团队可以获取它的地方(例如,像 Artifactory这样的工件存储库)。

在过去的几年里,CDC的方法变得越来越流行,并且开发了一些工具来简化编写和交换。

Pact可能是这些天来最突出的一个。它有一种为消费者和提供者编写测试的复杂方法,为独立的服务提供开箱即用的存根,并允许您与其他团队交换CDC测试。Pact已经被移植到许多平台上,可以与JVM语言、Ruby、. net、JavaScript以及更多其他语言一起使用。

如果你想从信用违约掉期开始,却不知道如何开始,那么签订协议可能是一个明智的选择。一开始, documentation可能非常多。要有耐心,并坚持到底。它有助于对CDCs有一个牢固的了解,从而使您在与其他团队合作时更容易提倡使用CDCs。

消费者驱动的契约测试可以真正改变游戏规则,从而建立能够快速且自信地行动的自主团队。帮你自己一个忙,通读一下这个概念并试一试。一套可靠的CDC测试是非常宝贵的,它能够在不破坏其他服务和对其他团队造成很多挫折的情况下快速移动。

 

消费者测试(我们的团队)

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

首先,我们在我们的建筑中包含了一个用于编写契约消费者测试的库。

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的测试相比,该测试的优点是每次运行时都会生成一个协议文件(可以在target/pacts/&pact-name>.json中找到)。这个契约文件以一种特殊的JSON格式描述了我们对契约的期望。然后可以使用这个契约文件来验证存根服务器的行为是否与真正的服务器一样。我们可以将契约文件交给提供接口的团队。他们使用这个契约文件并使用其中定义的期望编写一个提供者测试。通过这种方式,他们测试他们的API是否满足我们的所有期望。

这就是CDC的消费者驱动部分的来源。消费者通过描述他们的期望来驱动接口的实现。提供者必须确保它们满足所有的期望,并且它们已经完成。没有镀金,没有雅格尼之类的东西。

将契约文件提供给团队可以通过多种方式进行。一个简单的方法是将它们签入版本控制并告诉提供程序团队始终获取最新版本的pact文件。更先进的方法是使用工件存储库,如Amazon的S3或pact broker之类的服务。从简单的开始,根据你的需要成长。

在实际的应用程序中,不需要同时进行集成测试和客户端类的消费者测试。示例代码基包含这两个代码基,以向您展示如何使用其中一个。如果您想使用pact编写CDC测试,我建议坚持使用后者。编写测试的工作是相同的。使用pact的好处是,您可以自动获得一个pact文件,其中包含对契约的期望,其他团队可以使用该文件轻松地实现他们的提供者测试。当然,只有当你能够说服其他团队也使用pact时,这才有意义。如果这不起作用,使用集成测试和Wiremock组合是一个不错的方案b。

 

提供者测试(另一个团队)

提供者测试必须由提供weather API的人员实现。我们正在使用一个由darksky.net提供的公共API。理论上,darksky团队将在他们的端上实现提供者测试,以检查他们是否违反了他们的应用程序和我们的服务之间的契约。

很明显,他们不关心我们可怜的样本应用程序,也不会为我们实现CDC测试。这就是面向公众的API和采用微服务的组织之间的巨大区别。面向公众的api不能考虑每一个消费者,否则它们将无法前进。在你自己的组织中,你可以而且应该这么做。你的应用很可能只服务少数人,最多几十个消费者。为了保持系统稳定,您可以为这些接口编写提供者测试。

提供团队获取契约文件并对其提供的服务运行它。为此,它们实现了一个提供者测试,该测试读取契约文件,生成一些测试数据,并对它们的服务运行契约文件中定义的期望。

契约人员编写了几个库来实现提供者测试。他们的主要GitHub repo为您提供了一个很好的概述:哪些消费者和哪些提供者库可用。选择一个最适合你的技术堆栈。

为了简单起见,我们假设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"));
    }
}

您可以看到,提供者测试所要做的就是加载一个契约文件(例如,通过使用@PactFolder注释来加载以前下载的契约文件),然后定义应该如何提供预定义状态的测试数据(例如,使用Mockito mocks)。没有要实现的自定义测试。这些都是从契约文件派生出来的。提供者测试必须具有与使用者测试中声明的提供者名称和状态匹配的对应项。

供应商测试(我们的团队)

我们已经了解了如何测试我们的服务和天气提供商之间的契约。通过这个接口,我们的服务充当消费者,天气服务充当提供者。进一步考虑一下,我们将看到我们的服务还充当了其他服务的提供者:我们提供了一个REST API,它提供了几个端点,可以供其他服务使用。

由于我们刚刚了解到契约测试非常流行,所以我们当然也为这个契约编写了一个契约测试。幸运的是,我们使用的是消费者驱动的契约,所以所有的消费团队都向我们发送了他们的契约,我们可以使用这些契约来实现REST API的提供者测试。

让我们先把Spring的Pact provider库添加到我们的项目中:

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将获取Pact文件并对我们的服务发出HTTP请求,然后根据我们设置的状态进行响应。

用户界面测试

大多数应用程序都有某种用户界面。通常,我们在web应用程序上下文中讨论的是web接口。人们常常忘记,REST API或命令行界面与web用户界面一样,都是用户界面。

UI测试测试应用程序的用户界面是否正常工作。用户输入应该触发正确的动作,数据应该呈现给用户,UI状态应该按照预期进行更改。

UI测试和端到端测试有时(如Mike Cohn的例子)被认为是同一件事。对我来说,这合并了两个相当正交的概念。

是的,端到端测试应用程序通常意味着通过用户界面驱动测试。然而,反过来说就不成立了。

测试用户界面不需要以端到端方式完成。根据使用的技术不同,测试用户界面可以非常简单,只需为前端javascript代码编写一些单元测试,并去掉后端代码。

对于传统的web应用程序,可以使用Selenium等工具来测试用户界面。如果您认为REST API是您的用户界面,那么您应该通过围绕API编写适当的集成测试来获得所需的一切。

对于web界面,您可能希望围绕UI测试多个方面:行为、布局、可用性或对公司设计的坚持性只是其中的几个方面。

幸运的是,测试用户界面的行为非常简单。单击这里,在那里输入数据,并希望用户界面的状态相应地更改。现代单页应用程序框架(react, vue)。js、Angular等等)通常都自带工具和助手,它们允许您以一种相当低层次(单元测试)的方式彻底测试这些交互。即使您使用普通javascript滚动自己的前端实现,也可以使用常规的测试工具,如Jasmine或Mocha。对于更传统的服务器端呈现应用程序,基于硒的测试将是您的最佳选择。

测试web应用程序的布局是否保持不变要稍微困难一些。根据您的应用程序和用户的需要,您可能希望确保代码更改不会意外破坏网站的布局。

问题在于,计算机在检查某样东西是否“看起来不错”方面出了名的糟糕(也许一些聪明的机器学习算法可以在未来改变这一点)。

如果您想在构建管道中自动检查web应用程序的设计,可以尝试一些工具。这些工具中的大多数都使用Selenium以不同的浏览器和格式打开web应用程序,进行截图,并将这些截图与之前的截图进行比较。如果旧截图和新截图有意想不到的不同,这个工具会让你知道。

盖伦就是这些工具之一。但是,如果您有特殊的需求,即使使用您自己的解决方案也不是太难。我与构建阵容及其基于java的近亲j阵容合作过的一些团队也实现了类似的目标。这两个工具都采用了我前面描述的基于selenium的方法。

一旦您想要测试可用性和“看起来不错”的因素,您就离开了自动化测试领域。在这个领域,您应该依赖探索性测试、可用性测试(甚至可以像走廊测试一样简单),并向用户展示他们是否喜欢使用您的产品,以及是否可以使用所有特性而不会感到沮丧或烦恼。

 

端到端测试

通过已部署的应用程序的用户界面进行测试是您可以测试应用程序的最端到端方式。前面描述的webdriver驱动的UI测试是端到端测试的一个很好的例子。

图11:端到端测试测试整个、完全集成的系统

端到端测试(也称为广义堆栈测试)在您需要决定软件是否工作时给您最大的信心。Selenium和WebDriver协议允许您通过对已部署的服务自动驱动(无头)浏览器、执行单击、输入数据和检查用户界面状态来自动化测试。您可以直接使用Selenium,或者使用构建在它之上的工具,夜视就是其中之一。

端到端测试本身也有一些问题。他们是出了名的脆弱,经常因为意想不到和无法预见的原因而失败。他们的失败往往是一种错误的肯定。用户界面越复杂,测试就越脆弱。浏览器的怪圈、计时问题、动画和意想不到的弹出对话框只是让我花了比我愿意承认的更多时间进行调试的部分原因。

在微服务世界中,还有一个大问题是谁负责编写这些测试。因为它们跨越多个服务(您的整个系统),所以没有一个团队负责编写端到端测试。

如果你有一个集中的质量保证团队,他们看起来很合适。同样,拥有一个集中的QA团队是一个很大的反模式,不应该在DevOps世界中占有一席之地,因为在DevOps世界中,您的团队应该是真正跨功能的。谁应该拥有端到端测试并没有简单的答案。也许您的组织有一个实践社区或质量协会可以处理这些问题。找到正确答案在很大程度上取决于您的组织。

此外,端到端测试需要大量的维护,运行速度非常慢。考虑一个有多个微服务的场景,您甚至不能在本地运行端到端测试——因为这也需要在本地启动所有微服务。祝您在开发机器上运行数百个应用程序时好运,而不会破坏RAM。

由于它们的高维护成本,您应该将端到端测试的数量减少到最小。

考虑用户与应用程序之间的高价值交互。尝试提出定义产品核心价值的用户旅程,并将这些用户旅程中最重要的步骤转换为自动化的端到端测试。

如果你正在建立一个电子商务网站,你最有价值的客户旅程可能是一个用户搜索产品,把它放在购物篮和结帐。就是这样。只要这次旅行还有效,你就不会有太大的麻烦。也许您会发现一个或两个更重要的用户旅程,您可以将它们转换为端到端测试。任何超过这一点的事情都可能是痛苦多于有益的。

记住:在您的测试金字塔中有许多较低的级别,您已经测试了各种边缘用例以及与系统其他部分的集成。没有必要在更高的级别上重复这些测试。高维护工作量和大量的误报会降低您的速度,并导致您对测试失去信任,这迟早都会发生。

 

用户界面端到端测试

对于端到端测试,Selenium和WebDriver协议是许多开发人员选择的工具。使用Selenium,您可以选择一个您喜欢的浏览器,让它自动调用您的网站,点击这里和那里,输入数据,并检查用户界面中的内容是否发生了更改。

Selenium需要一个可以启动并用于运行测试的浏览器。对于不同的浏览器,您可以使用多个所谓的“驱动程序”。选择一个(或多个)并将其添加到build.gradle中。无论选择哪种浏览器,都需要确保团队和CI服务器中的所有开发人员都在本地安装了正确版本的浏览器。保持同步可能会很痛苦。对于Java,有一个叫做webdrivermanager的小库,可以自动下载并设置您想使用的浏览器的正确版本。将这两个依赖项添加到构建中。格勒德,你可以走了:

testCompile (“org.seleniumhq.selenium: selenium-chrome-driver: 2.53.1”)

testCompile (“io.github.bonigarcia: webdrivermanager: 1.7.2”)

在您的测试套件中运行一个功能齐全的浏览器可能会很麻烦。特别是在使用连续交付时,运行管道的服务器可能无法启动包含用户界面的浏览器(例如,因为没有可用的X-Server)。您可以通过启动像xvfb这样的虚拟x服务器来解决这个问题。

最近的一种方法是使用无头浏览器(即没有用户界面的浏览器)来运行web驱动程序测试。直到最近PhantomJS还是领先的用于浏览器自动化的无头浏览器。自从Chromium和Firefox都宣布在他们的浏览器PhantomJS中实现了无头模式后,PhantomJS突然就过时了。毕竟,最好使用用户实际使用的浏览器(比如Firefox和Chrome)来测试您的网站,而不是使用人工浏览器,因为这对开发人员来说很方便。

无头火狐和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向我们的朋友“Pan先生”说“hello”时,我们得到了一个很好的问候。神奇的!如果您甚至不使用web接口,那么端到端测试就足够了。

 

验收测试——您的特性工作正常吗?

您在测试金字塔中的位置越高,就越有可能从用户的角度测试您正在构建的特性是否正确工作。您可以将您的应用程序视为一个黑盒子,并将测试中的焦点从

when I enter the values x and y, the return value should be z

towards

given there's a logged in user

and there's an article "bicycle"

when the user navigates to the "bicycle" article's detail page

and clicks the "add to basket" button

then the article "bicycle" should be in their shopping basket

有时您会听到这类测试的术语功能测试或验收测试。有时候人们会告诉你功能测试和验收测试是两码事。有时这些术语会被合并。有时人们会无休止地争论措辞和定义。这种讨论通常会引起很大的困惑。

事情是这样的:在某一点上,您应该确保从用户的角度测试您的软件是否正确工作,而不仅仅是从技术的角度。你所谓的测试其实并不那么重要。然而,进行这些测试是必要的。选择一个术语,坚持它,并编写这些测试。

这也是人们谈论BDD和允许您以BDD方式实现测试的工具的时刻。编写测试的BDD或BDD风格的方法是一个很好的技巧,可以将您的思维从实现细节转移到用户的需求。去试试吧。

您甚至不需要采用成熟的BDD工具,比如Cucumber(尽管您可以)。一些断言库(如cai .js)允许您使用should风格的关键字编写断言,从而使您的测试读起来更像北斗。即使您不使用提供这种表示法的库,聪明且经过良好分解的代码也将允许您编写关注用户行为的测试。一些辅助方法/函数可以让你走很长的路:

# 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。某些质量问题甚至不会在您的自动化测试中变得明显(考虑设计或可用性)。尽管您对测试自动化的意图是最好的,但是某些类型的手工测试仍然是一个好主意。

图12:使用探索性测试来发现构建管道没有发现的所有质量问题

在您的测试组合中包括探索性测试。它是一种手工测试方法,强调测试人员发现运行系统中的质量问题的自由和创造性。只需在常规的时间表上花些时间,卷起袖子,试着打破你的应用程序。使用一种破坏性的思维方式,想出一些方法来引发应用程序中的问题和错误。记录下你找到的所有东西。注意bug、设计问题、慢响应时间、丢失或误导错误消息,以及所有其他可能会惹恼您的软件用户的事情。

好消息是,您可以愉快地使用自动化测试自动化您的大部分发现。为您发现的bug编写自动化测试可以确保将来不会出现任何bug的回归。此外,它还可以帮助您在修复bug期间缩小问题的根源。

在探索性测试期间,您将发现在构建管道中未被注意到的问题。不要沮丧。这是关于构建管道成熟度的重要反馈。和任何反馈一样,一定要付诸行动:想想你能做些什么来避免未来出现这类问题。也许您错过了一组特定的自动化测试。也许您只是在这个迭代中草率地使用了自动化测试,并且需要在将来进行更彻底的测试。也许有一个闪亮的新工具或方法,您可以在您的管道中使用,以避免这些问题在未来。一定要采取行动,这样您的管道和整个软件交付就会随着时间的推移而变得更加成熟。

测试术语的混淆

讨论不同的测试分类总是困难的。我所说的单元测试可能与您的理解略有不同。而集成测试则更糟。对某些人来说,集成测试是一项非常广泛的活动,它通过整个系统的许多不同部分进行测试。对我来说,这是一件相当狭隘的事情,一次只测试一个外部部分的集成。有些将它们称为集成测试,有些将它们称为组件测试,有些更喜欢使用服务测试这个术语。甚至有人会说,这三个词完全是两码事。没有对错之分。软件开发社区还没有设法解决围绕测试的定义良好的术语。

不要太拘泥于模棱两可的术语。无论您将其称为端到端测试、宽堆栈测试还是功能测试,都没有关系。您的集成测试对您和其他公司的人是否有不同的意义并不重要。是的,如果我们的专业能在一些定义明确的术语上达成一致并坚持下去,那就太好了。不幸的是,这还没有发生。由于在编写测试时存在许多细微差别,所以它实际上更像是一个频谱,而不是一堆离散的桶,这使得一致的命名更加困难。

重要的是,您应该找到适合您和您的团队的术语。要清楚要编写的不同类型的测试。在您的团队中就命名达成一致,并就每种测试类型的范围达成一致。如果你在你的团队中(甚至在你的组织中)保持这种一致性,这才是你真正应该关心的。Simon Stewart在描述谷歌使用的方法时很好地总结了这一点。我认为这很好地说明了过多地关注名称和命名约定是不值得这么麻烦的。

将测试放入部署管道中

如果您正在使用持续集成或持续交付,那么您将拥有一个部署管道,它将在您每次对软件进行更改时运行自动化测试。通常,这个管道被分成几个阶段,这些阶段会逐渐让您更加确信您的软件已经准备好部署到生产环境中。听到所有这些不同类型的测试,您可能想知道应该如何将它们放置在部署管道中。要回答这个问题,您只需要考虑持续交付的一个非常基本的价值(实际上是极限编程和敏捷软件开发的核心价值之一):快速反馈。

一个好的构建管道会告诉您,您已经尽可能快地搞砸了。您不希望仅仅等待一个小时就发现您最新的更改破坏了一些简单的单元测试。如果你的管道花了那么长时间给你反馈,你很可能已经回家了。通过将快速运行的测试放在管道的早期阶段,您可以在几秒钟内,甚至几分钟内获得这些信息。相反,您将较长时间运行的测试(通常是范围更广的测试)放在后面的阶段,以避免延迟来自快速运行测试的反馈。您可以看到,定义部署管道的阶段不是由测试类型驱动的,而是由测试的速度和范围驱动的。记住,它可以是一个非常合理的决定的范围较窄,湍急的集成测试在同一阶段作为单元测试-仅仅是因为他们给你更快的反馈,而不是因为你想画线测试的正式类型。

避免重复测试

既然您已经知道应该编写不同类型的测试,那么还有一个需要避免的陷阱:在金字塔的不同层中复制测试。虽然你的直觉可能会说没有太多的测试,但我向你保证,确实有。您测试套件中的每一个测试都是额外的负担,并不是免费的。编写和维护测试需要时间。阅读和理解他人的测试需要时间。当然,运行测试需要时间。

与生产代码一样,您应该力求简单并避免重复。在实现测试金字塔的过程中,你应该记住两条经验法则:

  1. 如果高级测试发现错误,并且没有低级测试失败,则需要编写低级测试
  2. 把你的测试尽可能往下推

第一个规则很重要,因为较低级别的测试允许您更好地缩小错误范围,并以独立的方式复制它们。当您调试手头的问题时,它们将运行得更快,并且不会那么臃肿。它们将成为未来很好的回归测试。第二条规则对于保持测试套件的速度非常重要。如果您已经在较低级别的测试中自信地测试了所有条件,那么没有必要在您的测试套件中保留较高级别的测试。它只是没有增加更多的信心,一切都在工作。在日常工作中进行冗余的测试会变得很烦人。您的测试套件将变慢,当您更改代码的行为时,您需要更改更多的测试。

让我们换一种说法:如果更高级别的测试让您对应用程序的正确工作更有信心,那么您应该拥有它。为控制器类编写单元测试有助于测试控制器本身中的逻辑。不过,这并不能告诉您这个控制器提供的REST端点是否实际响应HTTP请求。因此,您向上移动测试金字塔,并添加一个测试来精确地检查它——但仅此而已。您不需要再次测试低级测试中已经包含的所有条件逻辑和边缘用例。确保高级测试集中于低级测试不能覆盖的部分。

当涉及到消除没有任何价值的测试时,我是严格的。我删除已经在较低级别上覆盖的高级测试(假定它们不提供额外的价值)。如果可能的话,我用较低级别的测试替换较高级别的测试。有时候这很难,尤其是当你知道做一个测试是件很辛苦的事情的时候。小心沉没成本谬论,按下删除键。没有理由在没有价值的测试上浪费更多宝贵的时间。

 

编写干净的测试代码

与编写一般代码一样,编写好的、干净的测试代码需要非常小心。这里有更多的提示,让你在使用你的自动化测试套件之前,可以使用可维护的测试代码:

测试代码和生产代码一样重要。给予同样的关心和关注。“这只是测试代码”并不是为草率的代码辩解的有效借口

每个测试测试一个条件。这有助于你保持你的测试简短和容易推理

“安排、执行、断言”或“给定、何时、然后”是保持测试结构良好的很好的助记符

可读性很重要。不要太干。如果可以提高可读性,复制是可以的。试着在干燥和潮湿的代码之间找到一个平衡

当有疑问时,使用三原则来决定何时重构。使用之前重用

结论

就是这样!我知道这是一个漫长而艰难的阅读来解释为什么以及如何测试你的软件。好消息是,这些信息是非常永恒的,并且与您正在构建的软件类型无关。无论您是在开发微服务、物联网设备、移动应用程序还是web应用程序,本文的经验教训都可以应用到所有这些方面。

我希望本文能对您有所帮助。现在继续检查示例代码,并将这里解释的一些概念放入您的测试组合中。拥有一个可靠的测试组合需要一些努力。相信我,从长远来看,这样做是有回报的,它会让你作为一个开发者的生活更加平静。

确认

感谢Clare Sudbery、Chris Ford、Martha Rohte、Andrew Jones-Weiss David Swallow、Aiko Klostermann、Bastian Stein、Sebastian Roidl和Birgitta Bockeler为本文的早期草稿提供反馈和建议。感谢Martin Fowler的建议、见解和支持。

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

本文:http://pub.intelligentx.net/node/512

讨论:请加入知识星球或者小红圈【首席架构师圈】

Tags
 
Article
知识星球
 
微信公众号
 
视频号