在我目睹的许多项目中,自动化测试是一个谜。每个人都按照他或她认为合适的方式来写测试
因为这是在wiki中记录的一些灰尘规则所要求的,但是没有人能回答关于团队测试策略的有针对性的问题。
本章提供了一个六边形体系结构的测试策略。对于体系结构中的每个元素,我们将讨论要覆盖它的测试类型。
让我们按照图21中的测试金字塔的思路开始讨论测试问题。
它是一个比喻,帮助我们决定哪种类型的测试数量,我们的目标是什么。
根据测试金字塔,我们应该创建许多廉价的测试和不太昂贵的测试。
最基本的说法是,我们应该有高覆盖率的细粒度测试,这些测试构建起来便宜、易于维护、快速运行和稳定。这些是单元测试,以验证单个“单元” (通常是一个类)按预期工作。
一旦测试组合了多个单元并跨单元边界、架构边界甚至系统边界,它们往往会变得更昂贵、运行更慢、更脆弱(失败 由于一些配置错误,而不是一个功能错误)。金字塔告诉我们这些测试越是昂贵,我们就越不应该追求这些测试的高覆盖率,因为否则我们就会花太多时间来构建测试而不是新功能。
根据不同的背景,测试金字塔通常以不同的层次显示。让我们看一下我选择的层次来讨论测试我们的六边形架构。请注意,"单元测试 "的定义。"集成测试 "和 "系统测试 "的定义随上下文而变化。
单元测试是金字塔的基础。一个单元测试通常实例化一个单一的类,并通过其接口测试其 功能的接口。如果被测试的类与其他类有依赖关系,这些 其他的类不被实例化,而是用mock来代替,模拟真实类的行为。在测试过程中需要它。
集成测试构成了金字塔的下一层。这些测试实例化了一个由多个单元组成的网络 单位的网络,并通过一个入口类的接口向其发送一些数据来验证该网络是否按预期工作。在我们的解释中,集成测试将跨越两层之间的边界。 所以对象的网络是不完整的,或者说必须在某一点上对模拟进行工作。
最后进行系统测试,启动组成我们应用程序的整个对象网络,并验证某个用例是否在应用程序的所有层中如预期的那样工作。
在系统测试之上,可能会有一个包含应用程序UI的端到端测试层。我们在这里不考虑端到端测试,因为我们只讨论一个后端体系结构 在这本书。
现在我们已经定义了一些测试类型,让我们看看哪种类型的测试最适合我们的六边形体系结构的每个层。
我们先看一下我们架构中心的一个领域实体。让我们回顾一下第四章 "实现用例 "中的 "账户 实体,在第四章 "实现用例 "中。一个账户的状态由以下部分组成 帐户在过去某个时间点的余额(基线余额)和自那时以来的存款和提款列表(活动)。
我们现在想验证withdraw()方法是否按预期工作:
上述测试是一个普通的单元测试,它实例化处于特定状态的帐户,调用其withdraw()方法,并验证提取是否成功,并有预期的副作用 被测试的帐户对象的状态。
该测试相当容易设置,容易理解,而且运行非常快。测试没有比这 比这更简单。像这样的单元测试是我们验证在域实体中编码的业务规则的最佳选择。域实体中编码的业务规则。我们不需要任何其他类型的测试,因为领域实体的行为很少或没有 对其他类的依赖性。
再往外走一层,下一个要测试的架构元素是用例。让我们看一下对 第4章 "实现用例 "中讨论的SendMoneyService的测试。"发送金钱 "用例 用例锁定了源账户,因此在此期间没有其他交易可以改变其余额。如果我们可以成功地从源账户取钱,我们也会锁定目标账户 并把钱存入那里。最后,我们再次解锁两个账户。
我们希望验证当事务成功时,一切是否按预期工作:
为了使测试更具可读性,它被构造为在行为驱动开发中常用的给定/何时/然后(given/when/then)的部分。
在 "给定(given) "部分,我们创建了源账户和目标账户,并使它们进入正确的状态 用一些名称以 given…() 开头的方法。我们还创建了一个SendMoneyCommand来作为 作为用例的输入。
在“when”部分中,我们简单地调用sendMoney()方法来调用用例。
“然后”部分断言事务是成功的,并验证是否已经调用了某些方法 针对源帐户和目标帐户,以及负责锁定和解锁帐户的帐户锁定实例。
在底层,测试利用Mockito²⁶库在given…()方法中创建模拟对象。Mockito还提供了then()方法来验证在一个模拟对象上是否调用了某个方法。
由于被测试的用例服务是无状态的,我们不能在 "then” 部分验证某种状态。相反,测试将验证服务是否与其(模拟的)依赖关系上的某些方法发生交互。这意味着测试很容易受到被测试代码结构变化的影响,而不仅仅是它的行为。反过来,这也意味着该测试必须被修改的可能性更高 如果被测试的代码被重构。
考虑到这一点,我们应该仔细考虑我们实际上想要在测试中验证哪些交互作用。不像我们在上面的测试中那样验证所有的交互可能是个好主意,而是 侧重于最重要的那些。否则,我们必须随着被测类的每一个变化而改变测试的内容,被测类的每一次改变,都要改变测试,从而削弱了测试的价值。
虽然这个测试仍然是一个单元测试,但它接近于一个集成测试,因为我们正在测试依赖关系上的交互。它比一个完整的集成测试更容易创建和维护。然而,因为我们是在使用模拟,而不需要管理真正的依赖关系。
向外移动另一层,我们就会到达适配器。让我们来讨论一下测试一个web适配器。
回想一下,一个web适配器接受输入,例如通过JSON字符串的形式,可能会对其进行一些验证,将输入映射到用例期望的格式,然后将其传递给案例。然后,它将用例的结果映射回JSON,并通过HTTP响应将其返回给客户端。
在对web适配器的测试中,我们希望确保所有这些步骤都按照预期工作:
上述测试是对一个名为SendMoneyController的网络控制器的标准集成测试。在testSendMoney()方法中,我们要创建一个输入对象 然后向Web控制器发送一个模拟的HTTP请求。请求主体包含输入 对象作为一个JSON字符串。
使用isOk()方法,我们验证HTTP响应的状态为200,并验证模拟用例类。
此测试涵盖了web适配器的大部分职责。
我们实际上没有在HTTP协议上进行测试,因为我们用MockMvc 对象来模拟。我们相信框架会正确地将所有的东西翻译成HTTP协议。不需要 测试框架。
从JSON的输入映射到SendMoneyCommand对象的整个路径都包括在内。 但是。如果我们将SendMoneyCommand对象构建为一个自我验证的命令,正如第4章 "实现用例 "中解释的那样 第4章 "实现用例 "中解释的那样,我们甚至已经确保了这种映射会产生 句法上有效的输入到用例中。此外,我们还验证了该用例确实被调用了 并且HTTP响应具有预期的状态。
那么,为什么这是一个集成测试,而不是一个单元测试?尽管看起来我们只是在测试 一个web控制器类,但是在这个测试中,还有很多事情要做。通过 @WebMvcTest注解,我们告诉Spring实例化一个完整的对象网络,负责响应某些请求路径。在Java和JSON之间进行映射,验证HTTP输入。等等。在这个测试中,我们要验证我们的Web控制器是否作为这个网络的一部分工作。
==由于Web控制器与Spring框架有很大的关系,因此将其集成到这个框架中进行测试是有意义的 而不是孤立地测试它。如果我们用一个普通的单元测试来测试Web控制器 测试,我们会失去对所有映射、验证和HTTP的覆盖,而且我们永远无法确定它在生产中是否真的工作。==我们永远不能确定它是否真的在生产中工作,在那里它只是框架中的一个齿轮。
出于类似的原因,用集成测试而不是单元测试来覆盖持久化适配器是有意义的。单元测试,因为我们不仅要验证适配器中的逻辑,而且还要验证映射到 数据库的映射。
我们想测试一下我们在第6章 "实现一个持久化适配器 "中建立的持久化适配器。 该适配器有两个方法,一个用于从数据库加载一个帐户实体,另一个用于 将新的帐户活动保存到数据库中。
通过@DataJpaTest,我们告诉Spring实例化数据库访问所需的对象网络,包括连接到数据库的Spring数据存储库。我们添加了一些添加 以确保某些对象被添加到该网络中。例如,被测试的适配器需要这些对象来将传入的域对象映射到数据库对象中。
在方法loadAccount() 的测试中,我们使用SQL脚本将数据库置于特定状态。然后,我们只需通过适配器API加载帐户,并验证它的状态为 我们期望它在SQL脚本中给出了数据库状态。
对updateActivities() 的测试则正好相反。我们正在创建一个带有新帐户活动的帐户对象,并将其传递给适配器以持久化。然后,我们检查该活动是否已经完成 ,通过ActivityRepository的API保存到数据库中。
这些测试的一个重要方面是,我们不是在模拟数据库。这些测试实际上是 打击数据库。如果我们把数据库模拟掉了,测试仍然会覆盖同样的代码行 的代码,产生相同的高覆盖率的代码行。但尽管如此,测试的高覆盖率 仍然有相当高的几率在真实数据库的设置中失败,由于SQL 语句中的错误或数据库表和Java对象之间的意外映射错误而导致测试失败。
请注意,默认情况下,Spring会在测试过程中启动一个内存数据库来使用。这是非常实用,因为我们不需要配置任何东西,测试就能开箱工作。
由于这个内存数据库很可能不是我们在生产中使用的数据库,但是。 即使在对内存数据库进行测试时,仍然有很大的可能性在真实的数据库中出错。测试在内存数据库中完美运行。数据库喜欢实现他们自己的SQL风味。
因此,持久性适配器测试应该针对实际数据库运行。像Testcontainers²⁷这样的库在这方面有很大的帮助,它可以根据需要启动一个带有数据库的Docker容器。
针对真实的数据库运行有一个额外的好处,即我们不需要处理两个不同的数据库系统。如果我们在测试期间使用内存中的数据库,我们可能需要配置 以某种方式重新运行它,或者我们可能必须为每个数据库创建单独版本的数据库迁移脚本,这一点都不好玩。
在金字塔的顶部是系统测试。系统测试启动整个应用程序并针对其API运行请求,验证所有层协同工作。
在一个系统测试中,为“Send Money”进行测试用例,我们向应用程序发送一个HTTP请求,并验证响应以及帐户的帐户余额:
通过@SpringBootTest,我们告诉Spring启动组成应用程序的整个对象网络。我们还将应用程序配置为在一个随机端口上公开自己。
在测试方法中,我们只需创建一个请求,然后将其发送到应用程序,然后检查响应状态和帐户的新余额。
我们使用TestRestTemplate来发送请求,而不是MockMvc,正如我们之前在 网络适配器的测试。这意味着我们正在做真正的HTTP,使测试更接近于生产 环境。
就像我们要通过真正的HTTP,我们要通过真正的输出适配器。在我们的例子中,这只是一个持久性适配器,它将应用程序连接到数据库。在一个与其他系统对话的应用程序中,我们会有额外的输出适配器。要让所有这些第三方系统都能正常运行,并不总是可行的。
所有这些第三方系统都能正常运行,即使是为了进行系统测试,所以我们可能会把它们模拟掉。毕竟。我们的六边形架构使我们可以很容易地做到这一点,因为我们只需要输出端口的几个接口。
请注意,我不遗余力地使这个测试尽可能地可读。我把所有丑陋的逻辑都隐藏在 在辅助方法中。这些方法现在形成了一种特定领域的语言,我们可以用它来 验证事物的状态。
虽然像这样的特定领域的语言在任何类型的测试中都是一个好主意,但它在系统测试中甚至更加重要。系统测试模拟应用程序的真实用户比单元要好得多 或者集成测试可以,所以我们可以使用它们从用户的角度来验证应用程序。如果有一个合适的词汇表在手,这就容易多了。这个词汇表还允许领域专家使用 他们最适合体现应用程序的用户,也可能不是程序员,他们可以对测试进行推理并给出反馈。有整个用于行为驱动开发的库 比如JGiven²⁸,它提供了一个框架来为你的测试创建一个词汇表。
如果我们像前面几节中所述创建了单元和集成测试,那么系统测试将涵盖许多相同的代码。它们还能提供任何额外的好处吗?是的,是的。 除了单元测试和集成测试之外,它们还可以清除其他类型的错误。例如,层之间的一些映射可能会关闭,我们在单元和集成测试中不会注意到 单独的。
如果系统测试结合多个用例来创建场景,则它们最能发挥其优势。每个场景都表示用户通常通过应用程序可能采用的特定路径。
线路覆盖率是衡量测试成功的一个糟糕的指标。除了100%以外的任何目标都是完全 毫无意义,因为代码库的重要部分可能根本就没有被覆盖。而且,即使在 100%,我们仍然不能确定每一个bug都被压制住了。
我建议衡量我们运送软件的舒适程度。如果我们在执行测试后足够相信测试,我们就很好。我们运送的次数越多,就越信任在我们的测试中有。如果我们一年只发货两次,没有人会相信这些测试,因为它们一年只证明自己两次。
这要求我们在最初几次发货时要有信心,但如果我们把修复和学习生产中的错误作为优先事项,我们就会走上正确的道路。从生产中的错误中学习,我们就会走上正确的道路。对于每个生产中的错误,我们应该问问题:“为什么我们的测试没有发现这个错误?”,记录答案,然后添加一个测试覆盖它。随着时间的推移,这将使我们对发货感到满意,而文档甚至会提供一个指标来衡量我们在一段时间内的改进。
然而,从一个定义我们应该创建的测试的策略开始是有帮助的。我们的六角形建筑的其中一个策略是:
注意“在实现时”一词:当测试是在特性开发期间而不是在开发之后完成时,它们将成为一个开发工具,不再感觉像是一件苦差事。
但是,如果每次添加一个新字段时必须花一个小时修复测试,那么我们就做错了。可能是我们的测试对代码中的结构变化太脆弱了,我们
我们应该研究如何改进。如果我们在每次重构时都要修改测试,那么测试就失去了价值。
六边形架构风格将领域逻辑和外向型适配器干净地分开。这有助于我们定义一个清晰的测试策略,用单元测试覆盖中心域逻辑,用集成测试覆盖适配器。
==输入和输出端口在测试中提供了非常可见的模拟点。对于每个端口,我们可以决定模拟它,或者使用真正的实现。(The input and output ports provide very visible mocking points in tests)==如果每个端口都非常小和集中,则进行模拟它们是一件轻而易举的事情,而不是苦差事。一个端口接口提供的方法越少,关于我们必须在测试中模拟哪些方法的混乱就越少。
如果模拟的负担太重,或者我们不知道我们应该用哪种测试来覆盖代码库的某一部分,这就是一个警告信号。在这方面,我们的测试有 额外的责任是作为金丝雀–警告我们架构中的缺陷并引导我们回到创建一个可维护的代码库的道路上。