在 Stack Overflow 成立之初,我们只是一个快速、精简运行的网站。Stackoverflow.com 是由开发人员为开发人员创建的小型初创公司。像所有初创公司一样,我们优先考虑对我们来说最重要的质量属性,而忽视了许多其他属性,包括根据最佳实践进行单元测试。网站是为开发人员而建的,我们发现很多用户都很乐意报告错误,并在我们修复错误的同时解决它们。
几年前,我们推出了Stack Overflow for Teams Enterprise,突然拥有了一个大公司都在使用的付费产品。与社区网站用户不同,他们不想在生产中发现错误。我们有集成测试套件,但测试基础架构,尤其是单元测试,远远落后于产品的成熟度。
我们正在努力改变这种状况。端到端测试和集成测试很好,也是平衡测试计划的一部分,但它们可能会很慢。如果你希望实现测试驱动开发(我们就是这样做的),并快速测试新功能,那么就应该编写单元测试。
本文将介绍我们在加强单元测试计划方面所做的工作。
测试类型复习
在深入探讨如何在开发周期中添加单元测试之前,先来了解一下常见的测试类型。以下是如何定义不同类别的测试及其优点和缺点。
探索性测试: 这类测试让质量保证工程师和测试人员专注于他们擅长的领域:发现边缘情况和错误。你可以给他们提供早期版本,让他们在上面敲敲打打,直到出现问题为止。测试人员不应该为每次变更/发布制定大量的手动回归测试计划。如果有一套成熟的 e2e、集成和单元测试,涵盖了回归部分,那么就可以让测试人员通过发挥创造力来发现错误。
端到端(e2e): 这些测试模拟真实用户如何与应用程序交互,因此需要完整的应用程序设置,包括网络、数据库和依赖关系。可以设置这些测试的模拟版本,但通常他们会使用真实版本。当 e2e 测试通过时,就可以高度确信应用程序能按预期运行—至少对于正常路径操作、边缘情况和错误来说,在 e2e 中测试需要大量工作。但缺点是,由于 e2e 测试涉及整个应用程序,因此可能既慢又不稳定。
集成测试: 这些测试功能如何与其依赖项协同工作。这些测试并不涵盖整个应用程序,而且是自动化的。与 e2e 测试一样,可以使用模拟和存根来防止向客户发送电子邮件等操作,但集成测试的重点是测试功能如何与依赖项协同工作,因此在可能的情况下应考虑使用真实的功能。集成测试可以测试 SQL 查询等操作,而这些操作在不访问依赖项的情况下是无法测试的。但是,任何测试依赖关系的方法都可能比较缓慢和不稳定。
单元测试:关于单元测试到底是什么,还存在一些争议,这里定义为单元测试是一种自动测试,它不与进程外的依赖关系对话;测试最小的一段代码,以确保其功能正确。它只测试单个进程,不测试其他任何内容。单元测试速度快,独立于应用程序中的任何其他部分运行。但缺点是,单元测试只测试单个功能,因此可以想象所有单元测试都通过了,而整个功能却被破坏了。如果测试过于接近功能的实现,维护起来就会很繁琐。
对我们来说,最大的弊端是历史架构使得编写单元测试非常困难。最佳测试实践表明,我们应该有大量的单元测试、中等数量的集成测试和少量的 e2e 测试。
由于我们几乎没有单元测试,所以必须开始行动。
我们为什么需要单元测试?
你可能会问,为什么现在要添加单元测试—我们已经做到了这一步,而且做得很好,不是吗?
正如之前提到的,作为一个工程组织,我们正在走向成熟,且拥有大型企业出高价购买的付费产品。因为在未来几年的路线图上有大量新技术投资,因此需要一个有弹性的代码库,能在必要时重构代码。换句话说,它能让我们快速行动,而不会破坏东西。此外,为测试重构代码还能创建一个干净的代码基准,并对未来的代码执行 “整洁规则”:让代码保持整洁,甚至比你发现它时更整洁。
除了代码方面的好处,重构还能让整体测试程序变得更好,花费的时间更少。过去,在手动回归测试和拉取请求审查期间的手动测试上花费了大量时间。将这些测试自动化为单元测试,将释放开发人员的大量时间。这将更接近于测试驱动开发,从而能够继续为 Stack Overflow for Teams 产品的所有三个版本和社区网站提供新功能,即使这些功能需要修改现有代码。
我们的工作
为了创建真正的单元测试,需要确保任何功能都能与其依赖关系隔离开来。由于在公共网站和 Stack Overflow for Teams 实例上发生的几乎所有事情都会从数据库中提取数据,因此需要一种方法来向测试指示何时提取模拟数据。使用Dapper和 .NET 中的Entity Framework来管理数据库连接,来创建了一个扩展DbContext的接口,以便将模拟数据视为数据库连接。
Stack Overflow 会重复执行大量相同的查询。由于网站是为了提高速度而创建的,因此在 Entity Framework 中编译了大量此类查询。根据 DbContext 接口编译查询有点问题,因为 EF.CompileQuery 需要一个 DbContext 的具体实例。我们想出了一个辅助类,这样能在使用真实数据库时可以轻松地使用编译查询,而在运行单元测试时则可以使用内存查询。查询保持完全相同,因此这样验证了测试的是正确的行为。
一旦能够连接到模拟数据库,就需要提供一种方法来创建作为测试一部分的模拟数据。因此,这里引入了一个构建器,它可以为测试创建模拟网站数据。使用构建器而不是构造器,这样就可以改变这些模拟网站的构建方式,而不必重写所有的单元测试。构建器只通过显式传递所需的信息来构建对象,其他一切都使用默认值。同样,不想将测试与实现紧密结合在一起,因此选择尽可能抽象对象的构造。
一百多个 Stack Exchange 站点和 Teams 实例共享大量代码,但内容和设计可能有所不同。这些差异由站点设置控制,站点设置是一个智能配置存储,可以扩展到数以万计的站点而不会占用太多内存。要做到这一点,需要数据库连接,因此也需要在这方面做一些改动。为集成测试设置了一个模拟设置,但它的互耦程度非常高。在大多数其他代码钩子之前设置了一个异步上下文感知注入步骤,这样独立运行的测试就可以在不使用数据库的情况下初始化自定义模拟设置。这样做的另一个好处是,并行运行的测试不再改变同一套模拟设置,从而解决了一些不稳定问题。
我们还希望能够测试前端代码。为此,使用了 JavaScript/TypeScript 生态系统中最流行的测试库之一Jest。JS/TS 也有其他可靠的测试库,其中最著名的是Mocha,但在 Stacks 编辑器中使用 Jest 时更方便,因此我们决定将它用于Stacks所有的前端代码。
在这一点上,可以开始编写测试。基于这些变化,在 Stack Overflow for Teams 实例中建立了一个测试说明书,详细介绍了如何编写良好的单元测试和集成测试、从数据库模拟数据以及缓存。
好测试造就好代码
编写一个好的单元测试并不难。编写好的、可测试的代码才是难点。实现可测试代码的最佳方法包括编写无依赖性的纯功能代码。这在现代网络应用程序中是不可能实现的。第二个最好的方法是刻意注入依赖关系。过去,从静态上下文访问大量对象,而不是刻意传递它们,这使得创建可测试版代码变得非常困难。
有了它,就可以致力于可测试性、编写弹性代码,更重要的是,快速实现客户和社区需要的新功能。我们也在不断成长,这意味着代码质量变得越来越重要。自动化单元测试和可测试代码有助于实现所有这些目标。