单元测试已成为开发的标准组成部分。有多种工具可以以不同方式用于单元测试。本文将展示一些对我有效的提示或最佳实践。
在本文中,你将学习
- 如何使用JUnit和断言框架编写清晰易读的单元测试
- 如何在某些情况下避免假阳性测试
- 编写单元测试时应避免的事项
不要过度使用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
断言并无实际害处,但应避免使用,原因如下:
- 它不增加任何额外价值。它只是增加了阅读和维护的代码量。
- 当
service
为null
时,测试无论如何都会失败,并且我们看到了失败的真正根源。测试仍然达到了其目的。 - 使用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中找到。
防止假阳性成功测试
当使用任何带有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"));
}
令人惊讶的是,我们的测试成功通过了。
正确实践
如本节开头所述,通过在消费者中添加额外的assertThat
(第7行),我们的测试可以轻松修正。正确的测试应如下所示:
@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的流畅API来创建更具可读性的测试。
非链式断言
考虑listLogs
测试,其目的是测试组件的日志记录。这里的目的是检查:
- 声明收集的日志数量
- 断言存在
DEBUG
和INFO
日志消息
@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" );
});
}
链接断言
通过提及的流式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提供了大量帮助。本文中,为了编写更清晰、更可靠的测试,提供了若干技巧和提示。请注意,这些建议大多具有主观性,取决于个人偏好和代码风格。
所用源码可在我的仓库中找到:
Source:
https://dzone.com/articles/hints-for-unit-testing-with-assertj