为什么单元测试的目标从类改为依赖行为? - miro


类级别的测试有以下主要问题:

  1. 类测试使更改变得痛苦
  2. 类测试不验证实际行为
  3. 类测试很难理解

  
类测试使更改变得痛苦
当对我们的代码进行更改时,这会成为一个问题,因为每个小的修改都会破坏测试。由于对代码库的典型更改会影响多个类,因此我们通常必须为我们接触的每个类更新测试。不仅如此,我们可能还需要更新其他模拟任何已更改类的测试。这变得单调乏味,甚至为改变最细微的细节增加了额外的障碍。
即使我们只修改内部实现细节,我们的测试也会中断并需要更新。假设我们想将一个类重构为两个类,以便我们可以在其他地方重用部分逻辑。这将立即中断测试,要求我们删除和更新原始类的测试用例,并为添加的类创建一组新的测试用例。我们甚至没有改变系统的任何外部行为。
相反,我们希望测试仅在外部行为发生变化时才中断。这将使我们可以自由地对我们的代码库进行任何内部重构,而无需对我们的测试进行任何更改。
  
类测试不验证实际行为
类级测试侧重于孤立的各个类。因此,我们正在测试实现细节,而不是整个代码的行为。一个主要的缺点是,每当测试失败时,它都不会告诉我们代码的外部行为是否发生了变化,因为测试可能只是由于实现细节的改变而失败了。
当所有测试在更改后继续为绿色时,这甚至是一个问题——通常向开发人员表明一切都很好,并且可以安全部署。然而,这种类型的测试通常不是这种情况,因为我们依赖于模拟其他类。每次模拟一个类时,都会假设该类是如何工作的,当类本身发生变化并且我们忘记更新mack时,这种假设很快就会过时无效。
这意味着即使类测试在更改后继续保持绿色,我们也无法确定整个代码实际上是否正确运行。相反,我们希望测试通过或失败仅与代码库的外部行为相关联。如果测试失败,则行为已更改,如果测试通过,则代码的行为相同。
 
类测试很难理解
我们刚刚介绍了孤立的测试类并不能说明我们代码的外部行为。因此,要真正了解进行更改后流程是否正常工作,我们需要了解流程中涉及的每个类,以及它们相应的测试是否涵盖了所有必需的案例以及它们所使用的类的所有可能结果。
然后,我们必须在脑海中将其拼凑起来,以得出各个类是否会共同导致流的正确外部行为的结论。这既困难又容易出错,尤其是当更改是由不熟悉代码库每个角落的人进行时。
更复杂的事实是,类测试由于其对实现细节的关注,导致许多测试经常中断,并且由于许多不同的原因。这意味着开发人员需要不断地更新测试,每次都需要全面了解流程中涉及的类、它们的测试以及更改后它们如何以不同的方式相互影响。
让我们看一下下面的示例,该示例显示了四个相互使用的类:
如果类D发生变化,我们不仅要了解和更新 的测试D,还需要了解它对所有类的影响D及其对应的测试。
在此示例中,这将是类B和C. 此外,B 由于D变化可能导致行为不同,我们还需要了解A该类的测试。
尽管理解单个类测试可能并不困难,但当我们需要从这些测试中推​​断出任何类型的外部行为时,它就会变得相当复杂。
相反,我们更希望一个单独的测试用例就足以推断出我们代码库的某些实际外部行为。
 
替代方案
我们自然需要一种替代的自动化测试方法。我们主要将这些概念应用于测试单个微服务,但它们也可以应用于许多其他类型的系统,例如本机和 Web 应用程序甚至库。
我们最终得到的结果依赖于将我们的运行系统视为只关注外部行为的黑匣子的基本概念。这意味着,作为测试的一部分,我们启动系统并在系统运行时对其执行每个测试。我们的目标是尽可能将其视为一个黑盒,因为这会自动使测试独立于实现细节并专注于行为。本质上,我们将整个系统(例如微服务)视为被测单元或系统,而不是单元测试类。
这已成为我们最细粒度的测试类型,并且仍然只关注单个系统或代码库的行为。可能需要其他类型的测试来确保跨多个系统的端到端正确行为。
 
模拟外部依赖
将我们的系统视为黑盒时,我们不再希望模拟、存根、复制或伪造代码库的任何内部部分,因为测试不应该关心这些。
不过,我们确实想模拟外部依赖项,因为这允许我们单独测试我们的系统。根据什么是有意义的,什么是外部的,什么不是外部的定义会因项目而异。
例如,在测试我们的微服务时,数据库被视为内部数据库,因此不会被模拟。时间被视为外部组件并被模拟,系统与其他外部系统之间的 HTTP 通信也是如此。在我们的微服务使用消息队列的情况下,两者都可以。
微服务本身发布和使用的消息不会被模拟,但是,当向其他系统发布或接收任何消息或从其他系统接收任何消息时,这些消息将被模拟并视为外部消息。
 
通过调用外部公开的端点来实现测试
将正在运行的系统视为黑盒时,我们希望安排测试数据并提供与在真实环境中运行时完全相同的输入。
对于微服务,这可以通过调用它公开的端点,或在服务使用的外部队列上发布消息。
对于前端,这可能是通过实际按下按钮和导航用户界面,类似于用户会做的事情。
使用这种方法,我们确保所有测试都基于真实的应用程序状态,就像它们在生产中出现的那样。此外,由于一切都是通过允许的系统输入来调用的,我们永远不会花时间测试现实中不可能发生的案例,这是一个额外的好处。
为了使测试易于编写和维护,创建可重用的方法来安排常用的测试数据通常是非常值得的。一个例子可以是在数据库中安排用户。与其在每个测试中发出创建用户的 HTTP 请求,不如将其移至每个测试用例都可以调用的可重用方法。
 
断言结果
安排好测试数据后,我们准备在我们的系统上执行操作并断言结果。由于我们仍然将我们的系统视为一个黑匣子,我们的目标是只断言我们的行为导致的外部结果。
如果我们的操作是 HTTP 请求,外部结果的示例可能是 HTTP 响应。此外,外部结果也可能是系统发出的传出 HTTP 调用以及在外部消息队列上发布的消息。
在设置模拟以断言我们系统的正确外部行为时,请考虑在严格模式下使用模拟。从某种意义上说,它们应该是严格的,因为当使用任何未专门设置为处理的输入调用时,它们会导致测试失败。这不仅可以确保我们的系统做正确的事情,而且不会发生意外行为。我们不想在不应该的时候进行 HTTP 调用并将消息发送到其他系统。