第03课:怎么针对微服务架构做单元测试?

第03课:怎么针对微服务架构做单元测试?

单元测试是开发人员编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。通常而言,一个单元测试是用于判断某个特定条件(或者场景)下某个特定函数的行为。例如,你可能把一个很大的值放入一个有序 list 中去,然后确认该值出现在 list 的尾部。或者,你可能会从字符串中删除匹配某种模式的字符,然后确认字符串确实不再包含这些字符了。

对于单元测试中单元的含义,一般来说,要根据实际情况去判定其具体含义,如 C 语言中单元指一个函数,Java 里单元指一个类,前端应用中可以指一个窗口或一个菜单等。总的来说,单元就是人为规定的最小的被测功能模块。

这节课,我们将探讨在微服务架构下,单元测试的设计、实现和质量控制。

设计:定义测试边界

要设计高效率(既运行快速又覆盖率高)的单元测试,首要要准确地定义测试边界。测试的目的就是为了验证边界里“黑盒”的行为是否符合预期,我们向黑盒输入数据,然后验证输出的正确性。在单元测试里,黑盒指的是函数或者类的方法,目的是单独测试特定代码块的行为。

但是在微服务架构中,很多时候黑盒的输出需要依赖于其他的功能或者服务,即存在外部依赖。为了更好地理解这个概念,我们以一个简单的注册功能为例:

image

从图中可以看出,这个函数包含了一些输入和输出。输入参数包括基本的用户注册信息(姓名、用户名和密码),而返回新创建的用户 ID。

但是在此过程中,还有一些不是很明显的输入数据。这个函数调用了两个外部函数:db.user.inser() 是向数据库插入数据;Password.hashAndsave() 是一个微服务,用于生成密码的哈希值,再加以保存。在某些情况下,数据库可能会返回错误,比如用户名已经存在,导致数据库插入失败。另外,因为需要调用外部的微服务生成密码哈希值,如果网络连接出现问题,或者哈希值生成服务由于发生过载而导致服务超时,那么密码保存就会返回错误。User.create() 函数必须能够妥善地处理这两种错误,这是测试的重点。

也就是说,为了全面地测试用户注册功能,单元测试所要做的不仅仅是简单地输入各种不同的参数,它还要能够让外部函数/微服务,能够产生出指定的错误,再验证函数的错误处理逻辑是否符合预期。

因此,为了在不依赖于外部条件的情况下制造出各种输入数据,就需要使用 Stub 或者 Mock,中文可以理解为对函数外部依赖的模拟器。简而言之,它意味着用一个假的版本替换了真实的对象(例如一个类、模块、函数或者微服务)。假的版本的行为特征和真实对象非常类似,采用相同的调用方法,并按照你在测试开始之前预定义的返回方式,提供返回数据。测试框架在运行被测试的函数时,可以把对外部依赖函数/服务的调用,重定向到 Stub 上,这样单元测试就可以在没有外部服务的情况下进行,即保证了速度,又避免了网络条件的影响。

这里再强调下 Stub 和 Mock 的区别,很多人经常搞混。Stub 就是一个纯粹的模拟器,用于替代真实的服务/函数,收到请求返回指定结果,不会记录任何信息。Mock 则更进一步,还会记录调用行为,可以根据行为来验证系统的正确性。

创建 Stub 的工具有很多,包括 Node.js/JavaScript 框架下的 sinon.js, testdouble.js 等;Python 下的 mock 等。

在刚刚提到的注册函数和密码哈希值生成、保存服务之间,插入一个 Stub(模拟器)的示意图如下:

image

我们可以使用模拟器来达到各种目的:

  • 模拟器可返回任意的设定值,用于模拟外部函数的输出。这在测试罕见的边界情况时会非常有用,比如有些错误场景可能很少发生或者非常难以重现。
  • 模拟器也可以捕捉被测试函数传给外部函数的参数,或者把这些参数记录下来。这样就可以验证被测试函数需要调用哪些外部函数,以及需要传给外部函数哪些参数。

通过对外部依赖函数使用模拟器,通常可以在几秒钟内,执行数千个单元测试。这样,开发人员就可以把单元测试加入到日常的开发工作管线(Pipeline)当中,包括直接集成到常用的 IDE 里,或者通过终端命令行触发。通过在编写代码的同时,频繁运行单元测试,有助于尽早发现代码中的问题。对于程序员来说,如果养成了对自己写的代码进行单元测试的习惯,不但可以写出高质量的代码,而且还能提高编程水平。

顺便说一句,在微服务架构中,单元测试的作用不仅限于代码开发,它们还对 DevOps/CI(持续集成)有很大的帮助,可以集成到代码合并(Merge)流程里。

譬如,GitHub 支持对一些主流 CI 服务的状态检查。一般它会限制对“Master”主分支的提交权限,不允许开发人员直接向该分支提交代码,而是要求他们把代码先提交到其他分支上(提交 Pull Request),再由其他开发人员进行代码审查(Code Review)。最后,在将代码合并到主分支的时候,GitHub 要求先通过状态检查。这时,Jenkins、CircleCI 和 TravisCI 等 CI 服务都提供了状态检查钩子(hook),它们会从分支上获取代码并运行单元测试。如果通过了,就允许合并代码,否则就不允许。整个过程如下图所示:

image

实现:单元测试的流程

单元测试的工具有很多,例如:

  • C++:Googletest、GMock
  • Java:Junit、TestNG、Mockito、PowerMock
  • JavaScript:Qunit、Jasmine
  • Python:unittest
  • Lua:luaunit

一个单元测试的实现主要分为以下几步:

  1. 设置测试数据;
  2. 在测试中调用你的方法;
  3. 判断返回的结果是否符合预期。

这三步可以简化为“三 A 原则”: Arrange(设置)、Act (调用)、Assert(检查)。

或者也可以借用 BDD(行为驱动测试)的概念,把单元测试的流程分为三步:Given(上下文)、When (事件)、Then(结果)。

下面我们来看一个真实的例子,这是一个名为 ExampleController 的类,用于在人名库(PersonRepository)中查找人名。

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

下面,我们将用 Junit,对类中的 hello(lastname)方法进行单元测试。

JUnit 是 Java 社区中知名度最高的单元测试工具,用于编写和运行可重复的测试用例。JUnit 设计得非常小巧,但是功能却非常强大。它诞生于 1997 年,由 Erich Gamma 和 Kent Beck 共同开发完成。其中 Erich Gamma 是经典著作《设计模式:可复用面向对象软件的基础》一书的作者之一,并在 Eclipse 中有很大的贡献;Kent Beck 则是一位极限编程(XP)方面的专家和先驱。

public class ExampleControllerTest {

    private ExampleController subject;

    @Mock
    // 模拟器
    private PersonRepository personRepo;

    @Before
    // 在每个测试方法之前执行
    public void setUp() throws Exception {
        initMocks(this);
        subject = new ExampleController(personRepo);
    }

    @Test
    // 测试用例1
    public void shouldReturnFullNameOfAPerson() throws Exception {
        Person peter = new Person("东", "王");
        given(personRepo.findByLastName("王"))
            .willReturn(Optional.of(东));

        String greeting = subject.hello("王");

        assertThat(greeting, is("你好王东!"));
    }

    @Test
    // 测试用例2
    public void shouldTellIfPersonIsUnknown() throws Exception {
        given(personRepo.findByLastName(anyString()))
            .willReturn(Optional.empty());

        String greeting = subject.hello("王");

        assertThat(greeting, is("这位王先生是谁?"));
    }
}

Arrange(设置)、Act (调用)、Assert(检查)。

可以看到,首先我们用一个 Stub(模拟器),替换真正的 PersonRepository 类,这样我们可以预先定义我们希望返回的值。

记下来,我们按照 3A 原则,编写了两个单元测试。第一个是正常运行的用例:

  1. Arrange(设置):建立一个名为王东的人物,并且让模拟器准备好,在输入参数为王时,返回“王东”。
  2. Act(调用):调用函数 hello("王")。
  3. Assert(检查):检查返回结果是否为"你好王东!"。

第二是异常运行的测试用例:

  1. Arrange(设置):让模拟器准备好,在输入任何参数时,均返回空值。
  2. Act(调用):调用函数 hello("王")。
  3. Assert(检查):因为模拟器返回的是空值,这是检查返回结果是否为"这位王先生是谁?"

通过这样的正面和反面的测试用例,我们可以彻底地检查 hello(lastname) 方法是否工作正常。

质量控制:监控测试覆盖率

着重需要提及的一点是,测试人员应当设法将单元测试的覆盖率作为一个重要的监控指标,记录并可视化。例如,Teamcity 或者 Jenkins 这样的流程化工具,支持用 dotCover 来统计流程中单元测试的覆盖率,并将结果以 TXT 报告或者 HTML 的方式显示在任务页面上。进一步也可以将覆盖率、测试结果的数据,自动输出到 SonarQube 这样的代码质量监控工具之中,以便随时检查出测试没有通过或者测试覆盖率不符合预期的情况。

enter image description here

高覆盖率的单元测试是保障代码质量的第一道也是最重要的关口。从分工上来说,测试人员可能不会参与单元测试的开发与维护,但是测试人员应当协助开发人员确保单元测试的部署和覆盖率,这是确保后续一系列测试手段发挥作用的前提。

本课总结

简单总结一下本课程所学习的内容:

  1. 用模拟器来定义单元测试的边界,模拟对外界函数/服务的调用;
  2. 依照三 A 原则,实现单元测试;
  3. 使用流程化工具,实时监控单元测试的覆盖率。

下一课中,我们将重点介绍单元测试的下一个层级——集成测试。

上一篇
下一篇
目录