參數化測試讓開發者能夠有效地使用多種輸入值來測試他們的代碼。在JUnit 測試的領域中,有經驗的使用者長期以來一直在與實現這些測試的複雜性搏鬥。但隨著JUnit 5.7的發布,測試參數化的新時代來臨,為開發者提供了一流的支援和增強的能力。讓我們深入了解一下JUnit 5.7為參數化測試帶來的令人振奮的可能性!
JUnit 5.7 文档中的參數化範例
讓我們看看一些文檔中的例子:
@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
assertTrue(StringUtils.isPalindrome(candidate));
}
@ParameterizedTest
@CsvSource({
"apple, 1",
"banana, 2",
"'lemon, lime', 0xF1",
"strawberry, 700_000"
})
void testWithCsvSource(String fruit, int rank) {
assertNotNull(fruit);
assertNotEquals(0, rank);
}
@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List<String> list) {
assertEquals(5, str.length());
assertTrue(num >=1 && num <=2);
assertEquals(2, list.size());
}
static Stream<Arguments> stringIntAndListProvider() {
return Stream.of(
arguments("apple", 1, Arrays.asList("a", "b")),
arguments("lemon", 2, Arrays.asList("x", "y"))
);
}
《@ParameterizedTest》注解必須與幾個提供的源注解之一一起使用,以描述從哪裡獲取參數。參數的來源通常被稱為“數據提供者”。
I will not dive into their detailed description here: the JUnit user guide does it better than I could, but allow me to share several observations:
- 《@ValueSource》僅限於提供單個參數值。換句話說,測試方法不能有超過一個參數,而且可以使用 的類型也受到限制。
- 傳遞多個參數在一定程度上由《@CsvSource》解決,它將每個字符串解析為一條記錄,然後按字段逐個傳遞為參數。當字符串長或參數多時,這可能會變得難以閱讀。可以使用的類型也受到限制——稍後會有更多關於這一點的介紹。
- 所有在注解中声明实际值的源都限制为编译时常量(Java注解的限制,而非JUnit)。
@MethodSource
和@ArgumentsSource
提供了一個流/集合,其中包含(無類型的)n-元組,然後將其作為方法參數傳遞。支持各種實際類型來表示n-元組的序列,但無法保證它們會適合方法的參數列表。這種類型的源需要額外的方法或類,但它對於從何處以及如何獲取測試數據沒有限制。
正如你所看到的,可用的源類型從簡單的(使用簡單,但功能有限)到極端靈活的(需要更多的代碼才能運作)。
- 旁註 — 這通常意味著良好的設計:基本功能需要少量的代碼,當用於實現更複雜的使用案例時,增加額外的複雜性是合理的。
看起來與這個假設的從簡單到靈活的連續體不相符的是 @EnumSource
。請看看這個具有4個參數集(每個2個值)的非平凡範例。
- 註釋 — 雖然
@EnumSource
將枚舉的值作為單個測試方法的參數傳遞,從概念上講,測試是由枚舉的字段參數化的,這對參數的數量沒有限制。
enum Direction {
UP(0, '^'),
RIGHT(90, '>'),
DOWN(180, 'v'),
LEFT(270, '<');
private final int degrees;
private final char ch;
Direction(int degrees, char ch) {
this.degrees = degrees;
this.ch = ch;
}
}
@ParameterizedTest
@EnumSource
void direction(Direction dir) {
assertEquals(0, dir.degrees % 90);
assertFalse(Character.isWhitespace(dir.ch));
int orientation = player.getOrientation();
player.turn(dir);
assertEquals((orientation + dir.degrees) % 360, player.getOrientation());
}
想想看:硬编码的值列表严重限制了其灵活性(无法使用外部或生成数据),而声明enum
所需的大量额外代码使得这种选择相对于@CsvSource
来说显得相当冗长。
但这只是第一印象。我们将看到当利用Java枚举的真正力量时,这可以变得多么优雅。
- 旁注:本文没有讨论生产代码中枚举的验证。当然,无论您选择如何验证它们,这些枚举都必须声明。相反,它关注的是何时以及如何将测试数据表达为枚举的形式。
何时使用它
有些情况下,枚举的表现比其他选择更好:
每个测试多个参数
当您只需要一个参数时,您很可能不想让事情比@ValueSource
更复杂。但是,一旦您需要多个参数——比如说,输入和预期结果——您就必须求助于@CsvSource,
@MethodSource/@ArgumentsSource
或 @EnumSource
。
在某种程度上,enum
允许您“悄悄带入”任何数量的数据字段。
所以,当您在未来需要添加更多测试方法参数时,您只需在现有的枚举中添加更多字段,而无需触摸测试方法的签名。当您在多个测试中重用数据提供者时,这变得非常有价值。
對於其他來源,必須使用ArgumentsAccessor
或ArgumentsAggregator
來獲得枚舉出廠時具有的靈活性。
類型安全
對於Java開發人員來說,這應該是個非常重要的點。
從CSV(文件或字面量)讀取的參數,@MethodSource
或@ArgumentsSource
,它們不能提供編譯時的保證,參數的數量及其類型將與簽名匹配。
顯然,JUnit會在運行時報錯,但忘記來自IDE的任何代碼輔助。
與之前一樣,當您為多個測試重用相同的參數時,這會累加。使用類型安全的 approach 區會在將來擴展參數集時帶來巨大的好處。
自定義類型
這主要是優於基于文本的來源,如從CSV讀取數據的那些——文本中編碼的值需要轉換為Java類型。
如果您有一個從CSV記錄實例化的自定義類,您可以使用ArgumentsAggregator
來完成。然而,您的數據聲明仍然不是類型安全的——當在”匯總”參數時,方法簽名與聲明的數據之間的任何不匹配都會在運行時彈出。更不用說聲明聚合器類增加了更多支持代碼,這是您的參數化工作所需的。而我們曾經偏好使用@CsvSource
而不是@EnumSource
以避免額外的代碼。
可文檔化
與其他方法不同,枚舉源具有Java符號,既用於參數集(枚舉實例),也用於它們包含的所有參數(枚舉字段)。它們提供了直接附加說明的地方,這種形式更加自然 — JavaDoc。
並不是說說明不能放在其他地方,而是按照定義,它將放在它所說明的內容較遠的地方,因此更難找到,且更容易過時。
但還有更多!
現在:枚舉。就是。類。
感覺許多初級開發人員還沒有真正意識到Java枚舉的強大之處。
在其他編程語言中,它們真的只是 glorified constants(優化過的常量)。但在Java中,它們是方便的小型實現,采用了飛重量設計模式(具有完整類的許多優勢)。
這為什麼是好事?
測試固件相關行為
與任何其他類一樣,枚舉可以向其添加方法。
如果枚舉測試參數在多個測試之間重用 — 數據相同,只是稍微有不同的測試方式,這會變得很方便。為了在不進行大量複製和粘貼的情況下有效地使用參數,這些測試之間需要共享一些幫助代碼。
這不是一個幫助類和幾個靜態方法不能“解決”的問題。
- 旁註:請注意這種設計受到功能妒忌的影響。測試方法 — 或者更糟,輔助類別方法 — 必須從枚舉對象中提取數據以對該數據進行操作。
儘管這是(唯一的)過程式編程方式,但在面向對象的世界裡,我們可以做得更好。
直接在枚舉聲明中聲明「輔助」方法,我們會將代碼移動到數據所在的地方。或者,用面向對象的行話來說,輔助方法會成為實現為枚舉的測試固件「行為」。這不僅會使代碼更加符合語法(在實例上調用合理的 方法而不是傳遞數據的靜態方法),還會使跨測試用例重用枚舉參數變得更容易。
繼承
枚舉可以實現帶有(默認)方法的接口。當合理使用時,這可以被用來在多個數據提供者 — 多個枚舉 — 之間共享行為。
一個很容易想到的例子是分別為正數和負數測試創建的獨立枚舉。如果它們代表類似的測試固件,那麼它們很可能有一些共享的行為。
空談無價
讓我們在假設的源代碼文件轉換器的測試套件中說明這一點,這個轉換器與執行 Python 2 到 3 轉換的那個非常相似。
為了真正對這樣一個全面的工具有所信心,最終會有一套大量的輸入文件展現語言的各種方面,並且有匹配的文件來比對轉換結果。除了這些,還需要驗證對於有問題的輸入,用戶會收到哪些警告/錯誤。
這對於參數化測試來說是自然適合的,因為有大量的樣本需要驗證,但它並不完全適合JUnit的任何簡單參數源,因為這些數據相對複雜。
請見下文:
enum Conversion {
CLEAN("imports-correct.2.py", "imports-correct.3.py", Set.of()),
WARNINGS("problematic.2.py", "problematic.3.py", Set.of(
"Using module 'xyz' that is deprecated"
)),
SYNTAX_ERROR("syntax-error.py", new RuntimeException("Syntax error on line 17"));
// 很多,很多其他的...
@Nonnull
final String inFile;
@CheckForNull
final String expectedOutput;
@CheckForNull
final Exception expectedException;
@Nonnull
final Set expectedWarnings;
Conversion(@Nonnull String inFile, @Nonnull String expectedOutput, @NotNull Set expectedWarnings) {
this(inFile, expectedOutput, null, expectedWarnings);
}
Conversion(@Nonnull String inFile, @Nonnull Exception expectedException) {
this(inFile, null, expectedException, Set.of());
}
Conversion(@Nonnull String inFile, String expectedOutput, Exception expectedException, @Nonnull Set expectedWarnings) {
this.inFile = inFile;
this.expectedOutput = expectedOutput;
this.expectedException = expectedException;
this.expectedWarnings = expectedWarnings;
}
public File getV2File() { ... }
public File getV3File() { ... }
}
@ParameterizedTest
@EnumSource
void upgrade(Conversion con) {
try {
File actual = convert(con.getV2File());
if (con.expectedException != null) {
fail("No exception thrown when one was expected", con.expectedException);
}
assertEquals(con.expectedWarnings, getLoggedWarnings());
new FileAssert(actual).isEqualTo(con.getV3File());
} catch (Exception ex) {
assertTypeAndMessageEquals(con.expectedException, ex);
}
}
使用枚舉不會限制我們數據可以有多複雜。正如你所看到的,我們可以在枚舉中定義多個方便的構造器,所以聲明新的參數集非常乾淨整齊。這避免了使用過長的參數列表,這樣的列表常常充滿了很多”空的”值(null、空字符串或集合),讓人疑惑第7個參數——你知道,其中一個null——到底代表了什麼。
注意枚舉如何讓複合類型(Set
、RuntimeException
)的使用沒有任何限制或神奇的轉換。傳遞這樣的數據也是完全類型安全的。
現在,我知道你在想什麼。這太囉嗦了。好吧,在一定程度上。現實情況是,你將會有更多數據樣本需要驗證,所以相比之下,模板代碼的量將會不太顯著。
還可以看到,如何利用同一個枚舉以及它們的幫助方法來編寫相關的測試:
@ParameterizedTest
@EnumSource
// 升級已經升級的文件總是會通過,不會進行任何更改,也不會發出警告。
void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
File actual = convert(con.getV3File());
assertEquals(Set.of(), getLoggedWarnings());
new FileAssert(actual).isEqualTo(con.getV3File());
}
@ParameterizedTest
@EnumSource
// 復原升級過程中創建的文件應該始終能夠無警告通過。
void downgrade(Conversion con) throws Exception {
File actual = convert(con.getV3File());
assertEquals(Set.of(), getLoggedWarnings());
new FileAssert(actual).isEqualTo(con.getV2File());
}
還是有更多的話要說
從概念上講,@EnumSource
鼓勵您創建一個複雜的、機器可讀的單個測試情景描述,模糊了數據提供者和測試固件之間的界限。
將每個數據集表示為Java符號(枚舉元素)的另一点優勢在於,它們可以單獨使用;完全脫離數據提供器/參數化測試。由於它們具有合理的名稱并且是自包含的(在數據和行為方面),它們有助於創造出整洁且易於閱讀的測試。
@Test
void warnWhenNoEventsReported() throws Exception {
FixtureXmls.Invalid events = FixtureXmls.Invalid.NO_EVENTS_REPORTED;
// read() 是由所有 FixtureXmls 共享的輔助方法
try (InputStream is = events.read()) {
EventList el = consume(is);
assertEquals(Set.of(...), el.getWarnings());
}
}
現在,@EnumSource
不會成為您最常使用的參數源之一,這是一件好事,因為過度使用它並沒有好處。但在適當的情況下,知道如何充分利用它提供的功能會很有幫助。不。
Source:
https://dzone.com/articles/junit5-parameterized-tests-with-enumsource