您还未登录! 登录 | 注册 | 帮助  

您的位置: 首页 > 软件测试技术 > 其他相关 > 正文

软件测试金字塔

发表于:2018-06-28 作者:Ham Vocke 来源:DevOps时代 点击数:

“测试金字塔”是一个隐喻,它告诉我们将软件测试分成不同颗粒度的桶,也给出了我们应该在这些组中进行多少次测试的想法。尽管测试金字塔的概念已经存在了一段时间,但团队仍然很难正确地实施。本文重新探讨了测试金字塔的原始概念,并展示了如何将其付诸实践。讨论你应该在金字塔的不同层次上寻找哪种类型的测试,并给出了如何实现这些测试的实例。

Ham Vocke

生产就绪软件在投入生产之前需要进行测试。

随着软件开发规律的成熟,软件测试方法也日趋成熟。开发团队不再需要大量的手动软件测试人员,而是将测试工作最大部分自动化。自动化测试可以让团队在短时间内知道他们的软件有什么问题,而不是几天或几周。

由自动化测试助力的缩短反馈环路与敏捷开发实践,持续交付和DevOps文化携手并进。 采用有效的软件测试方法,团队可以快速而自信地行动。

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

(测试)自动化的重要性

软件已成为我们生活的世界的重要组成部分。它早已超出了提高企业效率这一个目的。今天,公司都试图想方设法成为一流的数字公司。随着我们每个人都会与越来越多的软件进行交互。创新的车轮会转得更快。

如果你想跟上步伐,必须研究如何在不牺牲质量的情况下更快地交付你的软件。持续交付是一种自动确保你的软件可以随时发布到生产环境中的方式,可以为你提供帮助。通过持续交付,可以使用构建管道自动测试软件并将其部署到测试和生产环境中。

手动构建,测试和部署不断增加的软件数量很快就变得不可能了-除非你希望将所有时间花费在手动,重复性的工作而不是提高交付效率的工作上。

Figure 1: Use build pipelines to automatically and reliably get your software into production

传统上,软件测试过于手动化,通过将应用程序部署到测试环境,然后执行一些黑盒测试,例如,通过点击你的用户界面来查看是否有任何问题。这些测试通常由测试脚本指定,以确保测试人员能够进行一致性检查。

很明显,手动测试所有更改非常耗时,重复且乏味。重复是无聊的,无聊会导致错误,并使你在本周末之前寻找不同的工作。(译者注:意思就是没做完就找不同的事做) 幸运的是,对于重复性任务有一种补救措施:自动化

自动化重复性测试可以成为软件开发人员生活中的重大改变。使自动化测试,你不再需要盲目地遵循点击协议来检查你的软件是否仍能正常工作。自动化你的测试,你可以改变代码库而不用打眼球。如果你曾经尝试过在没有适当的测试套件的情况下进行大规模的重构,我敢打赌这会是一个多么可怕的体验。 你怎么知道你是否意外地破坏了某些东西?那么,就是你点击所有的手动测试用例。但说实话:你真的喜欢这样吗?

如何做大规模的变化的时候,并知道你是否在几秒钟内破坏了东西,同时喝一口咖啡?如果你问我,这样会更愉快。

测试金字塔

如果你想认真对待软件的自动化测试,应该了解一个关键概念:测试金字塔。 迈克·科恩在他的着作“与敏捷成功”一书中提出了这个概念。这是一个伟大的视觉隐喻,告诉你思考不同层次的测试。它还会告诉你在每个图层上要做多少测试。

Figure 2: The Test Pyramid

Mike Cohn的原始测试金字塔由你的测试套件应包含的三个层组成(从下到上):

1、Unit Tests

2、Service Tests

3、User Interface Tests

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

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

1、用不同的粒度编写测试

2、更高的层次,更少的测试

坚持金字塔形状,以提出一个健康,快速和可维护的测试套件:写许多小而快的单元测试。 写一些更粗粒度的测试和减少高级测试,从头到尾测试你的应用程序。 注意,你最终不会得到一个测试冰淇淋锥,这将是一个噩梦来维持,并且运行时间太长。

(译者注:测试冰激凌锥的示意图)

不要太拘泥于科恩测试金字塔中单个图层的名称。

事实上,它们可能会引起误解:服务测试是一个难以理解的术语(科恩本人谈论的观察结果是许多开发人员完全忽略了这一层)。在诸如react,angular,ember.js等单页面应用程序框架的日子里,UI测试显然不必位于金字塔的最高层 - 在这些框架中你完全可以使用单元测试测试你的UI。

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

我们会使用的工具和库

JUnit: test runner

Mockito:mocking dependencies

Wiremock:用于剔除外部服务

Pact:用于编写CDC测试

Selenium:用于编写UI驱动的端到端测试

REST-assured:用于编写REST API驱动的端到端测试

应用例子

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

示例应用程序显示了典型的微服务的特征。

它提供了一个REST接口,与数据库交互并从第三方REST服务获取信息。

它在Spring Boot中实现,即使你以前从未使用过Spring Boot,也应该可以理解。

请务必查看Github上的代码。

自述文件包含您在计算机上运行应用程序及其自动化测试所需的说明。

功能

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

GET / hello 返回“Hello Word”. 总是

GET / hello{lastname} 用提供的姓氏查找该人。

如果有这个人,则返回”Hello{Firstname}{Lastname}”.

GET /weather 返回当前的天气状况 Hambur, Germany.

高层级结构

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

Figure 3: the high level structure of our microservice system

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

内部结构

在内部,Spring服务有一个典型的Spring体系结构:

Figure 4: the internal structure of our microservice

  • 控制器类提供REST端点并处理HTTP请求和响应
  • 存储库类与数据库接口并负责向持久存储器写入数据和从持久存储器读取数据
  • 客户端类与其他API交互,在我们的例子中,它通过darksky.net weather API的HTTPS获取JSON
  • Domain 类捕捉我们的domain 模型,包括领域逻辑(公平地说,在我们的例子中,这是相当微不足道的)。

有经验的Spring开发人员可能注意到这里经常使用的图层缺失:受Domain-Driven Design的启发,很多开发人员构建了一个由服务类组成的服务层。我决定不在此应用程序中包含服务层。

其中一个原因是我们的应用程序很简单,服务层本来就是不必要的间接层。 另外一个是我认为人们过度使用服务层。我经常遇到在服务类中捕获整个业务逻辑的代码库。 Domain 模型仅仅成为数据层,而不是行为(Anemic Domain Model)。

对于每一个不平凡的应用程序来说,这会浪费很多潜能来保持代码的结构良好和可测试性,并且不能充分利用面向对象的功能。

我们的存储库非常简单,并提供简单的CRUD功能。

为了简化代码,我使用了Spring Data。Spring Data为我们提供了一个简单而通用的CRUD存储库实现,我们可以使用它来代替我们自己的实现。它还负责为测试启动内存数据库,而不是像生产中那样使用真正的PostgreSQL数据库。看看代码库,让自己熟悉内部结构。这对我们的下一步将是有用的:测试应用程序!

单元测试

测试套件的基础将由单元测试组成。你的单元测试确保你的代码库的某个单元(你的受测主题)按预期工作。单元测试具有测试套件中所有测试的最小范围。测试套件中的单元测试数量将远远超过任何其他类型的测试。

Figure 5: A unit test typically replaces external collaborators with test doubles

什么是单位?

如果你问三个不同的人在单元测试中的“单位”是什么意思,你可能会收到四个不同的,微妙的答案。在一定程度上,这是一个你自己定义的问题,没有标准答案。

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

善于交际和孤独

有些人认为,被测主题的所有合作者(例如被测试的课程调用的其他类)都应该用模拟或存根代替,以获得完美的隔离,避免副作用和复杂的测试设置。 其他人则认为只有缓慢或副作用较大的合作者(例如,访问数据库或进行网络调用的类)应该被存根或模拟。

偶尔,人们会将这两种测试标记为孤独的单元测试,测试将所有合作者和社交单元测试存储在允许与真正合作者交谈的测试中(Jay Fields的“有效地使用单元测试工作”创造了这些术语)。如果你有空闲时间,你可以打开看一下,阅读更多关于不同思想流派的优点和缺点。

在一天结束时,决定是否进行单独的或社交单元测试并不重要。重要的是编写自动化测试。就我个人而言,我发现自己一直都在使用这两种方法。如果使用真正的方法,合作者变得尴尬,我会慷慨地使用模拟和存根。

如果我觉得参与的合作者让我对测试更有信心,那么我只会将我的服务的最外面的部分存根。

Mocking and Stubbing

Mocks和Stubs 是两种不同类型的Test Doubles(不止这两种)。许多人可以互换地使用术语Mock和Stub。我认为在脑海中精确保持其特定属性是件好事。 你可以使用test doubles 来替换你在生产中使用的对象,并使用来帮助你进行测试的实现。简而言之,它意味着用一个假的版本替换了一件真实的东西(例如一个类,模块或函数)。假的版本看起来和行为像真实的东西(回答相同的方法调用),但你在单元测试开始时自己定义的预设回应。使用test doubles并不特定于单元测试。更精细的test doubles可用于以受控方式模拟系统的整个部分。然而,在单元测试中,你很可能会遇到很多mock和stubs(取决于你是合作或独立的开发人员),只是因为很多现代语言和库让设置变得简单和舒适。

无论你选择何种技术,很可能语言标准库或一些流行的第三方库将提供优化的安装模拟方法。 甚至从头开始编写你自己的模拟只是写一个假的类/模块/功能与真实的相同的签名,并在测试中设置假的类。

单元测试运行速度非常快。在一台状况良好的机器上,你可以在几分钟内完成数千个单元测试。单独测试小部分代码库,避免链接数据库,文件系统或触发HTTP查询(通过使用这些部分的mock和stub)来保持测试的快速。

一旦掌握了编写单元测试的窍门,你将会越来越流利地编写。

剔除外部协作者,设置一些输入数据,调用测试主题并检查返回的值是否与预期相符。 看看测试驱动开发,让单元测试指导你的开发; 如果正确应用,它可以帮助你进入一个良好的流程,并提出良好的可维护设计,同时自动生成全面的全自动测试套件。尽管如此,这不是银弹。还要继续,尝试一下,看看它是否适合你。

我真的需要测试这种私有方法吗?

如果你发现自己真的需要测试私有方法,那么你应该退后一步,问自己为什么。 我很确定这是一个设计问题,而不是一个范围问题。很可能你觉得需要测试一个私有方法,因为它很复杂,并且通过该类的公共接口来测试这个方法需要很多尴尬的设置。 每当我发现自己处于这种状况时,我通常会得出结论,我正在测试的这个类已经太复杂了。 它做得太多,违反了单一责任原则—SOLID原则中的S。 对我而言,解决方案通常是将原始类分成两个类。 通常只需要一两分钟的思考,就可以找到一种把一个大班级分成两个小班并有个人责任的好办法。 我将私有方法(我迫切想要测试)移动到新类中,并让旧类调用新方法。 Voilà,我难以测试的私有方法现在是公开的,可以很容易地测试。最重要的是,我坚持单一责任原则改进了我的代码结构。

测试什么?

单元测试的好处在于,你可以为所有生产代码类编写单元测试,而不管它们的功能或内部结构属于哪个层。你可以像测试存储库,域类或文件读取器一样单元测试控制器。 只需坚持one test class per production class,你就有了一个良好的开端。

单元测试类应该测试该类的公共接口。

私有方法无法进行测试,因为你无法从不同的测试类中调用它们。 受保护的或私有的包可以从测试类访问(考虑到测试类的包结构与生产类相同),但测试这些方法可能已经太过了。

编写单元测试时有一条细线:它们应该确保测试所有不重要的代码路径(包括开心路径和边缘情况)。同时它们不应该与你的实现过于紧密相关。 为什么会这样?

太接近生产代码的测试很快变得令人讨厌。

只要重构生产代码(快速回顾:重构意味着更改代码的内部结构而不更改外部可见行为),你的单元测试将会中断。

这样你就失去了单元测试的一大好处:充当代码变更的安全网。你宁愿厌倦那些每次重构都会失败的愚蠢测试,这会导致更多的工作而不是帮助;而且其他人会想谁写这个愚蠢的测试?

你该做什么呢?不要在你的单元测试中反映你的内部代码结构,反而测试观察行为。将

如果我如数值 x 和 y, 结果会是 z 吗?

代替为

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

私有方法通常应被视为实施细节。

这就是为什么你甚至不应该有试探他们的冲动。

我经常听到单元测试(或TDD)的反对者认为编写单元测试是毫无意义的工作,因为你必须测试所有的方法才能提高测试覆盖率。

他们经常引用一个情景:过于热心的团队领导迫使他们为getter和setter以及所有其他种类繁琐的代码编写单元测试,以便提供100%的测试覆盖率。 这有太多的错误。

是的,你应该测试公共接口。但更重要的是,你不要测试不重要的代码。 别担心,Kent Beck说没关系。你不会从测试简单的getter或setter或其他不重要的实现(例如没有任何条件逻辑)中获得任何东西。

节省时间,这是你可以参加的又一次会议,万岁!

测试结构

所有测试的良好结构(这不仅限于单元测试)是这样的:

1、设置测试数据

2、在测试中调用你的方法

3、断言预期的结果被返回

记住这种结构有一个很好的助记符:“排列,行动,断言”(Arrange, Act, Assert)。 另一个你可以使用的灵感来自BDD。

它是“给定”(given),“当”(when),“然后”(then)三合一,给出反映了设置,当方法调用,然后断言部分。

这种模式也可以应用于其他更高级别的测试。

在任何情况下,他们都能确保你的测试保持简单和一致的阅读。除此之外,考虑到这种结构的测试往往更短,更具表现力。

专业的测试助手

无论在应用程序体系结构的哪一层,你都可以为整个代码库编写单元测试,这是一件美妙的事情。该示例显示了对控制器的简单单元测试。不幸的是,当谈到Spring的控制器时,这种方法有一个缺点:Spring MVC的控制器大量使用注释来声明他们正在监听哪些路径,使用哪些HTTP动词,他们从URL路径解析哪些参数或者查询参数等等。在单元测试中简单地调用一个控制器的方法将不会测试所有这些关键的事情。幸运的是,Spring的贡献者提出了一个很好的测试助手,可以用它来编写更好的控制器测试。确保检查出MockMVC。它给你一个很好的DSL,你可以使用它来对你的控制器发出假的请求,并检查一切都没问题。我在示例代码库中包含了一个示例。很多框架都提供了测试助手来使测试代码库的某些方面更加愉快。查看你选择的框架的文档,看看它是否为你的自动化测试提供了有用的帮助。

实施单元测试

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

我们来看一个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' youre 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编写单元测试,这是Java事实上的标准测试框架。 我们使用Mockito来替换真正的PersonRepository类和stub以供我们测试。 这个stub允许我们定义在这个测试中存根方法应该返回的罐头响应。

Stub使我们的测试更加简单,可预测,并且使我们能够轻松设置测试数据。

在安排(arrange),行动(act),断言(assert)结构之后,我们编写了两个单元测试 - 一个正面的案例和一个被搜查的人无法找到的案例。

第一个正面的测试用例创建一个新的人物对象,并告诉模拟存储库在用“Pan”作为lastName参数的值调用时返回该对象。

测试然后继续调用应该测试的方法。 最后它断言返回值等于预期的返回值。 第二个测试的工作原理类似,但在场景中,测试方法未找到给定参数的人。

集成测试

所有非平凡的应用程序都将与其他部分(数据库,文件系统,对其他应用程序的网络调用)集成在一起。

在编写单元测试时,这些通常是你为了提供更好的隔离和更快的测试而遗漏的部分。 尽管如此,应用程序仍会与其他部分进行交互,并需要进行测试。集成测试可以帮助你。他们会测试应用程序与应用程序之外的所有部分的集成。

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

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

狭窄的集成测试活在你服务的边界。

从概念上讲,它们始终是触发一种导致与外部部分(文件系统,数据库,单独服务)集成的操作。 数据库集成测试看起来像这样:

Figure 6: A database integration test integrates your code with a real database

1、启动一个数据库

2、将你的应用程序连接到数据库

3、在代码中触发一个将数据写入数据库的函数

4、通过读取数据库中的数据来检查预期数据是否写入了数据库

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

Figure 7: This kind of integration test checks that your application can communicate with a separate service correctly

1、开始你的申请

2、启动单独服务的一个实例(或者具有相同接口的test double)

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

通过CrudRepository接口,Spring Boot通过findOne,findAll,save,update和delete方法提供了一个功能完备的CRUD存储库。

我们的自定义方法定义(findByLastName())扩展了这个基本功能,并为我们提供了一种按姓氏提取PersonS的方法。 Spring Data分析了方法的返回类型及其方法名称,并根据命名约定检查方法名称以找出它应该做什么。

虽然Spring Data负责实现数据库存储库,但我仍然编写了一个数据库集成测试。 你可能会争辩说,这是测试框架和我应该避免的,因为它不是我们正在测试的代码。 不过,我相信至少有一个集成测试是至关重要的。首先它测试我们的自定义findByLastName方法的行为如预期。

其次,它证明我们的存储库正确使用了Spring的接线并可以连接到数据库。 为了让你在机器上运行测试变得容易(无需安装PostgreSQL数据库),我们的测试连接到内存中的H2数据库。

我已经在build.gradle文件中将H2定义为测试依赖项。test目录中的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)));
    }
}

你可以看到,我们的集成测试遵循与单元测试相同的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));
    }
}

要使用Wiremock,我们在固定端口(8089)上实例化一个WireMockRule。 使用DSL可以设置Wiremock服务器,定义它应该监听的端点,并设置它应该响应的灌装响应(canned responses)。

接下来我们调用想要测试的方法,即调用第三方服务的方法,并检查结果是否正确解析。

了解测试如何知道应该调用虚拟的Wiremock服务器而不是真正的darksky API非常重要。 秘密在我们包含在src / test / resources中的application.properties文件中。这是运行测试时Spring加载的属性文件。在这个文件中,我们覆盖了像API键和URLs这样的配置,其值适合我们的测试目的,例如调用虚拟的Wiremock服务器而不是真正的服务器:

weather.url = http://localhost:8089

请注意,这里定义的端口必须与我们在测试中实例化WireMockRule时所定义的端口相同。 通过在我们的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等工具为单独服务编写narrow integration tests非常简单。 不幸的是,这种方法有一个缺点:我们如何确保我们设置的假服务器的行为像真正的服务器?

在目前的实施中,单独的服务可能会改变其API,我们的测试仍然会通过。 现在我们只是测试我们的WeatherClient可以解析假服务器发送的响应。 这是一个开始,但非常脆弱。

使用端到端测试并针对真实服务的测试实例运行测试而不是使用假服务可以解决此问题,但会使我们依赖于测试服务的可用性。

幸运的是,有一个更好的解决方案来解决这个困境:对虚假服务器和真实服务器运行合同测试可确保我们在集成测试中使用的虚假测试是忠实的测试。 让我们看看接下来的工作。

合同测试

更多的现代软件开发组织已经找到了通过跨不同团队开发系统来扩展其开发工作的方法。 个别团队建立个别的,松散耦合的服务,而不用彼此踩脚趾,并将这些服务整合到一个大的,有凝聚力的系统中。

最近围绕微服务的讨论正是关注这一点。

将系统分割成许多小型服务常常意味着这些服务需要通过某些(希望定义明确的,有时意外增长的)接口相互通信。

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

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

对于每个接口,涉及两方:提供者和消费者。 该提供商向消费者提供数据。 消费者处理从提供者处获得的数据。

在REST世界中,提供者使用所有必需的端点构建REST API;

消费者调用此REST API来获取数据或触发其他服务中的更改。

在异步的,事件驱动的世界中,提供者(通常称为发布者)将数据发布到队列中; 消费者(通常称为订户)订阅这些队列并读取和处理数据。

Figure 8: Each interface has a providing(or publishing) and a consuming(or subscribing) party. The specification of an interface can be considered a contract.

由于你经常在不同团队之间传播消费和提供服务,你会发现自己处于必须明确指定这些服务之间的接口(所谓的合同)的情况。 传统上,公司通过以下方式来解决这个问题:

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

更现代化的软件开发团队用更自动化的东西取代了第5步和第6步:自动契约测试确保消费者和提供者方面的实现仍然坚持已定义的合同。他们作为一个很好的回归测试套件,并确保早期发现与合同的偏差。

在一个更敏捷的组织中,你应该采取更有效和浪费更少的路线。你在同一个组织内构建您的应用程序。直接与其他服务的开发人员直接交谈,而不是摒弃过于详细的文档,这不应该太难。毕竟他们是你的同事,而不是第三方供应商,你只能通过客户支持或法律上的防弹合同进行交谈。

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

Figure 9: 合同测试确保接口的提供者和所有消费者都坚持已定义的接口契约。 通过CDC测试,接口的消费者以自动化测试的形式发布他们的需求;提供者不断地获取并执行这些测试 这种方法允许提供团队只实施真正必要的事情(保持简单,YAGNI(You ain’t gonna need it)等等)。

提供界面的团队应持续(在他们的构建流水线中)获取并运行这些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测试对于能够快速移动而不会破坏其他服务并对其他团队造成很大的挫折,这个测试是无价的。

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

我们的微服务使用天气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非常相似。这次我们不使用Wiremock作为服务器stub,而是使用Pact。事实上,消费者测试与集成测试完全一样,我们用一个stub替换真正的第三方服务器,定义期望的响应并检查我们的客户端是否可以正确解析响应。在这个意义上,WeatherClientConsumerTest本身就是一个小范围的集成测试。与基于线连接的测试相比,这种测试的优点是每次运行时都会生成一个pact文件(在target / pacts /&pact-name>.json中找到)。该协议文件以特殊的JSON格式描述了我们对合同的期望。然后可以使用此协议文件来验证我们的存根服务器的行为与真实服务器的行为相同。我们可以将协议文件交给提供界面的团队。他们拿这个协议文件,并使用在那里定义的期望写一个提供者测试。这样他们测试他们的API是否满足我们所有的期望。

你会发现这是CDC消费者驱动部分的来源。

消费者通过描述他们的期望来推动接口的实现。提供者必须确保他们能够满足所有的期望,并且他们完成了。 没有镀金,没有YAGNI和东西。 将协议文件提供给提供团队可以通过多种方式进行。一个简单的方法是将它们放入版本控制并告诉提供者团队总是获取最新版本的协议文件。更多的进步是使用工件存储库,像亚马逊S3或协议代理的服务。

开始简单并根据需要增长。

在你的真实世界的应用程序中,你不需要两者,一个集成测试和一个客户端类的消费者测试。示例代码库包含两个向你展示如何使用任何一个。如果你想使用pact编写CDC测试,我建议坚持使用后者。编写测试的效果是一样的。使用pact的好处是,您可以自动获得一份pact文件,其中包含对其他团队可以轻松实施其供应商测试的合同期望。当然,如果你能说服其他团队也使用pact,这是唯一有意义的。如果这不起作用,使用集成测试和Wiremock组合是一个体面的计划b。

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

提供者测试必须由提供天气API的人员执行。我们正在使用dark sky.net提供的公共API。理论上,darksky team 将在他们的最后实施提供商测试,以检查他们是否违反了他们的应用程序和我们的服务之间的合同。

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

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

Pact伙伴已经编写了几个库来执行提供者测试。他们的主要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"));
    }
}

你会看到所有提供程序测试必须执行的操作是加载一个pact文件(例如,通过使用@PactFolder注释来加载以前下载的协议文件),然后定义应如何提供预定义状态的测试数据(例如,使用Mockito mocks)。没有定制测试可以被实施。这些都来自pact文件。Provider test 与消费者测试中声明的provider name状态匹配的对应对象是非常重要的。

Provider Test(our team)

我们已经看到如何测试我们的服务和天气提供商之间的合同。有了这个接口我们的服务作为消费者,天气服务就像提供者一样。进一步思考会看到,我们的服务还充当其他人的提供者:提供了一个REST API,它准备好供其他人使用的端点。

正如刚刚了解的那样,合同测试非常激烈,我们当然也会为这份合同写一份合同测试。 幸运的是,正在使用消费者驱动契约(consumer-driven contracts),因此所有消费团队都向我们发送他们的Pacts,我们可以使用它们来为我们的REST API实现提供者测试。

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

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

实现提供者测试的方式与之前描述的相同。为了简单起见,我将我们的简单消费者的pact文件输入到我们服务的存储库中。这使得目的更容易,在真实场景中,你可能会使用更复杂的机制来分发你的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 Tests

大多数应用程序都有某种用户界面。

通常,我们正在讨论Web应用程序环境中的Web界面。人们经常忘记REST API或命令行界面与花哨的Web用户界面一样多的用户界面。

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

UI测试和端到端测试有时(如Mike Cohn的案例)被认为是一回事。

对我来说,这是两个相互正交的概念。

是的,端到端测试你的应用程序通常意味着通过用户界面来驱动您的测试。

然而,反过来却是不正确的。

测试你的用户界面不一定要以端到端的方式进行。

根据你使用的技术,测试用户界面可能非常简单,只需为后端JavaScript代码编写一些单元测试并将其后端代码删除即可。

使用传统的Web应用程序测试用户界面可以使用像Selenium这样的工具来实现。如果你认为REST API是你的用户界面,应该通过围绕API编写适当的集成测试来获得所需的一切。

有了Web界面,可能需要在UI中测试多个方面:行为,布局,可用性,很少对公司设计的测试。

幸运的是,测试用户界面的行为非常简单。你点击这里,在那里输入数据,并希望用户界面的状态相应地改变。现代的单页面应用程序框架(react,vue.js,Angular等)通常带有自己的工具和helpers,它们允许您以相当低级的(单元测试)方式彻底测试这些交互。即使你使用vanilla javascript来实现自己的前端实现,你也可以使用常规的测试工具,如Jasmine或Mocha。使用更传统的服务器端渲染应用程序,基于Selenium的测试将是你的最佳选择。

测试你的web应用程序的布局是否保持完好,有点困难。根据你的应用程序和你的用户需求,可能需要确保代码更改不会意外地破坏网站的布局。 问题在于,计算机在检查某些“看起来不错”(可能是一些聪明的机器学习算法可能在将来改变)方面是非常糟糕的。

如果你想要在构建管道中自动检查Web应用程序的设计,有一些工具可供尝试。这些工具中的大多数都利用Selenium以不同的浏览器和格式打开您的Web应用程序,截取屏幕截图并将它们与以前拍摄的截图进行比较。如果新旧截图以意想不到的方式出现差异,该工具会通知您。

Galen是这些工具之一。

但是,如果您有特殊要求,即使推出自己的解决方案也不难。和我有合作的一些团队已经建立了阵容和基于Java的表哥jimupup来实现类似的功能。两种工具都采用了我之前描述的基于Selenium的方法。

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

端到端测试

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

当你需要确定软件是否正常工作时,端到端测试(也称为广泛堆栈测试)为你提供最大的信心。通过Selenium和WebDriver协议,你可以通过自动驱动(无头)浏览器针对部署的服务,执行点击操作,输入数据并检查用户界面的状态来自动执行测试。您可以直接使用Selenium或使用基于它的工具,Nightwatch就是其中之一。

端到端测试带来了各自的问题。

它们是出了名的碎片,往往因意外和不可预见的原因而失败。他们的失败往往是一种误解。你的用户界面越复杂,测试越碎片。浏览器的怪癖,计时问题,动画和意外的弹出对话框只是让我花更多时间进行调试的一些原因,而不是我想承认的。

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

如果你有一个集中的质量保证团队,他们看起来很合适。

然而,拥有一个集中的质量保证团队是一个很大的反模式,在DevOps世界里不应该有一席之地,你的团队是真正意义上的跨职能团队。谁应该拥有端到端的测试并不容易。也许你的组织有一个实践社区或一个可以照顾这些的高质量公会。找到正确的答案在很大程度上取决于你的组织。

此外,端到端测试需要大量维护,运行速度非常缓慢。

考虑到不止一两个微服务的格局,你甚至无法在本地运行端到端测试-因为这需要在本地启动所有微服务。在你的开发机器上启动了数百个应用程序,而不会炸毁你的RAM。

由于维护成本高昂,应该尽量减少端到端测试的数量。

考虑用户在应用程序中使用的高价值交互。

尝试提出定义产品核心价值的用户旅程,并将这些用户旅程中最重要的步骤转化为自动化的端到端测试。

如果你正在建立一个电子商务网站,你最有价值的客户旅程可能是一个用户搜索产品,将其放入购物篮并结帐。仅此而已。

只要这个旅程仍然有效,不应该太麻烦。

也许你会发现一两个更重要的用户旅程,可以将其转化为端到端测试。

除此之外,更多的事情都可能更痛苦。

请记住:你的测试金字塔中有很多较低的级别,已经测试了各种边界案例并与系统的其他部分进行了集成。 没有必要在更高层次上重复这些测试。 高昂的维护工作量和大量的误报会减慢你的速度,会让你在测试中失去信心,宜早不宜迟。

User Interface End-to-End Test

对于端到端测试,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-Server可用)。

您可以通过启动像xvfb这样的虚拟X-Server来解决此问题。

最近的方法是使用无头浏览器(即没有用户界面的浏览器)来运行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浏览器驱动程序,告诉它导航到我们的微服务的/ hello端点,并检查它是否在浏览器窗口中打印出“Hello World!”。这是很酷的东西!

REST API End-to-End Test

在测试应用程序时避免使用图形用户界面是一个好主意,它可以提供比较完整的端到端测试,同时仍涵盖应用程序堆栈的大部分内容。当通过应用程序的Web界面进行测试特别困难时,这可以派上用场。 也许你甚至没有一个Web UI,而是提供一个REST API来代替(因为你有一个单独的页面应用程序在某个地方与该API交谈,或者仅仅是因为你鄙视一切都很好)。 无论哪种方式,一个Subcutaneous Test,只是在图形用户界面下进行测试,并且可以让你真正走远,而不会对信心造成太大损失。 如果你像我们的示例代码那样提供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向我们的朋友“潘先生”说“打招呼”时,我们会得到一个很好的问候。 非常好! 如果你甚至没有运行网络界面,那么就可以进行足够多的端到端测试。

Acceptance Tests—Do Your Features Work Correctly?

在测试金字塔中移动得越高,进入测试领域的可能性就越大,从用户的角度来看,你构建的功能是否正常工作。

可以将你的应用程序视为黑盒子,并将测试中的焦点从下面中移除

当我输入值x和y时,返回值应该是z

而是用

因为有一个登录用户(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风格的编写测试方式可能是一个不错的窍门,可将你的思想从实施细节转移到用户需求。 继续尝试吧。

你甚至不需要像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)

验收测试可以有不同的粒度级别。

大多数时候他们将会相当高级并通过用户界面测试您的服务。然而,理解在技术上不需要在测试金字塔的最高级别编写验收测试是很好的。

如果你的应用程序设计和手头的场景允许您在较低的级别上编写验收测试,那就去做吧。 进行低级测试比进行高级测试要好。 验收测试的概念 - 证明功能为用户正确地工作 - 完全与测试金字塔正交。

Exploratory Testing

即使是最用功的自动化测试也不完美。 有时候你会错过自动化测试中的某些边缘情况。 有时通过编写单元测试来检测特定的错误几乎是不可能的。 某些质量问题在您的自动化测试中甚至不明显(考虑设计或可用性)。 尽管你对测试自动化有着最好的意图,但某些类型的手动测试仍然是一个好主意。

Figure 12: Use exploratory testing to spot all quality issues that your build pipeline didn’t spot

在测试组合中包含探索性测试。 这是一种手动测试方法,强调测试人员的自由和创造力,以便在运行中的系统中发现质量问题。

只需定期安排一些时间,卷起袖子并尝试破坏应用程序。

使用破坏性的思维方式,想出办法在应用程序中引发问题和错误。 记录您以后找到的所有内容。

注意错误,设计问题,响应时间缓慢,丢失或误导性的错误信息以及其他一切会让你作为软件用户烦恼的事情。

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

在探索性测试过程中,你会发现通过你的构建管道未被注意到的问题。不要感到沮丧。 这对您的构建管道的成熟度有很好的反馈。

与任何反馈一样,请务必采取行动:考虑你将来可以采取什么措施来避免这些问题。也许你错过了一些自动化测试。

也许在这次迭代中对自动化测试嗤之以鼻,并且需要在将来进行更彻底的测试。 也许有一种闪亮的新工具或方法可以用来避免将来出现这些问题。

请务必采取行动,以便管道和整个软件交付将走得更远变得更加成熟。

关于Testing Terminology的结论

谈论不同的测试分类总是很困难。

当我谈论单元测试时,我的意思可能与你的理解稍有不同。

如果是集成测试,情况更糟。

对于某些人来说,集成测试是一项非常广泛的活动,可以测试整个系统的许多不同部分。 对我而言,这是一个相当狭隘的东西,一次只测试一个外部部件的集成。 一些人称他们为集成测试,一些人称他们为组件测试,一些人更喜欢术语服务测试。

甚至其他人也会争辩说,所有这三个术语都是完全不同的东西。 没有对错。

软件开发社区根本没有设法围绕测试定义明确的术语。

不要太拘泥于模棱两可的话。

如果您称之为端到端或广泛的堆栈测试或功能测试,则无关紧要。

如果你的集成测试对你来说意味着与其他公司的人不同,那就没关系了。 是的,如果我们的专业能够按照一些明确定义的条件解决并且全部坚持下去,那将会非常好。 不幸的是,这还没有发生。

而且,由于在编写测试时有很多细微差别,反而比一堆离散的存储桶更像一个频谱,这使得一致的命名更加困难。

重要的是,你应该找到适合你和你的团队的条款。清楚你想写的不同类型的测试。 就团队中的命名达成一致,并就每种类型的测试范围达成共识。 如果你在团队内部(或者甚至在你的组织内)获得这种一致性,那么你应该关心的就是这些。 当Simon Stewart描述他们在Google使用的方法时,Simon Stewart总结得非常好。

而且我认为这完全表明,让名字和命名惯例过于沉闷是不值得的麻烦。

Putting Tests Into Your Depolyment Pipeline

如果你使用的是持续集成或持续交付,那么将拥有一个部署管道,每次对软件进行更改时都会运行自动化测试。

通常这个管道分成几个阶段,逐渐让你更加确信软件已准备好部署到生产环境。 听到所有这些不同类型的测试,你可能想知道如何将它们放置在部署管道中。 要回答这个问题,应该考虑持续交付(实际上是极限编程和敏捷软件开发的核心价值之一)的基本价值之一:快速反馈。 一个好的构建管道告诉你,尽可能快地搞砸。你不想等一个小时才能发现你的最新代码更改破坏了一些简单的单元测试。如果你的管道需要很长时间才能给反馈,那么你很可能已经回家了。通过快速运行的测试放在流水线的早期阶段,可以在几秒钟内获得这些信息,也可能需要几分钟。相反,在较晚的阶段,较长时间的运行测试(通常是较宽范围的测试)放在不会推迟快速运行测试的反馈。你会发现定义部署管道的阶段不是由测试类型驱动的,而是由速度和范围决定的。考虑到这一点它可以是一个非常合理的决定,把一些真正的狭义范围的和快速运行的集成测试在同一个舞台上你的单元测试 - 仅仅是因为他们给你更快的反馈,而不是因为你想画沿着你的测试的正式类型。

Avoid Tes tDuplication

现在你知道你应该写出不同类型的测试,但还有一个可以避免的错误:在金字塔的不同层次上重复测试。

虽然你的直觉可能会说不需要太多的测试。我向你保证,需要。

测试套件中的每一项测试都需要额外的时间,并不是免费的。

编写和维护测试需要时间。 阅读和理解其他人的测试需要时间。

当然,运行测试需要时间。

与生产代码一样,应该尽量简化并避免重复。

在实施你的测试金字塔的背景下,你应该记住两条经验法则:

1、如果较高级别的测试发现错误,并且没有较低级别的测试失败,则需要编写较低级别的测试

2、尽可能将测试推到测试金字塔的尽头。

第一条规则很重要,因为较低级别的测试可以让你更好地缩小错误并以独立方式复制错误。 当调试手头的问题时,它们会运行得更快,并且不会臃肿。 它们将成为未来良好的回归测试。

第二条规则对于快速保持测试套件非常重要。

如果你已经在较低级别的测试中自信地测试了所有条件,则不需要在测试套件中保留更高级别的测试。 它只是没有增加更多的信心。

多余的测试会在日常工作中变得烦人。

你的测试套件会变慢,当你改变代码的行为时你需要改变更多的测试。

让我们以不同的方式来表述:如果更高级别的测试让你更加确信应用程序正常工作,那么你应该拥有它。

为Controller类编写单元测试有助于测试Controller本身的逻辑。

不过,这并不能告诉这个Controller提供的REST端点是否实际响应HTTP请求。 所以,移动测试金字塔并添加一个测试来检查确切的 - 但没有更多。

你不要测试低级测试已经在高级测试中覆盖的所有条件逻辑和边界情况。

确保较高级别的测试侧重于较低级别测试无法覆盖的部分。

当涉及到不提供任何价值的测试时,我非常严格。

我删除了较低级别的高级测试(因为它们不提供额外的值)。

如果可能的话,我用较低级别的测试替换更高级别的测试。

有时候这很难,特别是如果你知道提出一个测试是艰苦的工作。

谨防沉没成本谬误并敲击删除键。

没有理由浪费更多宝贵的时间在不再提供价值的测试上。

Writing Clean Test Code

就像一般编写代码一样,写出良好和干净的测试代码需要非常细心。

在继续之前提出可维护的测试代码以及在自动化测试套件中破解,以下是一些更多提示:

1、测试代码与生产代码一样重要。 给它同样的关注。 “这只是测试代码”不是证明草率代码合理的理由 2、每个测试测试一个条件。 这可以帮助你保持测试简短并且容易推理 3、“安排,采取行动,断言”或“当时,那么”是很好的助记符,可以让你的测试保持良好的结构 4、可读性很重要。 不要试图过度DRY(Don’t repeat yourself)。 复制是可以的,如果它提高可读性的话。 尝试在DRY和DAMP代码之间找到平衡点 5、如果有疑问,请使用三条规则来决定何时重构。 重用之前使用

结论

就这样!我知道这是一个漫长而艰难的阅读,解释为什么以及如何测试你的软件。 好消息是,这些信息是持久有用的,并且无论你正在构建什么样样的软件。 无论您是从事微服务领域,物联网设备,移动应用程序还是Web应用程序,这些文章的教训都可以应用到所有这些领域。

我希望在这篇文章中有一些有用的东西。

现在继续查看示例代码,并将这里介绍的一些概念加入到您的测试组合中。 有一个坚实的测试组合需要一些努力。

它将会在更长的时间内得到回报,并且会让你的开发者更加安宁,相信我。

Acknowledgements

Thanks to Clare Sudbery, Chris Ford, Martha Rohte, Andrew Jones-Weiss David Swallow, Aiko Klostermann, Bastian Stein, Sebastian Roidl and Birgitta Böckeler for providing feedback and suggestions to early drafts of this article. Thanks to Martin Fowler for his advice, insights and support.