单元测试已成为开发的标准部分。许多工具可以以许多不同的方式用于它。本文演示了一些提示,或者说,对我来说效果很好的最佳实践。
在本文中,您将了解
如何使用 JUnit 和 Assert 框架编写干净且可读的单元测试
在某些情况下如何避免假阳性测试
编写单元测试时应避免的事项
不要过度使用 NPE 检查
我们都倾向于在主代码中尽可能避免 NullPointerException,因为它可能会导致丑陋的后果。我相信我们主要关心的不是在测试中避免NPE。我们的目标是以干净、可读和可靠的方式验证被测组件的行为。
不良做法
过去很多次,即使不需要断言,我也使用过断言,如下例所示:isNotNull
@Test
public void getMessage() {
assertThat(service).isNotNull();
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
此测试产生如下错误:
java.lang.AssertionError:
Expecting actual not to be null
at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)
良好做法
尽管附加断言并不真正有害,但由于以下原因,应避免使用:isNotNull
它不会增加任何附加值。它只是需要阅读和维护的更多代码。
无论如何,测试都会失败,我们看到了失败的真正根本原因。该测试仍然实现了其目的。servicenull
使用 AssertJ 断言时,生成的错误消息甚至更好。
请参阅下面修改后的测试断言。
@Test
public void getMessage() {
assertThat(service.getMessage()).isEqualTo("Hello world!");
}
修改后的测试会产生如下错误:
java.lang.NullPointerException: Cannot invoke "com.github.aha.poc.junit.spring.HelloService.getMessage()" because "this.service" is null
at com.github.aha.poc.junit.spring.StandardSpringTest.test(StandardSpringTest.java:19)
注意:该示例可以在 SimpleSpringTest 中找到。
断言值而不是结果
有时,我们会编写一个正确的测试,但以“糟糕”的方式。这意味着测试完全按预期工作并验证了我们的组件,但失败没有提供足够的信息。因此,我们的目标是断言价值而不是比较结果。
不良做法
让我们看看几个这样的糟糕测试:
// #1
assertThat(argument.contains("o")).isTrue();
// #2
var result = "Welcome to JDK 10";
assertThat(result instanceof String).isTrue();
// #3
assertThat("".isBlank()).isTrue();
// #4
Optional<Method> testMethod = testInfo.getTestMethod();
assertThat(testMethod.isPresent()).isTrue();
上述测试中的一些错误如下所示。
#1
Expecting value to be true but was false
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
#3
Expecting value to be true but was false
at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502)
at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)
良好做法
使用 AssertJ 及其流畅的 API 可以轻松解决问题。上面提到的所有情况都可以很容易地改写为:
// #1
assertThat(argument).contains("o");
// #2
assertThat(result).isInstanceOf(String.class);
// #3
assertThat("").isBlank();
// #4
assertThat(testMethod).isPresent();
与前面提到的完全相同的错误现在提供了更多价值。
#1
Expecting actual:
"Hello"
to contain:
"f"
at com.github.aha.poc.junit5.params.SimpleParamTests.stringTest(SimpleParamTests.java:23)
#3
Expecting blank but was: "a"
at com.github.aha.poc.junit5.ConditionalTests.checkJdk11Feature(ConditionalTests.java:50)
注意:该示例可以在 SimpleParamTests 中找到。
与组相关的断言在一起
断言链接和相关的代码缩进对测试的清晰度和可读性有很大帮助。
不良做法
当我们编写测试时,我们最终会得到正确但可读性较差的测试。让我们想象一个测试,我们想要在其中查找国家/地区并进行以下检查:
计算找到的国家/地区。
使用多个值断言第一个条目。
此类测试可能如下所示:
@Test
void listCountries() {
List<Country> result = ...;
assertThat(result).hasSize(5);
var country = result.get(0);
assertThat(country.getName()).isEqualTo("Spain");
assertThat(country.getCities().stream().map(City::getName)).contains("Barcelona");
良好做法
尽管前面的测试是正确的,但我们应该通过将相关断言组合在一起(第 9-11 行)来大大提高可读性。这里的目标是断言一次,并根据需要编写许多链接断言。请参阅下面的修改版本。result
@Test
void listCountries() {
List<Country> result = ...;
assertThat(result)
.hasSize(5)
.singleElement()
.satisfies(c -> {
assertThat(c.getName()).isEqualTo("Spain");
assertThat(c.getCities().stream().map(City::getName)).contains("Barcelona");
});
}
注意:该示例可以在 CountryRepositoryOtherTests 中找到。
防止误报成功测试
当使用任何带有参数的断言方法时,参数也必须包含在使用者中。否则,测试将一直通过 - 即使比较失败,这意味着错误的测试。仅当断言引发 or 异常时,测试才会失败。我想这很清楚,但很容易忘记它并写错测试。它不时发生在我身上。ThrowingConsumer assertThat RuntimeException AssertionError
不良做法
假设我们有几个国家/地区代码,我们想要验证每个代码是否满足某些条件。在我们的虚拟案例中,我们想断言每个国家/地区代码都包含“a”字符。正如你所看到的,这是无稽之谈:我们有大写的代码,但我们没有在断言中应用不区分大小写。
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> countryCode.contains("a"));
}
令人惊讶的是,我们的测试成功通过了。
良好做法
如本节开头所述,我们的测试可以通过消费者中的附加功能轻松纠正(第 7 行)。正确的测试应该是这样的:assertThat
@Test
void assertValues() throws Exception {
var countryCodes = List.of("CZ", "AT", "CA");
assertThat( countryCodes )
.hasSize(3)
.allSatisfy(countryCode -> assertThat( countryCode ).containsIgnoringCase("a"));
}
现在,测试按预期失败,并显示正确的错误消息。
java.lang.AssertionError:
Expecting all elements of:
["CZ", "AT", "CA"]
to satisfy given requirements, but these elements did not:
"CZ"
error:
Expecting actual:
"CZ"
to contain:
"a"
(ignoring case)
at com.github.aha.sat.core.clr.AppleTest.assertValues(AppleTest.java:45)
链断言
最后一个提示不是真正的实践,而是建议。应使用 AssertJ fluent API 来创建更具可读性的测试。
非链接断言
让我们考虑一下 test,其目的是测试组件的日志记录。这里的目标是检查:listLogs
断言收集的日志数
断言存在和日志消息DEBU GINFO
@Test
void listLogs() throws Exception {
ListAppender<ILoggingEvent> logAppender = ...;
assertThat( logAppender.list ).hasSize(2);
assertThat( logAppender.list ).anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
});
assertThat( logAppender.list ).anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(INFO);
assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
});
}
链接断言
通过上面提到的 Fluent API 和链接,我们可以这样更改测试:
@Test
void listLogs() throws Exception {
ListAppender<ILoggingEvent> logAppender = ...;
assertThat( logAppender.list )
.hasSize(2)
.anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(DEBUG);
assertThat( logEntry.getFormattedMessage() ).startsWith("Initializing Apple");
})
.anySatisfy(logEntry -> {
assertThat( logEntry.getLevel() ).isEqualTo(INFO);
assertThat( logEntry.getFormattedMessage() ).isEqualTo("Here's Apple runner" );
});
}
注意:该示例可在 AppleTest 中找到。
摘要和源代码
AssertJ 框架为其流畅的 API 提供了很多帮助。在本文中,提供了一些技巧和提示,以产生更清晰、更可靠的测试。请注意,这些建议大多是主观的。这取决于个人喜好和代码风格。