通過示例測試

已發表: 2022-02-15

我們將繼續撰寫有關與測試相關的所有內容的系列博客。 在本博客中,我們將重點放在真實示例上。

雖然本文中的示例是使用 JUnit 5 和 AssertJ 編寫的,但這些課程適用於任何其他單元測試框架。

JUnit 是最流行的 Java 測試框架。 AssertJ 是一個 Java 庫,可幫助開發人員編寫更具表現力的測試。

基本測試結構

我們將查看的第一個測試示例是一個簡單的計算器,用於將 2 個數字相加。

 類計算器應該{

     @Test // 1
     無效總和(){
         計算器計算器 = new Calculator(); // 2
         int 結果 = 計算器.sum(1, 2); // 3
         assertThat(結果).isEqualTo(3); // 4
     }
}

我更喜歡在編寫測試時使用ClassShould命名約定,以避免在每個方法名稱中重複shouldtest 你可以在這裡讀更多關於它的內容。

上面的測試是做什麼的?

讓我們逐行打破測試:

  1. @Test註解讓 JUnit 框架知道哪些方法應該作為測試運行。 在測試類中擁有不是測試的private方法是完全正常的。
  2. 這是我們測試的安排階段,我們準備測試環境。 這個測試我們只需要一個Calculator實例。
  3. 這是我們觸發我們想要測試的行為的行為階段。
  4. 這是我們檢查發生了什麼以及一切是否按預期解決的斷言階段。 assertThat(result)方法是 AssertJ 庫的一部分,並且有多個重載。

每個重載都返回一個專門的Assert對象。 返回的對象具有對我們傳遞給assertThat方法的對像有意義的方法。 在我們的例子中,該對像是AbstractIntegerAssert ,帶有用於測試整數的方法。 isEqualTo(3)將檢查result == 3 。 如果是,則測試將通過,否則將失敗。

在這篇博文中,我們不會關注任何實現。

關於ArrangeActAssert的另一種思考方式是GivenWhenThen

寫完sum實現後,我們可以問自己一些問題:

  • 我怎樣才能改進這個測試?
  • 還有更多我應該涵蓋的測試用例嗎?
  • 如果我添加一個正數和一個負數會發生什麼? 兩個負數? 一正一負?
  • 如果我溢出整數值怎麼辦?

讓我們添加這些案例並稍微改進現有的測試名稱。

我們不會在我們的實現中允許溢出。 如果sum溢出,我們將拋出ArithmeticException

 類計算器應該{

     私人計算器計算器=新計算器();

     @測試
     無效 sumPositiveNumbers() {
         int sum =calculator.sum(1, 2);
         assertThat(sum).isEqualTo(3);
     }

     @測試
     無效 sumNegativeNumbers() {
         int sum =calculator.sum(-1, -1);
         assertThat(sum).isEqualTo(-2);
     }

     @測試
     無效 sumPositiveAndNegativeNumbers() {
         int sum =calculator.sum(1, -2);
         assertThat(sum).isEqualTo(-1);
     }

     @測試
     無效 failWithArithmeticExceptionWhenOverflown() {
         assertThatThrownBy(() ->calculator.sum(Integer.MAX_VALUE, 1))
             .isInstanceOf(ArithmeticException.class);
     } 

}

JUnit 將在運行每個@Test方法之前創建一個新的CalculatorShould實例。 這意味著每個CalculatorShould將有一個不同的calculator ,因此我們不必在每個測試中都實例化它。

shouldFailWithArithmeticExceptionWhenOverflown測試使用不同類型的assert 。 它檢查一段代碼是否失敗。 assertThatThrownBy方法將運行我們提供的 lambda 並確保它失敗。 我們已經知道,所有的assertThat方法都返回一個專門的Assert允許我們檢查發生了哪種類型的異常。

這是一個示例,說明我們如何測試我們的代碼是否在我們期望的時候失敗。 如果在任何時候我們重構Calculator並且它沒有在溢出時拋出ArithmeticException ,我們的測試將失敗。

ObjectMother 設計模式

下一個示例是用於確保 Person 實例有效的驗證器類。

 類 PersonValidatorShould {

    私人 PersonValidator 驗證器 = new PersonValidator();

    @測試
    無效失敗時名稱IsNull(){
        Person person = new Person(null, 20, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf(InvalidPersonException.class);
    }

    @測試
    無效的失敗時年齡(){
        Person person = new Person("John", -5, new Address(...), ...);

        assertThatThrownBy(() -> validator.validate(person))
            .isInstanceOf(InvalidPersonException.class);

    }
}

ObjectMother設計模式通常用於創建複雜對象的測試中以隱藏測試中的實例化細節。 多個測試甚至可能創建同一個對象,但在其上測試不同的東西。

測試#1 與測試#2 非常相似。 我們可以通過將驗證提取為私有方法來重構PersonValidatorShould ,然後將非法的Person實例傳遞給它,期望它們都以相同的方式失敗。

 類 PersonValidatorShould {

     私人 PersonValidator 驗證器 = new PersonValidator();

     @測試
     無效失敗時名稱IsNull(){
         shouldFailValidation(PersonObjectMother.createPersonWithoutName());
     }

     @測試
     無效的失敗時年齡(){
         shouldFailValidation(PersonObjectMother.createPersonWithNegativeAge());
     }

     私人無效應該失敗驗證(人員無效人員){
         assertThatThrownBy(() -> validator.validate(invalidPerson))
             .isInstanceOf(InvalidPersonException.class);
   
     }
 }

測試隨機性

我們應該如何測試代碼中的隨機性?

假設我們有一個PersonGenerator ,它具有generateRandom來生成隨機Person實例。

我們首先編寫以下內容:

 類 PersonGeneratorShould {

     私有 PersonGenerator 生成器 = new PersonGenerator();

     @測試
     無效的 generateValidPerson() {
         人 person = generator.generateRandom();
         斷言(人)。
    }
}

然後我們應該問自己:

  • 我想在這裡證明什麼? 這個功能需要做什麼?
  • 我應該只驗證生成的人是非空實例嗎?
  • 我需要證明它是隨機的嗎?
  • 生成的實例是否必須遵循一些業務規則?

我們可以使用依賴注入來簡化我們的測試。

 公共接口 RandomGenerator {
     字符串生成隨機字符串();
     int generateRandomInteger();
}

PersonGenerator現在有另一個構造函數,它也接受該接口的一個實例。 默認情況下,它使用JavaRandomGenerator實現,該實現使用java.Random生成隨機值。

但是,在測試中,我們可以編寫另一個更可預測的實現。

 @測試
 無效的 generateValidPerson() {
     RandomGenerator randomGenerator = new PredictableGenerator("John Doe", 20);
     PersonGenerator 生成器 = new PersonGenerator(randomGenerator);
     人 person = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
}

此測試證明PersonGenerator生成由RandomGenerator指定的隨機實例,而無需深入了解RandomGenerator的任何細節。

測試JavaRandomGenerator並沒有真正增加任何價值,因為它是java.Random的簡單包裝器。 通過測試它,您實際上是在測試 Java 標準庫中的java.Random 編寫明顯的測試只會導致額外的維護,幾乎沒有任何好處。

為避免為測試目的編寫實現,例如PredictableGenerator ,您應該使用 Mockito 等模擬庫。

當我們編寫PredictableGenerator時,我們實際上是手動存根RandomGenerator類。 您也可以使用 Mockito 對其進行存根:

 @測試
 無效的 generateValidPerson() {
     RandomGenerator randomGenerator = mock(RandomGenerator.class);
     when(randomGenerator.generateRandomString()).thenReturn("John Doe");
     when(randomGenerator.generateRandomInteger()).thenReturn(20);

     PersonGenerator 生成器 = new PersonGenerator(randomGenerator);
     人 person = generator.generateRandom();
     assertThat(person).isEqualTo(new Person("John Doe", 20));
 }

這種編寫測試的方式更具表現力,並導致特定測試的實現更少。

Mockito 是一個用於編寫模擬和存根的 Java 庫。 在測試依賴於您無法輕鬆實例化的外部庫的代碼時,它非常有用。 它允許您為這些類編寫行為,而無需直接實現它們。

當我們有多個類似於我們習慣的測試時,Mockito 還允許創建和注入模擬以減少樣板代碼的另一種語法:

 @ExtendWith(MockitoExtension.class) // 1
 類 PersonGeneratorShould {

     @Mock // 2
     隨機生成器隨機生成器;

     @InjectMocks // 3
     私有 PersonGenerator 生成器;

     @測試
     無效的 generateValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         人 person = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

1. JUnit 5 可以使用“擴展”來擴展其功能。 這個註解允許它通過註解識別模擬並正確注入它們。

2. @Mock註解創建一個模擬的字段實例。 這與在我們的測試方法體中編寫mock(RandomGenerator.class)相同。

3. @InjectMocks註解會創建一個新的PersonGenerator實例,並在generator實例中註入 mock。

有關 JUnit 5 擴展的更多詳細信息,請參見此處。

有關 Mockito 注射的更多詳細信息,請參見此處。

使用@InjectMocks有一個陷阱。 它可能消除了手動聲明對象實例的需要,但我們失去了構造函數的編譯時安全性。 如果在任何時候有人向構造函數添加了另一個依賴項,我們將不會在此處收到編譯時錯誤。 這可能導致不容易檢測到的測試失敗。 我更喜歡使用@BeforeEach手動設置實例:

 @ExtendWith(MockitoExtension.class)
類 PersonGeneratorShould {

     @嘲笑
     隨機生成器隨機生成器;

     私有 PersonGenerator 生成器;

     @BeforeEach
     無效設置(){
         生成器 = 新 PersonGenerator(randomGenerator);
     }

     @測試
     無效的 generateValidPerson() {
         when(randomGenerator.generateRandomString()).thenReturn("John Doe");
         when(randomGenerator.generateRandomInteger()).thenReturn(20);

         人 person = generator.generateRandom();
         assertThat(person).isEqualTo(new Person("John Doe", 20));
     }
}

測試時間敏感的過程

一段代碼通常依賴於時間戳,我們傾向於使用諸如System.currentTimeMillis()之類的方法來獲取當前 epoch 時間戳。

雖然這看起來不錯,但當類在內部為我們做出決定時,很難測試和證明我們的代碼是否正常工作。 這種決定的一個例子是確定今天是什麼日子。

 類 IndexerShould {
     私有索引器 indexer = new Indexer();
     @測試
     無效 generateIndexNameForTomorrow() {
         String indexName = indexer.tomorrow("my-index");
         // 這個測試今天可以工作,但是明天呢?
        斷言(索引名稱)
           .isEqualTo("my-index.2022-02-02");
     }
}

我們應該再次使用依賴注入來“控制”生成索引名稱時的日期。

Java 有一個Clock類來處理這樣的用例。 我們可以將一個Clock實例傳遞給我們的Indexer來控制時間。 默認構造函數可以使用Clock.systemUTC()來實現向後兼容性。 我們現在可以用clock.millis()替換System.currentTimeMillis()調用。

通過注入Clock ,我們可以在類中強制執行可預測的時間並編寫更好的測試。

測試文件生成方法

  • 我們應該如何測試將其輸出寫入文件的類?
  • 我們應該將這些文件存儲在哪裡,以便它們在任何操作系統上工作?
  • 我們如何確保該文件不存在?

在處理文件時,如果我們嘗試自己解決這些問題,可能很難編寫測試,正如我們將在下面的示例中看到的那樣。 接下來的測試是對質量可疑的舊測試。 它應該測試DogToCsvWriter是否將狗序列化並將其寫入 CSV 文件:

 類 DogToCsvWriterShould {

     私人 DogToCsvWriter writer = new DogToCsvWriter("/tmp/dogs.csv");
     
     @測試
     無效 convertToCsv() {
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         字符串 csv = Files.readString("/tmp/dogs.csv");

         assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white");
     }
}

序列化過程應該與編寫過程解耦,但讓我們專注於修復測試。

上述測試的第一個問題是它無法在 Windows 上運行,因為 Windows 用戶將無法解析路徑/tmp/dogs.csv 。 另一個問題是,如果文件已經存在,它將無法工作,因為在執行上述測試時它沒有被刪除。 它在 CI/CD 管道中可能工作正常,但如果多次運行則不能在本地運行。

JUnit 5 有一個註解,您可以使用它來獲取對由框架為您創建和刪除的臨時目錄的引用。 雖然創建和刪除臨時文件的機制因框架而異,但想法保持不變。

 類 DogToCsvWriterShould {

     @測試
     void convertToCsv(@TempDir 路徑 tempDir) {
         路徑 dogsCsv = tempDir.resolve("dogs.csv");
         DogToCsvWriter writer = new DogToCsvWriter(dogsCsv);
         writer.appendAsCsv(new Dog(Breed.CORGI, Color.BROWN, "Monty"));
         writer.appendAsCsv(new Dog(Breed.MALTESE, Color.WHITE, "Zoe"));

         字符串 csv = Files.readString(dogsCsv);

         assertThat(csv).isEqualTo("Monty,corgi,brown\nZoe,maltese,white");
     }
}

有了這個小小的改動,我們現在確信上面的測試可以在 Windows、macOS 和 Linux 上運行,而不必擔心絕對​​路徑。 它還將在測試後刪除創建的文件,因此我們現在可以多次運行它並每次都獲得可預測的結果。

命令與查詢測試

命令和查詢有什麼區別?

  • 命令:我們指示對象執行產生效果而不返回值的動作(void 方法)
  • 查詢:我們要求一個對象執行一個動作並返回一個結果或異常

到目前為止,我們主要測試了在執行階段調用了返回值或拋出異常的方法的查詢。 我們如何測試void方法並查看它們是否與其他類正確交互? 框架提供了一組不同的方法來編寫這些類型的測試。

到目前為止,我們為查詢編寫的斷言都是以assertThat開頭的。 在編寫命令測試時,我們使用一組不同的方法,因為我們不再像處理查詢那樣檢查方法的直接結果。 我們想“驗證”我們的方法與系統其他部分的交互。

 @ExtendWith(MockitoExtension.class)
 類 FeedMentionServiceShould {

     @嘲笑
     私有 FeedRepository 存儲庫;

     @嘲笑
     私人 FeedMentionEventEmitter 發射器;

     私人 FeedMentionService 服務;

     @BeforeEach
     無效設置(){
         服務=新的FeedMentionService(存儲庫,發射器);
     }

     @測試
     無效的 insertMentionToFeed() {
         長 feedId = 1L;
         提及提及 = ...;

         何時(repository.upsertMention(feedId,提及))
             .thenReturn(UpsertResult.success(feedId, 提及));

         FeedInsertionEvent 事件 = 新 FeedInsertionEvent(feedId, 提及);
         提及Service.insertMentionToFeed(事件);

         驗證(發射器).mentionInsertedToFeed(feedId,提及);
         verifyNoMoreInteractions(發射器);
     }
}

在這個測試中,我們首先模擬我們的存儲庫,當被要求在我們的提要中更新提及時,以UpsertResult.success響應。 我們不關心在這裡測試存儲庫。 存儲庫方法應在FeedRepositoryShould中進行測試。 通過模擬這種行為,我們實際上並沒有調用存儲庫方法。 我們只是告訴它下次調用它時如何響應。

然後我們告訴我們的mentionService在我們的提要中插入這個提及。 我們知道,只有在提要中成功插入提及時,它才應該發出結果。 通過使用verify方法,我們可以確保在我們的提及和提要中調用了方法mentionInsertedToFeed ,並且沒有使用verifyNoMoreInteractions再次調用。

最後的想法

編寫質量測試來自經驗,而最好的學習方式就是實踐。 這篇博客中寫的技巧來自實踐。 如果您從未遇到過這些陷阱,則很難看到它們,希望這些建議可以使您的代碼設計更加健壯。 擁有可靠的測試將增加您更改事物的信心,而不會在每次必須部署代碼時大汗淋漓。

有興趣加入 Mediatoolkit 的團隊嗎?
查看我們為高級前端開發人員提供的空缺職位!