Best Practices For Unit Testing In Java - tenji/ks GitHub Wiki
单元测试是软件设计和实现的关键步骤。
可维护和可读的测试代码对于建立良好的测试覆盖率至关重要,这反过来又可以实现新功能和执行重构,而不必担心破坏某些东西。
一个测试应该包含三个由一个空行分隔的块。每个代码块都应该尽可能短。使用子功能来缩短这些块。
- Given (Input):测试准备,例如创建数据或配置模拟
- When (Action):调用你想测试的方法或动作
- Then (Output):执行断言以验证操作的正确输出或行为
// Do
@Test
public void findProduct() {
insertIntoDatabase(new Product(100, "Smartphone"));
Product product = dao.findProduct(100);
assertThat(product.getName()).isEqualTo("Smartphone");
}
// Don't
ProductDTO product1 = requestProduct(1);
ProductDTO product2 = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(product1).isEqualTo(product2);
如果要在等式断言中使用变量,请在变量前加上“actual”和“expected”。这增加了可读性并明确了变量的意图。此外,在 equals 断言中更难混淆它们。
// Do
ProductDTO actualProduct = requestProduct(1);
ProductDTO expectedProduct = new ProductDTO("1", List.of(State.ACTIVE, State.REJECTED))
assertThat(actualProduct).isEqualTo(expectedProduct); // nice and clear.
避免随机数据,因为它可能会导致难以调试的切换测试(toggling tests),并忽略错误消息,从而更难将错误追溯到代码。
// Don't
Instant ts1 = Instant.now(); // 1557582788
Instant ts2 = ts1.plusSeconds(1); // 1557582789
int randomAmount = new Random().nextInt(500); // 232
UUID uuid = UUID.randomUUID(); // d5d1f61b-0a8b-42be-b05a-bd458bb563ad
相反,对所有内容使用固定值。他们将创建高度可重复的测试,这些测试易于调试并创建可以轻松追溯到相关代码行的错误消息。
// Do
Instant ts1 = Instant.ofEpochSecond(1550000001);
Instant ts2 = Instant.ofEpochSecond(1550000002);
int amount = 50;
UUID uuid = UUID.fromString("00000000-000-0000-0000-000000000001");
你可以通过使用辅助函数(helper functions)来避免冗余的代码和工作量。
源代码更高的测试覆盖率总是有益的。然而,这并不是唯一要实现的目标。我们应该做出明智的决定,并选择一个更适合我们的实施、截止日期和团队的权衡。
作为一个经验法则,我们应该尝试通过单元测试覆盖 80% 的代码。
此外,我们可以使用 JaCoCo 和 Cobertura 等工具以及 Maven 或 Gradle 来生成代码覆盖率报告。
将细节或重复代码提取到子函数中,并给它们一个描述性的名称。这是保持测试简短和易于掌握测试要点的有力手段。
// Don't
@Test
public void categoryQueryParameter() throws Exception {
List<ProductEntity> products = List.of(
new ProductEntity().setId("1").setName("Envelope").setCategory("Office").setDescription("An Envelope").setStockAmount(1),
new ProductEntity().setId("2").setName("Pen").setCategory("Office").setDescription("A Pen").setStockAmount(1),
new ProductEntity().setId("3").setName("Notebook").setCategory("Hardware").setDescription("A Notebook").setStockAmount(2)
);
for (ProductEntity product : products) {
template.execute(createSqlInsertStatement(product));
}
String responseJson = client.perform(get("/products?category=Office"))
.andExpect(status().is(200))
.andReturn().getResponse().getContentAsString();
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
// Do
@Test
public void categoryQueryParameter2() throws Exception {
insertIntoDatabase(
createProductWithCategory("1", "Office"),
createProductWithCategory("2", "Office"),
createProductWithCategory("3", "Hardware")
);
String responseJson = requestProductsByCategory("Office");
assertThat(toDTOs(responseJson))
.extracting(ProductDTO::getId)
.containsOnly("1", "2");
}
使用 mock 单独测试每个类是一种常见的测试建议。但是,它有缺点:您没有在集成中测试所有类,并且内部重构会破坏所有测试,因为每个内部类都有一个测试。最后,您必须编写和维护多个测试。
相反,我建议专注于集成测试。 “集成测试”(或“组件测试”)是指将所有类放在一起(就像在生产中一样)并测试一个完整的 Vertical Slide,贯穿所有技术层(HTTP、业务逻辑、数据库)。这样,你正在测试的就是行为而不是实现。这些测试是准确的、接近生产的,并且对内部重构具有鲁棒性。理想情况下,我们只需要编写一个测试类。
尽管如此,单元测试还是很有用的,在某些情况下单元测试是更好的选择,或者将这两种方法结合起来是有意义的。但是,我的经验是集成测试在大多数情况下是更好和足够的选择。
使用内存数据库(H2、HSQLDB、Fongo)进行测试会降低测试的可靠性和范围。内存数据库和生产中使用的数据库行为不同,可能返回不同的结果。因此,基于内存数据库的绿色测试并不能保证您的应用程序在生产中的正确行为。此外,你很容易遇到无法使用(或测试)某个(特定于数据库的)功能的情况,因为内存数据库不支持或跟实际数据库的表现不一致。
解决方案是针对真实数据库执行测试。幸运的是,Testcontainers 库提供了一个很棒的 Java API,用于直接在测试代码中管理容器。
AssertJ 是一个非常强大且成熟的断言库,具有流畅的类型安全 API、种类繁多的断言和描述性失败消息。你想做的每件事都有一个断言。这可以防止您在保持测试代码简短的同时使用循环和条件编写复杂的断言逻辑。这里有些例子:
assertThat(actualProduct)
.isEqualToIgnoringGivenFields(expectedProduct, "id");
assertThat(actualProductList).containsExactly(
createProductDTO("1", "Smartphone", 250.00),
createProductDTO("1", "Smartphone", 250.00)
);
assertThat(actualProductList)
.usingElementComparatorIgnoringFields("id")
.containsExactly(expectedProduct1, expectedProduct2);
assertThat(actualProductList)
.extracting(Product::getId)
.containsExactly("1", "2");
assertThat(actualProductList)
.anySatisfy(product -> assertThat(product.getDateCreated()).isBetween(instant1, instant2));
assertThat(actualProductList)
.filteredOn(product -> product.getCategory().equals("Smartphone"))
.allSatisfy(product -> assertThat(product.isLiked()).isTrue());
避免简单的 assertTrue()
或 assertFalse()
断言,因为它们会产生神秘的失败消息:
// Don't
assertTrue(actualProductList.contains(expectedProduct));
assertTrue(actualProductList.size() == 5);
assertTrue(actualProduct instanceof Product);
expected: <true> but was: <false>
取而代之的是,使用 AssertJ 的断言,它会产生很好的开箱即用的失败消息。
// Do
assertThat(actualProductList).contains(expectedProduct);
assertThat(actualProductList).hasSize(5);
assertThat(actualProduct).isInstanceOf(Product.class);
Expecting:
<[Product[id=1, name='Samsung Galaxy']]>
to contain:
<[Product[id=2, name='iPhone']]>
but could not find:
<[Product[id=2, name='iPhone']]>
如果你真的需要检查布尔值,请考虑使用 AssertJ 的 as() 来改进失败消息。
JUnit5 是最先进的(单元)测试。它正在积极开发并提供许多强大的功能(如参数化测试、分组、条件测试、生命周期控制)。
-
Use Parameterized Tests
参数化测试允许使用不同的值多次重新运行单个测试。这样,您可以轻松地测试多个案例,而无需编写更多的测试代码。JUnit5 提供了很好的方法来使用
@ValueSource
、@EnumSource
、@CsvSource
和@MethodSource
编写这些测试。// Do @ParameterizedTest @ValueSource(strings = ["§ed2d", "sdf_", "123123", "§_sdf__dfww!"]) public void rejectedInvalidTokens(String invalidToken) { client.perform(get("/products").param("token", invalidToken)) .andExpect(status().is(400)) } @ParameterizedTest @EnumSource(WorkflowState::class, mode = EnumSource.Mode.INCLUDE, names = ["FAILED", "SUCCEEDED"]) public void dontProcessWorkflowInCaseOfAFinalState(WorkflowState itemsInitialState) { // ... }
我强烈建议广泛使用它们,因为您可以用最少的代码测试更多的案例。
-
Group the Tests
-
Readable Test Names with
@DisplayName
or Kotlin’s Backticks
为了测试 HTTP 客户端,我们需要模拟远程服务。为此,我经常使用 OkHttp 的 WebMockServer。替代方案是 WireMock 或 Testcontainer 的 Mockserver。
MockWebServer serviceMock = new MockWebServer();
serviceMock.start();
HttpUrl baseUrl = serviceMock.url("/v1/");
ProductClient client = new ProductClient(baseUrl.host(), baseUrl.port());
serviceMock.enqueue(new MockResponse()
.addHeader("Content-Type", "application/json")
.setBody("{\"name\": \"Smartphone\"}"));
ProductDTO productDTO = client.retrieveProduct("1");
assertThat(productDTO.getName()).isEqualTo("Smartphone");
Awaitility 是一个用于测试异步代码的库。你可以很容易地定义一个断言被重试的频率,直到它最终失败。
private static final ConditionFactory WAIT = await()
.atMost(Duration.ofSeconds(6))
.pollInterval(Duration.ofSeconds(1))
.pollDelay(Duration.ofSeconds(1));
@Test
public void waitAndPoll() {
triggerAsyncEvent();
WAIT.untilAsserted(() -> {
assertThat(findInDatabase(1).getState()).isEqualTo(State.SUCCESS);
});
}
这样,您可以避免在测试中使用脆弱的 Thread.sleep()
。
但是,测试同步代码要容易得多。这就是为什么我们应该尝试将同步和异步代码分开以便分别测试它们。
Spring Boot 提供了强大的测试功能 - 比我在这篇文章中所能涵盖的更多。
在许多情况下,使用 Spring 的测试功能是合适的。但是,启动 (Spring) DI 框架需要几秒钟才能开始测试。尤其是在测试的初始开发过程中,这会减慢反馈周期。