使用 JUnit 進行健壯單元和集成測試的指南
已發表: 2022-03-11自動化軟件測試對於軟件項目的長期質量、可維護性和可擴展性至關重要,對於 Java,JUnit 是實現自動化的途徑。
雖然本文的大部分內容將側重於編寫健壯的單元測試和利用存根、模擬和依賴注入,但我們還將討論 JUnit 和集成測試。
JUnit 測試框架是一個通用的、免費的、開源的工具,用於測試基於 Java 的項目。
在撰寫本文時,JUnit 4 是當前的主要版本,已在 10 多年前發布,最近一次更新是在兩年多前。
JUnit 5(帶有 Jupiter 編程和擴展模型)正在積極開發中。 它更好地支持 Java 8 中引入的語言特性,並包含其他新的、有趣的特性。 一些團隊可能會發現 JUnit 5 已準備好使用,而其他團隊可能會繼續使用 JUnit 4 直到 5 正式發布。 我們將看看兩者的例子。
運行 JUnit
JUnit 測試可以直接在 IntelliJ 中運行,但也可以在 Eclipse、NetBeans 甚至命令行等其他 IDE 中運行。
測試應始終在構建時運行,尤其是單元測試。 任何測試失敗的構建都應該被認為是失敗的,無論問題是在生產中還是在測試代碼中——這需要團隊的紀律和對解決失敗測試給予最高優先級的意願,但有必要遵守自動化的精神。
JUnit 測試也可以由 Jenkins 等持續集成系統運行和報告。 使用 Gradle、Maven 或 Ant 等工具的項目具有額外的優勢,即能夠在構建過程中運行測試。
搖籃
作為 JUnit 5 的示例 Gradle 項目,請參閱 JUnit 用戶指南的 Gradle 部分和 junit5-samples.git 存儲庫。 請注意,它還可以運行使用 JUnit 4 API(稱為“vintage” )的測試。
可以通過菜單選項 File > Open... 在 IntelliJ 中創建項目 > 導航到junit-gradle-consumer sub-directory > OK > Open as Project > OK 以從 Gradle 導入項目。
對於 Eclipse,可以從 Help > Eclipse Marketplace... 安裝 Buildship Gradle 插件。然後可以使用 File > Import... > Gradle > Gradle Project > Next > Next > 瀏覽到junit-gradle-consumer子目錄 > Next 導入項目> 下一步 > 完成。
在 IntelliJ 或 Eclipse 中設置 Gradle 項目後,運行 Gradle build任務將包括使用test任務運行所有 JUnit 測試。 請注意,如果未對代碼進行任何更改,則後續執行build時可能會跳過測試。
對於 JUnit 4,請參閱 JUnit 與 Gradle wiki 的使用。
馬文
對於 JUnit 5,請參閱用戶指南的 Maven 部分和 junit5-samples.git 存儲庫以獲取 Maven 項目的示例。 這也可以運行老式測試(使用 JUnit 4 API 的測試)。
在 IntelliJ 中,使用 File > Open... > 導航到junit-maven-consumer/pom.xml > OK > Open as Project。 然後可以從 Maven Projects > junit5-maven-consumer > Lifecycle > Test 運行測試。
在 Eclipse 中,使用 File > Import... > Maven > Existing Maven Projects > Next > 瀏覽到junit-maven-consumer目錄 > 選擇pom.xml > Finish。
可以通過將項目作為 Maven 構建運行來執行測試... > 指定test目標 > 運行。
對於 JUnit 4,請參閱 Maven 存儲庫中的 JUnit。
開發環境
除了通過 Gradle 或 Maven 等構建工具運行測試外,許多 IDE 還可以直接運行 JUnit 測試。
IntelliJ IDEA
JUnit 5 測試需要 IntelliJ IDEA 2016.2 或更高版本,而 JUnit 4 測試應該在較舊的 IntelliJ 版本中工作。
出於本文的目的,您可能希望從我的一個 GitHub 存儲庫(JUnit5IntelliJ.git 或 JUnit4IntelliJ.git)在 IntelliJ 中創建一個新項目,其中包括簡單Person類示例中的所有文件並使用內置JUnit 庫。 可以使用 Run > Run 'All Tests' 運行測試。 該測試也可以從PersonTest類在 IntelliJ 中運行。
這些存儲庫是使用新的 IntelliJ Java 項目創建的,並構建了目錄結構src/main/java/com/example和src/test/java/com/example 。 src/main/java目錄被指定為源文件夾,而src/test/java被指定為測試源文件夾。 在使用帶有@Test註釋的測試方法創建PersonTest類後,它可能無法編譯,在這種情況下,IntelliJ 建議將 JUnit 4 或 JUnit 5 添加到可以從 IntelliJ IDEA 發行版加載的類路徑中(請參閱這些有關更多詳細信息的 Stack Overflow 上的答案)。 最後,為所有測試添加了一個 JUnit 運行配置。
另請參閱 IntelliJ 測試方法指南。
蝕
Eclipse 中的空 Java 項目將沒有測試根目錄。 這已從項目屬性 > Java 構建路徑 > 添加文件夾... > 創建新文件夾... > 指定文件夾名稱 > 完成添加。 新目錄將被選為源文件夾。 在剩餘的兩個對話框中單擊“確定”。
可以使用 File > New > JUnit Test Case 創建 JUnit 4 測試。 選擇“New JUnit 4 test”和新創建的測試源文件夾。 指定“被測類”和“包”,確保包與被測類匹配。 然後,指定測試類的名稱。 完成嚮導後,如果出現提示,請選擇“將 JUnit 4 庫”添加到構建路徑。 然後可以將項目或單個測試類作為 JUnit 測試運行。 另請參閱 Eclipse 編寫和運行 JUnit 測試。
NetBeans
NetBeans 僅支持 JUnit 4 測試。 可以使用 File > New File... > Unit Tests > JUnit Test 或 Test for Existing Class 在 NetBeans Java 項目中創建測試類。 默認情況下,測試根目錄在項目目錄中命名為test 。
簡單生產類及其 JUnit 測試用例
讓我們看一個非常簡單的Person類的生產代碼及其對應的單元測試代碼的簡單示例。 您可以從我的 github 項目中下載示例代碼並通過 IntelliJ 打開它。
src/main/java/com/example/Person.java
package com.example; class Person { private final String givenName; private final String surname; Person(String givenName, String surname) { this.givenName = givenName; this.surname = surname; } String getDisplayName() { return surname + ", " + givenName; } } 不可變的Person類有一個構造函數和一個getDisplayName()方法。 我們想測試getDisplayName()返回的名稱是否符合我們的預期。 這是單個單元測試(JUnit 5)的測試代碼:
src/test/java/com/example/PersonTest.java
package com.example; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class PersonTest { @Test void testGetDisplayName() { Person person = new Person("Josh", "Hayden"); String displayName = person.getDisplayName(); assertEquals("Hayden, Josh", displayName); } } PersonTest使用 JUnit 5 的@Test和斷言。 對於 JUnit 4, PersonTest類和方法需要是公共的,並且應該使用不同的導入。 這是 JUnit 4 示例要點。
在 IntelliJ 中運行PersonTest類後,測試通過並且 UI 指示器為綠色。
常見的 JUnit 約定
命名
雖然不是必需的,但我們在命名測試類時使用通用約定; 具體來說,我們從正在測試的類的名稱( Person )開始,並在其上附加“Test”( PersonTest )。 命名測試方法是相似的,從被測試的方法開始( getDisplayName() )並在它前面加上“test”( testGetDisplayName() )。 雖然命名測試方法還有許多其他完全可以接受的約定,但在團隊和項目中保持一致很重要。
| 生產名稱 | 測試中的名稱 |
|---|---|
| 人 | 人員測試 |
getDisplayName() | testDisplayName() |
套餐
我們還採用在與生產代碼的Person類相同的包 ( com.example ) 中創建測試代碼PersonTest類的約定。 如果我們使用不同的包進行測試,我們將被要求在生產代碼類、構造函數和單元測試引用的方法中使用 public 訪問修飾符,即使它不合適,所以最好將它們放在同一個包中. 但是,我們確實使用單獨的源目錄( src/main/java和src/test/java ),因為我們通常不希望在發布的生產版本中包含測試代碼。
結構和註釋
@Test註解 (JUnit 4/5) 告訴 JUnit 執行testGetDisplayName()方法作為測試方法並報告它是通過還是失敗。 只要所有斷言(如果有的話)都通過並且沒有拋出異常,則認為測試通過。
我們的測試代碼遵循 Arrange-Act-Assert (AAA) 的結構模式。 其他常見模式包括 Given-When-Then 和 Setup-Exercise-Verify-Teardown(單元測試通常不明確需要 Teardown),但我們在本文中使用 AAA。
讓我們看看我們的測試示例是如何遵循 AAA 的。 第一行,“arrange”創建了一個將被測試的Person對象:
Person person = new Person("Josh", "Hayden"); 第二行,“act”,練習生產代碼的Person.getDisplayName()方法:
String displayName = person.getDisplayName();第三行,“assert”,驗證結果是否符合預期。
assertEquals("Hayden, Josh", displayName); 在內部, assertEquals()調用使用“Hayden, Josh” String 對象的 equals 方法來驗證從生產代碼 ( displayName ) 返回的實際值是否匹配。 如果不匹配,則測試將被標記為失敗。
請注意,對於這些 AAA 階段中的每一個,測試通常有不止一條線。
單元測試和生產代碼
現在我們已經介紹了一些測試約定,讓我們將注意力轉向使生產代碼可測試。
我們回到我們的Person類,在那裡我實現了一個方法來根據他或她的出生日期返回一個人的年齡。 代碼示例需要 Java 8 才能利用新的日期和功能 API。 下面是新的Person.java類的樣子:
人.java
// ... class Person { // ... private final LocalDate dateOfBirth; Person(String givenName, String surname, LocalDate dateOfBirth) { // ... this.dateOfBirth = dateOfBirth; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, LocalDate.now()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } }運行這個課程(在撰寫本文時)宣布喬伊 4 歲。 讓我們添加一個測試方法:
PersonTest.java
// ... class PersonTest { // ... @Test void testGetAge() { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); long age = person.getAge(); assertEquals(4, age); } }它今天過去了,但是當我們從現在開始一年後運行它時呢? 該測試具有不確定性和脆弱性,因為預期結果取決於運行測試的系統的當前日期。
存根和注入價值提供者
在生產環境中運行時,我們希望使用當前日期LocalDate.now()來計算人員的年齡,但即使在一年後進行確定性測試,測試也需要提供自己的currentDate值。
這稱為依賴注入。 我們不希望我們的Person對象確定當前日期本身,而是希望將此邏輯作為依賴項傳入。 單元測試將使用一個已知的存根值,而生產代碼將允許系統在運行時提供實際值。
讓我們向Person.java添加一個LocalDate供應商:
人.java
// ... class Person { // ... private final LocalDate dateOfBirth; private final Supplier<LocalDate> currentDateSupplier; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier) { // ... this.dateOfBirth = dateOfBirth; this.currentDateSupplier = currentDateSupplier; } // ... long getAge() { return ChronoUnit.YEARS.between(dateOfBirth, currentDateSupplier.get()); } public static void main(String... args) { Person person = new Person("Joey", "Doe", LocalDate.parse("2013-01-12")); System.out.println(person.getDisplayName() + ": " + person.getAge() + " years"); // Doe, Joey: 4 years } } 為了更容易測試getAge()方法,我們將其更改為使用LocalDate供應商currentDateSupplier來檢索當前日期。 如果您不知道供應商是什麼,我建議您閱讀有關 Lambda 內置功能接口的內容。
我們還添加了依賴注入:新的測試構造函數允許測試提供自己的當前日期值。 原來的構造函數調用這個新的構造函數,傳遞一個LocalDate::now的靜態方法引用,它提供了一個LocalDate對象,所以我們的 main 方法仍然像以前一樣工作。 我們的測試方法呢? 讓我們更新PersonTest.java :
PersonTest.java
// ... class PersonTest { // ... @Test void testGetAge() { LocalDate dateOfBirth = LocalDate.parse("2013-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-17"); Person person = new Person("Joey", "Doe", dateOfBirth, ()->currentDate); long age = person.getAge(); assertEquals(4, age); } } 測試現在註入了它自己的currentDate值,所以我們的測試在明年或任何一年運行時仍然會通過。 這通常稱為存根,或提供要返回的已知值,但我們首先必須更改Person以允許注入此依賴項。
構造Person對象時,請注意 lambda 語法 ( ()->currentDate )。 根據新構造函數的要求,這被視為LocalDate的提供者。
模擬和存根 Web 服務
我們已經準備好讓我們的Person對象(其整個存在都在 JVM 內存中)與外部世界進行通信。 我們要添加兩個方法: publishAge()方法,它將發布此人的當前年齡,以及getThoseInCommon()方法,它將返回與我們的Person生日相同或年齡相同的名人的姓名。 假設有一個我們可以與之交互的 RESTful 服務,稱為“人物生日”。 我們有一個 Java 客戶端,它由單個類BirthdaysClient組成。
com.example.birthdays.BirthdaysClient
package com.example.birthdays; import java.io.IOException; import java.util.Arrays; import java.util.Collection; public class BirthdaysClient { public void publishRegularPersonAge(String name, long age) throws IOException { System.out.println("publishing " + name + "'s age: " + age); // HTTP POST with name and age and possibly throw an exception } public Collection<String> findFamousNamesOfAge(long age) throws IOException { System.out.println("finding famous names of age " + age); return Arrays.asList(/* HTTP GET with age and possibly throw an exception */); } public Collection<String> findFamousNamesBornOn(int month, int dayOfMonth) throws IOException { System.out.println("finding famous names born on day " + dayOfMonth + " of month " + month); return Arrays.asList(/* HTTP GET with month and day and possibly throw an exception */); } } 讓我們增強我們的Person類。 我們首先為publishAge()的期望行為添加一個新的測試方法。 為什麼要從測試而不是功能開始? 我們遵循測試驅動開發(也稱為 TDD)的原則,其中我們首先編寫測試,然後編寫代碼以使其通過。
PersonTest.java
// … class PersonTest { // … @Test void testPublishAge() { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate); person.publishAge(); } } 此時,測試代碼無法編譯,因為我們還沒有創建它正在調用的publishAge()方法。 一旦我們創建了一個空的Person.publishAge()方法,一切都會過去。 我們現在準備好進行測試,以驗證此人的年齡是否實際發佈到了BirthdaysClient 。

添加模擬對象
由於這是一個單元測試,它應該在內存中快速運行,因此測試將使用模擬的BirthdaysClient構造我們的Person對象,因此它實際上不會發出 Web 請求。 然後測試將使用這個模擬對象來驗證它是否按預期調用。 為此,我們將添加對 Mockito 框架(MIT 許可)的依賴項以創建模擬對象,然後創建一個模擬的BirthdaysClient對象:
PersonTest.java
// ... import com.example.birthdays.BirthdaysClient; // ... import static org.mockito.Mockito.mock; class PersonTest { private BirthdaysClient birthdaysClient = mock(BirthdaysClient.class); // ... @Test void testPublishAge() { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); // ... } } 我們進一步擴充了Person構造函數的簽名以獲取一個BirthdaysClient對象,並更改了測試以注入模擬的BirthdaysClient對象。
添加模擬期望
接下來,我們在testPublishAge的末尾添加一個期望調用BirthdaysClient的內容。 Person.publishAge()應該調用它,如我們的新PersonTest.java所示:
PersonTest.java
// ... class PersonTest { // ... @Test void testPublishAge() throws IOException { // ... Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); verifyZeroInteractions(birthdaysClient); person.publishAge(); verify(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); } } 我們的 Mockito 增強的BirthdaysClient跟踪對其方法進行的所有調用,這就是我們在調用publishAge()之前使用verifyZeroInteractions()方法驗證沒有對BirthdaysClient進行的調用的方式。 儘管可以說沒有必要,但通過這樣做,我們可以確保構造函數不會進行任何惡意調用。 在verify()行中,我們指定了期望對BirthdaysClient的調用的外觀。
請注意,因為 publishRegularPersonAge 在其簽名中具有 IOException,所以我們也將其添加到我們的測試方法簽名中。
此時,測試失敗:
Wanted but not invoked: birthdaysClient.publishRegularPersonAge( "Joe Sixteen", 16L ); -> at com.example.PersonTest.testPublishAge(PersonTest.java:40) 這是意料之中的,因為我們尚未實現對Person.java的所需更改,因為我們正在關注測試驅動的開發。 現在,我們將通過進行必要的更改來通過此測試:
人.java
// ... class Person { // ... private final BirthdaysClient birthdaysClient; Person(String givenName, String surname, LocalDate dateOfBirth) { this(givenName, surname, dateOfBirth, LocalDate::now, new BirthdaysClient()); } // Visible for testing Person(String givenName, String surname, LocalDate dateOfBirth, Supplier<LocalDate> currentDateSupplier, BirthdaysClient birthdaysClient) { // ... this.birthdaysClient = birthdaysClient; } // ... void publishAge() { String nameToPublish = givenName + " " + surname; long age = getAge(); try { birthdaysClient.publishRegularPersonAge(nameToPublish, age); } catch (IOException e) { // TODO handle this! e.printStackTrace(); } } }測試異常
我們讓生產代碼構造函數實例化了一個新的BirthdaysClient ,並且publishAge()現在調用了birthdaysClient 。 所有測試通過; 一切都是綠色的。 偉大的! 但請注意, publishAge()正在吞噬 IOException。 我們不想讓它冒泡,而是用我們自己的 PersonException 將它包裝在一個名為PersonException.java的新文件中:
PersonException.java
package com.example; public class PersonException extends Exception { public PersonException(String message, Throwable cause) { super(message, cause); } } 我們將此場景實現為PersonTest.java中的新測試方法:
PersonTest.java
// ... class PersonTest { // ... @Test void testPublishAge_IOException() throws IOException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); IOException ioException = new IOException(); doThrow(ioException).when(birthdaysClient).publishRegularPersonAge("Joe Sixteen", 16); try { person.publishAge(); fail("expected exception not thrown"); } catch (PersonException e) { assertSame(ioException, e.getCause()); assertEquals("Failed to publish Joe Sixteen age 16", e.getMessage()); } } } 當調用publishRegularPersonAge()方法時,Mockito doThrow()調用birthdaysClient以引發異常。 如果沒有拋出PersonException ,我們將無法通過測試。 否則,我們斷言異常與 IOException 正確鏈接,並驗證異常消息是否符合預期。 現在,因為我們沒有在生產代碼中實現任何處理,所以我們的測試失敗了,因為沒有拋出預期的異常。 以下是我們需要在Person.java中更改以使測試通過的內容:
人.java
// ... class Person { // ... void publishAge() throws PersonException { // ... try { // ... } catch (IOException e) { throw new PersonException("Failed to publish " + nameToPublish + " age " + age, e); } } }存根:時間和斷言
我們現在實現Person.getThoseInCommon()方法,使我們的Person.Java類看起來像這樣。
我們的testGetThoseInCommon()與testPublishAge()不同,它不會驗證是否對birthdaysClient方法進行了特定調用。 相反,它when調用存根返回值時使用 getThoseInCommon( getThoseInCommon()需要調用的findFamousNamesOfAge()和findFamousNamesBornOn() 。 然後我們斷言我們提供的所有三個存根名稱都被返回。
使用assertAll() JUnit 5 方法包裝多個斷言允許將所有斷言作為一個整體進行檢查,而不是在第一個失敗的斷言之後停止。 我們還包含一條帶有assertTrue()的消息,以識別未包含的特定名稱。 下面是我們的“快樂路徑”(理想場景)測試方法的樣子(注意,這不是一組具有“快樂路徑”性質的穩健測試,但我們稍後會討論原因。
PersonTest.java
// ... class PersonTest { // ... @Test void testGetThoseInCommon() throws IOException, PersonException { LocalDate dateOfBirth = LocalDate.parse("2000-01-02"); LocalDate currentDate = LocalDate.parse("2017-01-01"); Person person = new Person("Joe", "Sixteen", dateOfBirth, ()->currentDate, birthdaysClient); when(birthdaysClient.findFamousNamesOfAge(16)).thenReturn(Arrays.asList("JoeFamous Sixteen", "Another Person")); when(birthdaysClient.findFamousNamesBornOn(1, 2)).thenReturn(Arrays.asList("Jan TwoKnown")); Set<String> thoseInCommon = person.getThoseInCommon(); assertAll( setContains(thoseInCommon, "Another Person"), setContains(thoseInCommon, "Jan TwoKnown"), setContains(thoseInCommon, "JoeFamous Sixteen"), ()-> assertEquals(3, thoseInCommon.size()) ); } private <T> Executable setContains(Set<T> set, T expected) { return () -> assertTrue(set.contains(expected), "Should contain " + expected); } // ... }保持測試代碼乾淨
儘管經常被忽視,但同樣重要的是要避免測試代碼出現惡化的重複。 乾淨的代碼和“不要重複自己”之類的原則對於維護高質量的代碼庫、生產和測試代碼都非常重要。 請注意,最近的 PersonTest.java 有一些重複,因為我們有幾個測試方法。
為了解決這個問題,我們可以做一些事情:
將 IOException 對象提取到私有 final 字段中。
將
Person對象的創建提取到它自己的方法(在本例中為createJoeSixteenJan2(),因為大多數 Person 對像都是使用相同的參數創建的。為驗證拋出的
PersonExceptions的各種測試創建一個assertCauseAndMessage()。
乾淨的代碼結果可以在 PersonTest.java 文件的這個再現中看到。
測試不僅僅是快樂的道路
當Person對象的出生日期晚於當前日期時,我們應該怎麼做? 應用程序中的缺陷通常是由於意外輸入或對角落、邊緣或邊界情況缺乏遠見。 盡可能地預測這些情況是很重要的,而單元測試通常是這樣做的合適地方。 在構建我們的Person和PersonTest時,我們包含了一些針對預期異常的測試,但這絕不是完整的。 例如,我們使用不表示或存儲時區數據的LocalDate 。 但是,我們對LocalDate.now()的調用會根據系統的默認時區返回一個LocalDate ,該時區可能比系統用戶的時區早一天或晚一天。 在實施適當的測試和行為時,應考慮這些因素。
邊界也應該被測試。 考慮一個帶有getDaysUntilBirthday()方法的Person對象。 測試應包括該人的生日是否在當年已經過去,該人的生日是否是今天,以及閏年如何影響天數。 這些場景可以通過檢查人的生日前一天、生日當天和生日後一天來覆蓋,其中下一年是閏年。 下面是相關的測試代碼:
PersonTest.java
// ... class PersonTest { private final Supplier<LocalDate> currentDateSupplier = ()-> LocalDate.parse("2015-05-02"); private final LocalDate ageJustOver5 = LocalDate.parse("2010-05-01"); private final LocalDate ageExactly5 = LocalDate.parse("2010-05-02"); private final LocalDate ageAlmost5 = LocalDate.parse("2010-05-03"); // ... @Test void testGetDaysUntilBirthday() { assertAll( createPersonAndAssertValue(ageAlmost5, 1, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageExactly5, 0, Person::getDaysUntilBirthday), createPersonAndAssertValue(ageJustOver5, 365, Person::getDaysUntilBirthday) ); } private Executable createPersonAndAssertValue(LocalDate dateOfBirth, long expectedValue, Function<Person, Long> personLongFunction) { Person person = new Person("Given", "Sur", dateOfBirth, currentDateSupplier); long actualValue = personLongFunction.apply(person); return () -> assertEquals(expectedValue, actualValue); } }集成測試
我們主要關注單元測試,但 JUnit 也可用於集成、驗收、功能和系統測試。 這樣的測試通常需要更多的設置代碼,例如,啟動服務器、使用已知數據加載數據庫等。雖然我們通常可以在幾秒鐘內運行數千個單元測試,但大型集成測試套件可能需要幾分鐘甚至幾小時才能運行。 通常不應使用集成測試來嘗試覆蓋代碼中的每個排列或路徑; 單元測試更適合於此。
為驅動 Web 瀏覽器填寫表單、單擊按鈕、等待內容加載等的 Web 應用程序創建測試通常使用 Selenium WebDriver(Apache 2.0 許可)和“頁面對像模式”(參見 SeleniumHQ github wiki和 Martin Fowler 關於頁面對象的文章)。
JUnit 可以有效地使用 HTTP 客戶端(如 Apache HTTP 客戶端或 Spring Rest 模板)測試 RESTful API(HowToDoInJava.com 提供了一個很好的示例)。
在我們使用Person對象的情況下,集成測試可能涉及使用真實的BirthdaysClient而不是模擬的,其配置指定了 People Birthdays 服務的基本 URL。 然後,集成測試將使用此類服務的測試實例,驗證生日是否已發布給它,並在將返回的服務中創建名人。
其他 JUnit 功能
JUnit 有許多我們尚未在示例中探索的附加特性。 我們將描述一些並為其他人提供參考。
測試夾具
應該注意的是,JUnit 為運行每個@Test方法創建了一個新的測試類實例。 JUnit 還提供了註解掛鉤來在所有或每個@Test方法之前或之後運行特定方法。 這些鉤子通常用於設置或清理數據庫或模擬對象,並且在 JUnit 4 和 5 之間有所不同。
| JUnit 4 | JUnit 5 | 對於靜態方法? |
|---|---|---|
@BeforeClass | @BeforeAll | 是的 |
@AfterClass | @AfterAll | 是的 |
@Before | @BeforeEach | 不 |
@After | @AfterEach | 不 |
在我們的PersonTest示例中,我們選擇在@Test方法本身中配置BirthdaysClient模擬對象,但有時需要構建涉及多個對象的更複雜的模擬結構。 @BeforeEach (在 JUnit 5 中)和@Before (在 JUnit 4 中)通常適用於此。
@After*註釋在集成測試中比單元測試更常見,因為 JVM 垃圾收集處理為單元測試創建的大多數對象。 @BeforeClass和@BeforeAll註釋最常用於需要執行一次昂貴的設置和拆卸操作的集成測試,而不是針對每個測試方法。
對於 JUnit 4,請參閱測試夾具指南(一般概念仍然適用於 JUnit 5)。
測試套件
有時你想運行多個相關的測試,但不是所有的測試。 在這種情況下,可以將測試分組組成測試套件。 有關如何在 JUnit 5 中執行此操作,請查看 HowToProgram.xyz 的 JUnit 5 文章以及 JUnit 團隊的 JUnit 4 文檔。
JUnit 5 的 @Nested 和 @DisplayName
JUnit 5 添加了使用非靜態嵌套內部類的功能,以更好地顯示測試之間的關係。 這對於那些在 Jasmine for JavaScript 等測試框架中使用過嵌套描述的人來說應該非常熟悉。 內部類使用@Nested註釋以使用它。
@DisplayName註釋也是 JUnit 5 的新增功能,允許您以字符串格式描述測試以報告,除了測試方法標識符之外,還可以顯示。
儘管@Nested和@DisplayName可以相互獨立使用,但它們一起可以提供描述系統行為的更清晰的測試結果。
Hamcrest 火柴人
Hamcrest 框架雖然本身不是 JUnit 代碼庫的一部分,但它提供了在測試中使用傳統斷言方法的替代方案,允許更具表現力和可讀性的測試代碼。 請參閱以下使用傳統 assertEquals 和 Hamcrest assertThat 的驗證:
//Traditional assert assertEquals("Hayden, Josh", displayName); //Hamcrest assert assertThat(displayName, equalTo("Hayden, Josh"));Hamcrest 可以與 JUnit 4 和 5 一起使用。Vogella.com 關於 Hamcrest 的教程非常全面。
其他資源
單元測試、如何編寫可測試代碼及其重要性一文涵蓋了編寫乾淨、可測試代碼的更具體示例。
Build with Confidence: A Guide to JUnit Tests 檢查了單元和集成測試的不同方法,以及為什麼最好選擇一種並堅持下去
JUnit 4 Wiki 和 JUnit 5 用戶指南始終是一個很好的參考點。
Mockito 文檔提供了有關附加功能和示例的信息。
JUnit 是通往自動化的道路
我們已經探索了 Java 世界中使用 JUnit 進行測試的許多方面。 我們研究了使用 Java 代碼庫的 JUnit 框架進行單元和集成測試,將 JUnit 集成到開發和構建環境中,如何在供應商和 Mockito 中使用模擬和存根,常見約定和最佳代碼實踐,測試什麼,以及一些其他出色的 JUnit 功能。
現在輪到讀者熟練地應用、維護和收穫使用 JUnit 框架的自動化測試的好處了。
