修复测试沙漏

| 2022-03-27

自动化测试使创建新功能、修复 bug 和重构代码变得更安全、更快。在计划做自动化测试时,我们设想了一个金字塔,它具有小单元测试的强大基础,一些精心设计的集成测试,以及一些大型的端到端测试。根据博文「向更多的端到端测试说:“NO!”」, 测试应该执行快速,可靠,且具体化;然而,端到端测试通常却是慢的,而且不可靠,并且出错了很难调试。

随着软件项目的增长,我们的测试分布的形状通常会变得不受欢迎,要么像「冰淇淋蛋筒」一样头重脚轻(没有单元或中等集成测试),要么像「沙漏」一样,两头重,中间轻(没有中等规模的集成测试)。

沙漏测试是指:有大量的单元测试和大量的端到端测试,以及很少或没有中等集成测试。

要将「沙漏」转换回「金字塔」,以便你能以可靠且可持续的方式测试所有组件的集成,您需要弄清楚如何构建被测系统和测试基础设施,并改进系统可测试性和测试代码。

我参与了一个有web UI、服务器和许多后端的项目。各个级别都有单元测试,覆盖范围很广,端到端测试的数量迅速增加。

端到端测试发现了单元测试遗漏的问题,但运行缓慢,环境问题导致了虚假故障,包括测试数据的损坏。此外,一些功能领域也很难测试,因为它们覆盖的范围超过了单元,但需要在系统内设置一些难以设置的状态。

我们最终找到了一个很好的测试体系结构,可以进行更快、更可靠的集成测试,但同时也犯过一些错误。

一个使用 protractor编写的 UI层端到端测试用八角形,如下所示:

describe('Terms of service are handled', () => {
  it('accepts terms of service', async () => {
    const user = getUser('termsNotAccepted');
    await login(user);
    await see(termsOfServiceDialog());
    await click('Accept')
    await logoff();
    await login(user);
    await not.see(termsOfServiceDialog());
  });
});

此测试用例是以一个用户的身份登录,查看该用户需要接受的服务条款对话框,接受它,然后注销并重新登录,以确保用户不会再次收到提示。

这个对服务条款的测试用例对于可靠运行是一个挑战,因为一旦协议被接受,后端服务器就没有RPC方法来反转操作,并“取消接受”TOS。

我们可以在每次测试中创建一个新用户,但这既耗时又难以清理测试数据。

要让这个「服务条款功能」在没有端到端测试的情况下变得可测试,第一个尝试是挂接服务器 RPC 方法,并在测试中设置期望值。用钩子拦截 RPC 调用并提供预期结果,而不是调用后端API。

This approach worked. The test interacted with the backend RPC without really calling it, but it cluttered the test with extra logic.

这种方法奏效了。测试用例与后端 RPC 进行了交互,但没有真正调用它。可是,它的一些额外逻辑为测试用例带一些混乱。

describe('Terms of service are handled', () => {
  it('accepts terms of service', async () => {
    const user = getUser('someUser');
    await hook('TermsOfService.Get()', true);
    await login(user);
    await see(termsOfServiceDialog());
    await click('Accept')
    await logoff();
    await hook('TermsOfService.Get()', false);
    await login(user);
    await not.see(termsOfServiceDialog());
  });
});

这个测试用例达到了测试 web UI 和服务器之间进行集成的目标,但不可靠。当系统在负载下扩展时,有多个服务器进程,并且不能保证 UI 会为所有 RPC 调用访问同一个服务器,因此可能会发生下面的状态,即:在一个服务器进程中设置钩子,而在另一个服务器进程中访问 UI。

这个钩子并也不在一个自然的系统边界上,这使得随着系统的发展和代码的重构,这个钩子需要投入更多的维护。

测试架构的下一个设计方案是伪造一个最终处理服务条款调用的后端。

这个伪实现非常简单:

public class FakeTermsOfService implements TermsOfService.Service {
  private static final Map<String, Boolean> accepted = new ConcurrentHashMap<>();

  @Override
  public TosGetResponse get(TosGetRequest req) {
    return accepted.getOrDefault(req.UserID(), Boolean.FALSE);
  }

  @Override
  public void accept(TosAcceptRequest req) {
    accepted.put(req.UserID(), Boolean.TRUE);
  }
}

并且,在测试用例中,测试的目标预期与测试本身并没有缠绕在一起:

describe('Terms of service are handled', () => {
  it('accepts terms of service', async () => {
    const user = getUser('termsNotAccepted');
    await login(user);
    await see(termsOfServiceDialog());
    await click('Accept')
    await logoff();
    await login(user);
    await not.see(termsOfServiceDialog());
  });
});

因为这个伪代码将接受的状态存储在内存中,所以无须为下一次测试迭代重置状态;只要重新启动这个伪服务器就足够了。

这是可行的,但是,当存在伪后端和真后端的混合体时,就可能会有问题了。因为真实后端之间的状态现在与伪后端不同步。

我们最终的一个比较成功的集成测试架构是:为除了一个后端之外的所有后端提供假实现,所有后端共享相同的内存状态。测试系统中只包含了一个真只的后端,因为它与 Web UI 紧密耦合。而它的依赖都连接到伪后端。这些是对整个被测系统的集成测试,但它们删除了后端依赖项。这种测试用例扩展了测试沙漏中的中等大小测试,允许我们使用真实后端进行更少的端到端测试。

请注意,这些集成测试并不是唯一的选项。对于 Web UI 中的逻辑,我们可以编写页面级的单元测试,这样可以让测试运行得更快、更可靠。然而,对于服务条款特性,我们希望同时测试 Web UI 和服务器逻辑,因此集成测试是一个很好的解决方案。

这使得未经修改的 UI 测试用例即可以在真实的后端系统上运行,也可以在伪后端系统上运行。

当使用伪后端运行时,测试更快、更可靠。这使得添加测试场景变得更容易,而使用真正的后端服务进行设置会更具挑战性。我们还删除了那些已被集成测试很好地覆盖的重复性端到端测试用例,从而,集成测试数据会多于端到端测试。

通过迭代,我们为集成测试找到了一个可持续的测试体系结构。

如果你面对的是一个测试沙漏,那么设计中型测试用例的测试架构可能并不显而易见。我建议进行试验,在定义良好的接口上划分系统,并通过运行更快、更可靠或解锁难以测试的区域来确保新测试用例提供价值。

参考


原文作者:Alan Myrvold
原文链接:Fixing a Test Hourglass
发表时间:November 09, 2020