在我目睹的许多项目中,自动化测试是一个谜。每个人都按照他或她认为合适的方式编写测试,因为这是 wiki 中记录的一些尘封的规则所要求的,但没有人可以回答有关团队测试策略的有针对性的问题。
本章提供了六边形架构的测试策略。对于我们架构的每个元素,我们将讨论覆盖它的测试类型。
测试金字塔
让我们沿着图 21 中的测试金字塔 22 开始讨论测试,这是一个比喻,帮助我们决定应该进行多少种类型的测试。
基本的说法是,我们应该拥有高覆盖率的细粒度测试,这些测试构建成本低、易于维护、运行快速且稳定。这些是单元测试,验证单个“单元”(通常是一个类)是否按预期工作。
一旦测试组合了多个单元并跨单元边界、体系结构边界甚至系统边界,它们往往会变得构建成本更高、运行速度更慢且更脆弱(由于某些配置错误而不是功能错误而失败)。金字塔告诉我们,这些测试的成本越高,我们就越不应该以这些测试的高覆盖率为目标,否则我们将花费太多时间来构建测试而不是新功能。
根据上下文,测试金字塔通常显示为不同的层。让我们看一下我选择讨论测试六边形架构的层。请注意,“单元测试”、“集成测试”和“系统测试”的定义因上下文而异。在一个项目中它们可能意味着不同的事物比另一个事物更重要。以下是我们将在本章中使用的这些术语的解释。 单元测试是金字塔的基础。单元测试通常实例化单个类并通过其接口测试其功能。如果被测试的类依赖于其他类,则这些其他类不会被实例化,而是被替换为模拟,模拟测试期间需要的真实类的行为。
集成测试构成了金字塔的下一层。这些测试实例化多个单元的网络,并通过入口类的接口向该网络发送一些数据来验证该网络是否按预期工作。在我们的解释中,集成测试将跨越两层之间的边界,因此对象网络并不完整,或者必须在某些时候与模拟一起工作。
最后,系统测试启动组成应用程序的整个对象网络,并验证某个用例是否在应用程序的所有层中按预期工作。
在系统测试之上,可能有一层端到端测试,其中包括应用程序的 UI。我们不会在这里考虑端到端测试,因为我们在本书中只讨论后端架构。
现在我们已经定义了一些测试类型,让我们看看哪种类型的测试最适合六边形架构的每一层。
使用单元测试测试域实体
我们首先查看架构中心的域实体。让我们回想一下第 4 章“实现用例”中的 Account 实体。账户的状态由账户在过去某个时刻的余额(基准余额)以及此后的存款和取款(活动)列表组成。
我们现在要验证withdraw()方法是否按预期工作:
class AccountTest {
@Test
void withdrawalSucceeds() {
AccountId accountId = new AccountId(L);
Account account = defaultAccount()
.withAccountId(accountId)
.withBaselineBalance(Money.of(L))
.withActivityWindow(new ActivityWindow(
defaultActivity().withTargetAccount(accountId).withMoney(Money.of(L)).build(),
defaultActivity().withTargetAccount(accountId).withMoney(Money.of(L)).build()))
.build();
boolean success = account.withdraw(Money.of(L), new AccountId(L));
assertThat(success).isTrue();
assertThat(account.getActivityWindow().getActivities()).hasSize();
assertThat(account.calculateBalance()).isEqualTo(Money.of(L));
}
}
上面的测试是一个简单的单元测试,它实例化处于特定状态的 Account,调用其withdraw() 方法,并验证提款是否成功以及对被测 Account 对象的状态产生预期的副作用。
该测试相当容易设置、易于理解,而且运行速度非常快。测试没有比这更简单的了。像这样的单元测试是验证域实体中编码的业务规则的最佳选择。我们不需要任何其他类型的测试,因为域实体行为对其他类几乎没有依赖关系。
使用单元测试测试用例
向外一层,下一个要测试的架构元素是用例。让我们看一下第 4 章“实现用例”中讨论的 SendMoneyService 测试。 “汇款”用例会锁定源账户,因此其他交易在此期间无法更改其余额。如果我们能够成功从源账户中提取资金,我们也会锁定目标账户并将资金存入其中。最后,我们再次解锁这两个账户。
我们希望验证交易成功时一切是否按预期运行:
class SendMoneyServiceTest {
// declaration of fields omitted
@Test
void transactionSucceeds() {
Account sourceAccount = givenSourceAccount();
Account targetAccount = givenTargetAccount();
givenWithdrawalWillSucceed(sourceAccount);
givenDepositWillSucceed(targetAccount);
Money money = Money.of(L);
SendMoneyCommand command = new SendMoneyCommand(
sourceAccount.getId(),
targetAccount.getId(),
money);
boolean success = sendMoneyService.sendMoney(command);
assertThat(success).isTrue();
AccountId sourceAccountId = sourceAccount.getId();
AccountId targetAccountId = targetAccount.getId();
then(accountLock).should().lockAccount(eq(sourceAccountId));
then(sourceAccount).should().withdraw(eq(money), eq(targetAccountId));
then(accountLock).should().releaseAccount(eq(sourceAccountId));
then(accountLock).should().lockAccount(eq(targetAccountId));
then(targetAccount).should().deposit(eq(money), eq(sourceAccountId));
then(accountLock).should().releaseAccount(eq(targetAccountId));
thenAccountsHaveBeenUpdated(sourceAccountId, targetAccountId);
}
// helper methods omitted
}
为了使测试更具可读性,它被构造为行为驱动开发中常用的给定/何时/那么部分。 在“给定”部分中,我们创建源账户和目标账户,并使用一些名称以给定...()开头的方法将它们置于正确的状态。我们还创建一个 SendMoneyCommand 来充当用例的输入。在“when”部分,我们只需调用 sendMoney() 方法来调用用例。 “then”部分断言交易成功,并验证是否已在源账户和目标账户以及负责锁定和解锁账户的 AccountLock 实例上调用了某些方法。
在幕后,测试利用莫基托库用于在给定的...() 方法中创建模拟对象。 Mockito 还提供了 then() 方法来验证是否已在模拟对象上调用了某个方法。
由于被测用例服务是无状态的,因此我们无法验证“then”部分中的某个状态。相反,测试验证服务是否与其(模拟的)依赖项上的某些方法进行交互。这意味着测试很容易受到被测代码结构变化的影响,而不仅仅是其行为的变化。反过来,这意味着如果重构被测代码,则必须修改测试的可能性更高。
考虑到这一点,我们应该认真思考我们真正想要在测试中验证哪些交互。最好不要像我们在上面的测试中那样验证所有交互,而是关注最重要的交互。否则,我们必须随着被测类的每一次更改而更改测试,从而破坏测试的价值。
虽然此测试仍然是单元测试,但它接近于集成测试,因为我们正在测试依赖项上的交互。然而,它比成熟的集成测试更容易创建和维护,因为我们正在使用模拟,而不必管理真正的依赖项。
通过集成测试来测试 Web Adaptor
向外移动另一层,我们到达适配器。让我们讨论一下测试 Web 适配器。
回想一下,Web 适配器通过 HTTP 获取输入(例如以 JSON 字符串的形式),可能会对其进行一些验证,将输入映射到用例期望的格式,然后将其传递给该用例。然后,它将用例的结果映射回 JSON,并通过 HTTP 响应将其返回给客户端。
在 Web 适配器的测试中,我们希望确保所有这些步骤都按预期工作:
@WebMvcTest(controllers = SendMoneyController.class)
class SendMoneyControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SendMoneyUseCase sendMoneyUseCase;
@Test
void testSendMoney() throws Exception {
mockMvc.perform(
post("/accounts/sendMoney/{sourceAccountId}/{targetAccountId}/{amount}", 41L, 42L, 500)
.header("Content-Type", "application/json")).andExpect(status().isOk());
then(sendMoneyUseCase).should().sendMoney(eq(new SendMoneyCommand(
new AccountId(41L),
new AccountId(42L),
Money.of(500L))));
}
}
上述测试是对使用 Spring Boot 框架构建的名为 SendMoneyController 的 Web 控制器的标准集成测试。在方法 testSendMoney() 中,我们创建一个输入对象,然后向 Web 控制器发送模拟 HTTP 请求。请求正文包含 JSON 字符串形式的输入对象。
然后,使用 isOk() 方法,我们验证 HTTP 响应的状态是否为 200,并验证模拟的用例类是否已被调用。
此测试涵盖了 Web 适配器的大部分职责。
我们实际上并没有通过 HTTP 协议进行测试,因为我们正在使用 MockMvc 对象来模拟它。
我们相信该框架能够正确地将所有内容与 HTTP 进行相互转换。无需测试框架。
然而,从 JSON 输入映射到 SendMoneyCommand 对象的整个路径都被涵盖了。如果我们将
SendMoneyCommand 对象构建为自验证命令,如第 4 章“实现用例”中所述,我们甚至可以确保此映射为用例生成语法上有效的输入。此外,我们还验证了用例确实被调用,并且 HTTP 响应具有预期状态。
那么,为什么这是集成测试而不是单元测试呢?尽管我们在这次测试中似乎只测试了一个 Web 控制器类,但实际上还有很多事情要做。通过 @WebMvcTest 注释,我们告诉 Spring 实例化整个对象网络,该对象负责响应某些请求路径、Java 和 JSON 之间的映射、验证 HTTP 输入等。在这个测试中,我们正在验证我们的网络控制器是否作为该网络的一部分工作。
由于 Web 控制器与 Spring 框架密切相关,因此将其集成到该框架中进行测试而不是单独进行测试是有意义的。如果我们使用简单的单元测试来测试 Web 控制器,我们将失去所有映射、验证和 HTTP 内容的覆盖范围,并且我们永远无法确定它是否真的在生产中工作,它只是框架机器中的一个齿轮。
通过集成测试来测试持久性适配器
出于类似的原因,使用集成测试而不是单元测试来覆盖持久性适配器是有意义的,因为我们不仅要验证适配器内的逻辑,还要验证到数据库的映射。
我们想要测试我们在第 6 章“实现持久性适配器”中构建的持久性适配器。该适配器有两种方法,一种用于从数据库加载账户实体,另一种用于将新账户活动保存到数据库:
@DataJpaTest
@Import({ AccountPersistenceAdapter.class, AccountMapper.class })
class AccountPersistenceAdapterTest {
@Autowired
private AccountPersistenceAdapter adapterUnderTest;
@Autowired
private ActivityRepository activityRepository;
@Test
@Sql("AccountPersistenceAdapterTest.sql")
void loadsAccount() {
Account account = adapter.loadAccount(
new AccountId(1L),
LocalDateTime.of(2018, 8, 10, 0, 0));
assertThat(account.getActivityWindow().getActivities()).hasSize();
assertThat(account.calculateBalance()).isEqualTo(Money.of());
}
@Test
void updatesActivities() {
Account account = defaultAccount()
.withBaselineBalance(Money.of(L))
.withActivityWindow(new ActivityWindow(
defaultActivity()
.withId(null)
.withMoney(Money.of(1L)).build()))
.build();
adapter.updateActivities(account);
assertThat(activityRepository.count()).isEqualTo();
ActivityJpaEntity savedActivity = activityRepository.findAll().get();
assertThat(savedActivity.getAmount()).isEqualTo(1L);
}
}
通过 @DataJpaTest,我们告诉 Spring 实例化数据库访问所需的对象网络,包括连接到数据库的 Spring Data 存储库。我们添加一些额外的 @Imports 以确保某些对象被添加到该网络中。例如,被测适配器需要这些对象来将传入的域对象映射到数据库对象。
在方法 loadAccount() 的测试中,我们使用 SQL 脚本将数据库置于特定状态。然后,我们只需通过适配器 API 加载账户并验证它是否具有我们期望它在 SQL 脚本中给出的数据库状态的状态。
updateActivities() 的测试则相反。我们正在使用新的账户活动创建一个 Account 对象,
并将其传递给适配器以进行持久化。然后,我们通过 ActivityRepository 的 API 检查 Activity 是否已保存到数据库中。
这些测试的一个重要方面是我们不会嘲笑数据库。测试实际上是在访问数据库。如果我们模拟数据库,测试仍然会覆盖相同的代码行,产生相同的高覆盖率代码行。但是,尽管覆盖率很高,但由于 SQL 语句中的错误或数据库表和 Java 对象之间的意外映射错误,测试在使用真实数据库的设置中仍然有相当高的机会失败。
请注意,默认情况下,Spring 将启动一个内存数据库以在测试期间使用。这非常实用,因为我们不需要配置任何东西,测试就可以开箱即用。
然而,由于这个内存数据库很可能不是我们在生产中使用的数据库,因此即使针对内存数据库的测试完美运行,实际数据库仍然很有可能出现问题。例如,数据库喜欢实现自己风格的 SQL。
因此,持久性适配器测试应该针对真实数据库运行。图书馆喜欢测试容器² ⁴ 在这方面提供了很大的帮助,它可以按需启动带有数据库的 Docker 容器。
针对真实数据库运行还有一个额外的好处,那就是我们不必处理两个不同的数据库系统。
如果我们在测试期间使用内存数据库,我们可能必须以某种方式配置它,或者我们可能必须为每个数据库创建单独版本的数据库迁移脚本,这一点也不有趣。
通过系统测试测试主要路径
金字塔顶部是系统测试。系统测试启动整个应用程序并针对其 API 运行请求,验证所有层是否协同工作。
在“汇款”用例的系统测试中,我们向应用程序发送 HTTP 请求并验证响应以及账户的新余额:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class SendMoneySystemTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
@Sql("SendMoneySystemTest.sql")
void sendMoney() {
Money initialSourceBalance = sourceAccount().calculateBalance();
Money initialTargetBalance = targetAccount().calculateBalance();
ResponseEntity response = whenSendMoney(sourceAccountId(),targetAccountId(),transferredAmount());
then(response.getStatusCode()).isEqualTo(HttpStatus.OK);
then(sourceAccount().calculateBalance()).isEqualTo(initialSourceBalance.minus(transferredAmount()));
then(targetAccount().calculateBalance()).isEqualTo(initialTargetBalance.plus(transferredAmount()));
}
private ResponseEntity whenSendMoney(
AccountId sourceAccountId,
AccountId targetAccountId,
Money amount) {
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
HttpEntity<Void> request = new HttpEntity<>(null, headers);
return restTemplate.exchange(
"/accounts/sendMoney/{sourceAccountId}/{targetAccountId}/{amount}",
HttpMethod.POST,
request,
Object.class, sourceAccountId.getValue(),
targetAccountId.getValue(),
amount.getAmount());
}
// some helper methods omitted
}
通过@SpringBootTest,我们告诉 Spring 启动组成应用程序的整个对象网络。我们还配置应用程序以将其自身暴露在随机端口上。
在测试方法中,我们只需创建一个请求,将其发送到应用程序,然后检查响应状态和账户的新余额。
我们使用 TestRestTemplate 来发送请求,而不是 MockMvc,就像我们之前在 Web 适配器测试中所做的那样。这意味着我们正在做真正的 HTTP,使测试更接近生产环境。
就像我们正在研究真正的 HTTP 一样,我们正在研究真正的输出适配器。在我们的例子中,这只是一个将应用程序连接到数据库的持久性适配器。在与其他系统通信的应用程序中,我们将有额外的输出适配器。让所有这些第三方系统启动并运行并不总是可行的,即使是进行系统测试,所以毕竟我们可能会嘲笑它们。我们的六边形架构使这对我们来说变得非常简单,因为我们只需要去掉几个输出端口接口。
请注意,我竭尽全力使测试尽可能具有可读性。我将所有丑陋的逻辑隐藏在辅助方法中。
这些方法现在形成了一种特定于领域的语言,我们可以用它来验证事物的状态。
虽然像这样的特定于领域的语言 DSL 在任何类型的测试中都是一个好主意,但它在系统测试中更为重要。系统测试比单元测试或集成测试更好地模拟应用程序的真实用户,因此我们可以使用它们从用户的角度来验证应用程序。如果手头有合适的词汇,这会容易得多。该词汇表还使最适合代表应用程序用户且可能不是程序员的领域专家能够推理测试并提供反馈。
有用于行为驱动开发的完整库,例如 JGiven 提供一个框架来为您的测试创建词汇表。
如果我们按照前面几节的描述创建了单元和集成测试,系统测试将覆盖许多相同的代码。
他们甚至提供任何额外的好处吗?是的,他们确实这么做了。通常,它们会消除单元测试和集成测试之外的其他类型的错误。例如,层之间的某些映射可能会关闭,仅通过单元和集成测试我们不会注意到这一点。
如果系统测试结合多个用例来创建场景,则可以最好地发挥其优势。每个场景代表用户通常在应用程序中可能采取的特定路径。如果最通过系统测试涵盖了重要的场景,我们可以假设我们的最新修改没有破坏它们并准备好发布。
多少测试才足够?
我参与过的许多项目团队都无法回答的一个问题是我们应该做多少测试。如果我们的测试覆盖了 80% 的代码行就足够了吗?应该比这个高吗?
线覆盖率是衡量测试成功与否的一个不好的指标。除了 100% 之外的任何目标都是完全无意义的,因为代码库的重要部分可能根本没有被覆盖。即使达到 100%,我们仍然不能确定每个错误都已被消除。
我建议通过我们交付软件的舒适程度来衡量测试的成功。如果我们对测试足够信任,可以在执行测试后交付,那么我们就很好。我们发货的次数越多,我们对测试的信任度就越高。如果我们每年只发货两次,那么没有人会相信这些测试,因为它们每年只证明自己两次。
这需要在我们发布的前几次中实现信念的飞跃,但如果我们将修复生产中的错误并从中学习作为优先事项,那么我们就走在正确的轨道上。对于每个生产错误,我们应该问一个问题“为什么我们的测试没有捕获这个错误?”,记录答案,然后添加覆盖它的测试。随着时间的推移,这将使我们对交付感到满意,并且文档甚至会提供一个指标来衡量我们随着时间的推移所取得的进步。
然而,从定义我们应该创建的测试的策略开始是有帮助的。我们的六边形架构的策略之一是:
• 在实现域实体时,用单元测试覆盖它
• 在实现用例时,用单元测试覆盖它
• 在实现适配器时,用集成测试覆盖它
• 通过系统测试涵盖用户在应用程序中可以采取的最重要路径。
请注意“实现时”一词:当测试是在功能开发期间而不是之后完成时,它们就成为一种开发工具,不再感觉像是一件苦差事。
然而,如果每次添加新字段时我们都必须花费一个小时来修复测试,那么我们就做错了。
也许我们的测试太容易受到代码结构变化的影响,我们应该考虑如何改进它。如果我们必须为每次重构修改测试,那么测试就失去了价值。
这如何帮助我构建可维护的软件?
六边形架构风格将域逻辑和面向外部的适配器清晰地分开。这有助于我们定义一个清晰的测试策略,该策略涵盖带有单元测试的中心域逻辑和带有集成测试的适配器。
输入和输出端口在测试中提供了非常明显的模拟点。对于每个端口,我们可以决定模拟它,或者使用真正的实现。如果每个端口都非常小并且集中,那么模拟它们就变得轻而易举,而不是一件苦差事。端口接口提供的方法越少,我们在测试中必须模拟哪些方法的混乱就越少。
如果模拟事物成为太大的负担,或者如果我们不知道应该使用哪种测试来覆盖代码库的特定部分,那么这是一个警告信号。在这方面,我们的测试作为金丝雀承担着额外的责任— —警告我们架构中的缺陷,并引导我们回到创建可维护的代码库的道路上。