使用 jqwik 進行基於屬性的測試
一、簡介
在本文中,我們將了解基於屬性的測試。我們將了解基於屬性的測試以及如何使用jqwik庫在 Java 中進行測試。
2. 參數化測試
在了解基於屬性的測試之前,我們先簡要了解一下參數化測試。參數化測試是我們可以編寫單個測試函數,然後使用許多不同的參數調用它。例如:
@ParameterizedTest
@CsvSource({"4,2,2", "6,2,3", "6,3,2"})
void testIntegerDivision(int x, int y, int expected) {
int answer = calculator.divide(x, y);
assertEquals(expected, answer);
}
這讓我們可以相對輕鬆地測試許多不同的輸入集。這可以讓我們確定一組適當的測試用例來查看和運行這些測試。接下來的挑戰就變成了決定這些測試用例應該是什麼。顯然,我們無法測試每一個可能的值集——這是不可行的。相反,我們會嘗試確定有趣的案例並進行測試。
對於這個例子,我們可以測試以下內容:
- 幾個正常情況
- 一個數除以它 – 總是得到“1”
- 將數字除以 1 – 始終給出原始數字
- 正數除以負數 – 總是得到負數
等等。然而,這是假設我們可以想到所有這些情況。當我們不想某事時怎麼辦?例如,如果我們將一個數字除以 0 會發生什麼?我們沒有想到要測試這一點,所以我們不知道結果會是什麼。
3. 基於屬性的測試
相反,如果我們可以以編程方式生成測試輸入怎麼辦?
實現此目的的一個明顯方法是進行參數化測試,在循環中生成輸入:
@ParameterizedTest
@MethodSource("provideIntegerInputs")
void testDivisionBySelf(int input) {
int answer = calculator.divide(input, input);
assertEquals(answer, 1);
}
private static Stream<Arguments> provideIntegerInputs() {
return IntStream.rangeClosed(Integer.MIN_VALUE, Integer.MAX_VALUE)
.mapToObj(i -> Arguments.of(i));
}
這保證能找到任何邊緣情況。然而,這樣做的代價是巨大的。它將測試每一個可能的整數值——即 4,294,967,296 個不同的測試用例。即使每次測試 1 毫秒,運行也需要近 50 天。
基於屬性的測試是這個想法的變體。我們將根據我們定義的一組屬性生成有趣的測試用例,而不是生成每個測試用例。
例如,對於我們的除法示例,我們可能只測試 -20 到 +20 之間的每個數字。如果我們假設任何異常情況都在這個範圍內,那麼這將具有足夠的代表性,同時也更容易管理。在這種情況下,我們的屬性只是“介於 -20 和 +20 之間”:
private static Stream<Arguments> provideIntegerInputs() {
return IntStream.rangeClosed(-20, +20).mapToObj(i -> Arguments.of(i));
}
4.jqwik 入門
jqwik是一個Java測試庫,為我們實現基於屬性的測試。它為我們提供了非常輕鬆高效地進行此類測試的工具。這包括生成數據集的能力,但它也與 JUnit 5 集成。
4.1.添加依賴項
為了使用jqwik,我們需要首先將其添加到我們的項目中。這是我們可以添加到構建中的單個依賴項:
<dependency>
<groupId>net.jqwik</groupId>
<artifactId>jqwik</artifactId>
<version>1.7.4</version>
<scope>test</scope>
</dependency>
最新版本可以在Maven Central Repository中找到。
我們還需要確保在項目中正確設置 JUnit 5,否則我們無法運行用 jqwik 編寫的測試。
4.2.我們的第一次測試
現在我們已經設置了 jqwik,讓我們用它編寫一個測試。我們將測試一個數字除以它本身是否返回 1,就像我們之前所做的一樣:
@Property
public void divideBySelf(@ForAll int value) {
int result = divide(value, value);
assertEquals(result, 1);
}
這裡的所有都是它的。那麼我們做了什麼?
首先,測試用@Property
而不是@Test
進行註釋。這告訴 JUnit 使用 jqwik 運行程序運行它。
接下來,我們有一個用@ForAll
註釋的測試參數。這告訴 jqwik 為該參數生成一組有趣的值,然後我們可以在測試中使用它們。在這種情況下,我們沒有任何約束,因此它只會從所有整數的集合中生成值。
如果我們運行這個會發生什麼?
jqwik 運行了 13 種不同的測試,才發現其中一項失敗。失敗的是輸入“0”,這導致它拋出ArithmeticException
。
這立刻就在我們的代碼中,在極少量的測試代碼中發現了問題。
5. 定義屬性
我們已經了解瞭如何使用 jqwik 為我們的測試提供值。在本例中,我們的測試採用一個整數,對值的大小沒有任何限制。
jqwik 可以為一組標準類型提供值 - 包括字符串、數字、布爾值、枚舉和集合等。所需要做的就是使用本身用@ForAll,
這將告訴 jqwik 使用為此屬性生成的不同值重複運行我們的測試。
我們可以擁有測試所需的任意多個屬性:
@Property
public void additionIsCommutative(@ForAll int a, @ForAll int b) {
assertEquals(a + b, b + a);
}
我們甚至可以根據需要混合類型。
5.1.約束屬性
然而,我們通常需要對這些參數施加一些限制。例如,當測試除法時,我們不能使用零作為分母,因此這需要成為我們測試中的一個約束。
我們可以編寫測試來檢測這些情況並跳過它們。但是,這仍將被視為測試用例。特別是,jqwik 在決定測試通過之前僅運行有限數量的測試用例。如果我們短路這一點,那麼我們就失去了使用屬性測試的意義。
相反,jqwik 允許我們約束屬性,以便生成的值都在範圍內。例如,我們可能想重複我們的除法測試,但僅限於正數:
@Property
public void dividePositiveBySelf(@ForAll @Positive int value) {
int result = divide(value, value);
assertEquals(result, 1);
}
在這裡,我們用@Positive
註釋了我們的屬性。這將導致 jqwik 將值限制為僅正值 - 即任何大於 0 的值。
jqwik 附帶了一組可應用於標準屬性類型的約束 - 例如:
- 是否包含
nulls
。 - 數字的最小值和最大值 - 無論是整數還是其他值。
- 字符串和集合的最小和最大長度。
- 字符串中允許的字符。
- 還有很多。
5.2.自定義約束
通常,標準約束足以滿足我們的需求。然而,在某些情況下,我們可能需要更加規範。 jqwik 使我們能夠編寫自己的生成函數,這些函數可以執行我們需要的任何操作。
例如,我們知道零是唯一不能用於除法的情況。其他所有數字都應該可以正常工作。然而,jqwik 的標準註釋不允許我們生成除一個值之外的所有內容。我們能做的最好的事情就是從整個範圍生成數字。
因此,我們將自己生成數字。這將使我們能夠從整個整數範圍生成除零之外的任何數字:
@Property
public void divideNonZeroBySelf(@ForAll("nonZeroNumbers") int value) {
int result = divide(value, value);
assertEquals(result, 1);
}
@Provide
Arbitrary<Integer> nonZeroNumbers() {
return Arbitraries.integers().filter(v -> v != 0);
}
為了實現這一目標,我們在測試類中編寫了一個新方法,用@Provide
註釋,返回Arbitrary<Integer>
。然後,我們在@ForAll
註釋上指示使用此方法生成,而不是僅從參數類型的整個可能值集中生成。這樣做意味著 jqwik 現在將使用我們的生成方法而不是默認的生成方法。
如果我們嘗試這樣做,我們會發現我們從整個整數值範圍中獲得了大範圍的正數和負數。而 0 永遠不會出現——因為我們明確地將其過濾掉。
5.3.假設
有時我們需要擁有多個屬性,但它們之間存在約束。例如,我們可能想要測試一個較大的數字除以一個較小的數字將始終返回大於 1 的結果。我們可以輕鬆地為每個數字定義屬性,但我們不能根據另一個屬性來定義一個屬性。
相反,這可以通過使用假設來完成。我們告訴我們的測試,我們假設一些前提條件,只有當該前提條件通過時,我們才會運行測試:
@Property
public void divideLargeBySmall(@ForAll @Positive int a, @ForAll @Positive int b) {
Assume.that(a > b);
int result = divide(a, b);
assertTrue(result >= 1);
}
運行這個測試將會通過,但讓我們看看輸出:
我們已經嘗試了 1,000 次。然而,我們實際上只檢查了其中的 498 個。這是因為生成的測試中有 502 個未滿足假設,因此未運行。
jqwik 有一個可配置的最大丟棄率。這是放棄的測試與嘗試的測試的比率,默認設置為“5”。這意味著,如果我們的假設對於每次嘗試都拒絕超過 5 個案例,那麼測試將被視為失敗,因為沒有足夠的可行測試數據。稍後我們將看到如何更改這些設置。
6. 結果縮水
jqwik 可以非常有效地找到我們的代碼未通過測試的情況。然而,如果生成的案例相當模糊,這並不總是有用。
相反,jqwik 將嘗試將任何失敗案例“縮小”到最小案例。這將幫助我們更直接地找到需要考慮的任何邊緣情況。
讓我們嘗試一個看似簡單的案例。對數字進行平方應該產生大於輸入的結果。
@Property
public void square(@ForAll @Positive int a) {
int result = a * a;
assertTrue(result >= a);
}
但是,如果我們運行此測試,則會失敗:
在這裡我們有一些額外的信息。 “原始樣本”是第一次測試失敗的生成值,“收縮樣本”是jqwik隨後將其縮小到但仍然失敗的樣本。
但為什麼這會失敗呢?這沒有什麼不尋常的。那麼我們自己來嘗試一下吧。 46,341 的平方等於 2,147,488,281,這恰好比整數所能容納的要大。然而,46,340 平方適合該範圍。所以我們確實在這裡發現了一個令人驚訝的優勢——我們無法對 46,341 進行平方並將結果存儲在整數字段中。
7. 重新運行測試
如果我們的測試失敗,我們通常會想要修復錯誤,然後重新運行測試。但是,如果生成的測試用例是隨機的,則重新運行測試可能不會遇到與之前相同的失敗情況。
jqwik 為我們提供了一些工具來幫助解決這個問題。默認情況下,運行之前失敗的測試將從失敗的確切情況開始,然後生成新的條件。這為我們提供了對之前失敗案例的非常快速的反饋。還可以將其配置為再次運行全套測試,但重新使用相同的隨機種子。這意味著所有測試用例的運行都與以前完全相同——無論是通過的還是失敗的。
然而,這兩種情況都需要一些本地存儲。詳細信息默認存儲在本地文件系統上的文件中 - .jqwik-database
。這意味著如果 CI 構建中的測試失敗,則存儲的詳細信息將在運行之間丟失。
另一種選擇是我們可以顯式地將固定種子傳遞到測試中。這將導致測試完全像隨機選擇種子一樣運行。測試運行的輸出還為我們提供了所使用的種子,因此我們可以將其複製到本地測試運行中以準確重現它的作用。
8. 配置測試
jqwik 附帶了一些開箱即用的合理默認設置,其中許多我們已經看到過提及。例如,它將運行 1,000 次迭代來查找任何失敗的案例。在許多情況下,這完全沒問題。但是,如果我們需要對此進行一些更改,我們可以。
其中許多可以直接在@Property
註釋上配置:
@Property(tries = 5000, shrinking = ShrinkingMode.OFF, generation = GenerationMode.RANDOMIZED)
該註釋現在將:
- 運行測試 5,000 次迭代而不是默認迭代。
- 禁用結果縮小。
- 表示更喜歡隨機生成屬性而不是詳盡無遺。
除此之外,我們還可以配置假設的丟棄率、如何重新運行失敗測試的詳細信息以及如何以完全相同的方式隨機生成值的詳細信息。
我們可以通過在測試類上使用@PropertyDefaults
註釋來執行完全相同的操作,然後它將配置此類中的每個測試方法。許多設置也可以在位於類路徑根目錄的junit-platform.properties
文件中配置 - 例如,在src/test/resources
中。這樣做將為整個項目配置默認值,而不僅僅是一組測試。
9. 總結
在這裡我們看到了 jqwik 庫的介紹。這只是這個庫可以實現的一小部分,但希望足以看到屬性測試可以給我們的項目帶來的力量。
與往常一樣,我們可以在 GitHub 上找到本文中的所有代碼。