單元測試已成為開發的標準部分。有許多工具可用於此目的,並且有多種不同的使用方式。本文展示了一些對我有效的提示或最佳實踐。
在本文中,您將學到
- 如何使用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及其fluent 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