单元测试之JUnit 5 参数化测试使用手册
1. 概要
junit5是下一代JUnit測試框架,新增了很多特性幫助開發人員更好得編寫測試用例。其中一大特性就是參數化測試,其目的就是讓我們可以使用不同的參數多次執行一個測試方法,從而覆蓋不同的條件分支。(簡單來說就是既Cover 所有的情況,還能減少 Duplicate Code )
在這邊教程中,我們將深度探索參數化教程。現在開始吧!
2. 依賴
為了使用JUnit 5的參數化測試,我們需要從JUnit平臺引入 junit-jupiter-params包。
如果我們項目使用Maven來管理,那么就需要在pom.xml中加入如下依賴
如果我們使用Gradle來編譯項目,則需要在gradle的配置加下如下代碼:
testCompile("org.junit.jupiter:junit-jupiter-params:5.4.2")3. 示例
假設我們已有一個工具方法,現在我們想要對這個方法的功能進行測試驗證
public class Numbers {public static boolean isOdd(int number) {return number % 2 != 0;} }參數化測試和普通的測試比較像,不過我們需要使用@ParameterizedTest注解
@ParameterizedTest @ValueSource(ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // six numbers void isOdd_ShouldReturnTrueForOddNumbers(int number) {assertTrue(Numbers.isOdd(number)); }Junit 5的測試執行器會執行上述測試用例,然后isOdd方法會被執行6次,每次從@ValueSource的整型數組里拿出一個參數作為isOdd方法的入參。
從這個示例我們可以看出,執行參數化測試需要2個條件:
1. 參數源 , 這里是一個整型數組
2. 接收參數的地方, 在這里就是測試方法上的number參數
但是從這個例子中,還有一個執行參數化測試的必要條件,目前暫時看不出來,我們繼續往下看
4. 參數源
現在我們知道了,一個參數化測試會使用不同的參數重復執行多次。那么這里的參數我們不僅僅可以使用上述的數字,讓我們來體驗一下!
4.1. 簡單值(Simple Value)
通過使用 @ValueSource 注解, 我們可以使用數組來對不同的參數逐一執行測試用例
舉例來說,假設我們要測試如下isBlank這一簡單方法
public class Strings {public static boolean isBlank(String input) {return input == null || input.trim().isEmpty();} }我們期望這個方法能夠對null或者空字符串返回true。所以我們可以寫一個如下的參數化測試去對原函數的行為進行斷言:
@ParameterizedTest @ValueSource(strings = {"", " "}) void isBlank_ShouldReturnTrueForNullOrBlankStrings(String input) {assertTrue(Strings.isBlank(input)); }可以看出,上述測試用例會被執行2次,每次都將數組里的一個參數作為方法的入參來執行。
@ValueSource的不足在于它只能支持如下這些簡單類型
- short (with the shorts attribute)
- byte (with the bytes attribute)
- int (with the ints attribute)
- long (with the longs attribute)
- float (with the floats attribute)
- double (with the doubles attribute)
- char (with the chars attribute)
- java.lang.String (with the strings attribute)
- java.lang.Class (with the classes attribute)
當前,使用參數化測試也可以每次執行時只是有一個參數(也就是說參數源的數組只有一個元素)
在進一步學習之前,是否有人注意到目前為止我們都沒有以null為參數來進行驗證? 因為這里有一個限制:哪怕我們
使用String或者Class數組作為參數源,我們也不能在@ValueSource注解里使用null作為入參
4.2. Null and Empty Values
在JUnit 5.4,我們可以單獨用 @NullSource來驗證參數為null的參數化測試的執行
@ParameterizedTest @NullSource void isBlank_ShouldReturnTrueForNullInputs(String input) {assertTrue(Strings.isBlank(input)); }因為基本類型參數不能被賦值為null, 所以我們不能使用@NullSource作為基本參數
類似的,還有一個 @EmptySource注解,可以幫助我們驗證參數為empty的情形
@ParameterizedTest @EmptySource void isBlank_ShouldReturnTrueForEmptyStrings(String input) {assertTrue(Strings.isBlank(input)); }上述測試代碼里使用了@EmptySource注解,就會以empty 空參數作為入參執行當前測試用例
對于String類型的參數,就會以一個空字符串為參數傳遞測試。此外,@EmptySource注解還可以對集合和數組參數提供空值
為了同時驗證參數為null和空的情形,我們可以使用@NullAndEmptySource這個組合注解,同時包含了@EmptySource
和@NullSource的功能
和@EmptySource一樣,上述組合注解@NullAndEmptySource也可以對String,集合,數組類型參數進行參數化測試
為了讓參數化測試可以一次執行更多的參數條件,我們可以將 @ValueSource, @NullSource, @EmptySource 三個注解一起使用,如下:
@ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "\t", "\n"}) void isBlank_ShouldReturnTrueForAllTypesOfBlankStrings(String input) {assertTrue(Strings.isBlank(input)); }4.3. Enum
為了以給定Enum枚舉類型作為參數執行測試,我們可以使用@EnumSource注解
比如,下面我們就以1-12月所有的月份為參進行了驗證斷言
@ParameterizedTest @EnumSource(Month.class) // passing all 12 months void getValueForAMonth_IsAlwaysBetweenOneAndTwelve(Month month) {int monthNumber = month.getValue();assertTrue(monthNumber >= 1 && monthNumber <= 12); }此外,我們還可以使用注解的names屬性,只執行我們想要執行的參數枚舉條件
下面就是一個驗證非閏年4月,9月,6月,11月都是30天的斷言
默認情形下,names屬性里的枚舉值是我們想要執行的參數;如果想要排除特定參數則可以設置mode參數為EXCLUDE
@ParameterizedTest @EnumSource(value = Month.class,names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER", "FEBRUARY"},mode = EnumSource.Mode.EXCLUDE) void exceptFourMonths_OthersAre31DaysLong(Month month) {final boolean isALeapYear = false;assertEquals(31, month.length(isALeapYear)); }In addition to literal strings, we can pass a regular expression to the names attribute:
另外對于字符串的枚舉類型,我們還可以使用正則表達式作為names的屬性值
@EnumSource和@ValueSource一樣,每次執行測試用例僅能使用枚舉類的其中一個值作為參數
4.4. CSV Literals
現在有一個場景,假設我們想要驗證 toUpperCase() 方法是否可以正確地把一個字符串轉化為對應的大寫。此時@ValueSource就不太夠用了
為了滿足類似場景的參數化測試,我們需要按照以下步驟:
- 對于測試方法的每一個輸入都設置它預期的輸出值
- 使用入參計算出實際的執行結果
- 比較預期值和實際值是否相符
所以我們需要支持同時傳入多個參數的數據源(同時傳入入參值和預期值),@CsvSource就是其中一個
@CsvSource接收一個以逗號為分隔符的數組作為數據源,數組的每一組元素對應著一個CSV的一個記錄,也就是輸入參數和預期值 (CSV–Comma-Separated Values,有時也稱為字符分隔值)
每次會從源中獲取一對參數,以逗號為分隔符進行劃分后,分別作為入參和預期值執行測試用例。默認情形下我們使用逗號作為分隔符,不過也可以使用delimiter 屬性定義自己的分隔符
@ParameterizedTest @CsvSource(value = {"test:test", "tEst:test", "Java:java"}, delimiter = ':') void toLowerCase_ShouldGenerateTheExpectedLowercaseValue(String input, String expected) {String actualValue = input.toLowerCase();assertEquals(expected, actualValue); }上述例子中以冒號作為分隔符,仍舊是一個CSV源。
4.5. CSV Files
如果不想在代碼里直接寫CSV源,也可以使用一個CSV文件。
舉例來說,我們可以定義如下一個CSV文件:
input,expected test,TEST tEst,TEST Java,JAVA我們可以通過@CsvFileSource加載上述CSV文件, 可以通過numLinesToSkip 參數忽略最上面的列名
@ParameterizedTest @CsvFileSource(resources = "/data.csv", numLinesToSkip = 1) void toUpperCase_ShouldGenerateTheExpectedUppercaseValueCSVFile(String input, String expected) {String actualValue = input.toUpperCase();assertEquals(expected, actualValue); }我們通過resources屬性指定想要執行的CSV文件路徑,可以同時指定多個文件
numLinesToSkip 屬性表示在執行CSV文件的時候我們需要跳過的行數。不指定的話,默認情形下,@CsvFileSource會執行對應文件的每一行。不過通常我們會用這個屬性跳過CSV文件的列名,就像上面的例子這樣
跟@CsvSource注解一樣,我們也可以通過delimiter屬性自定義分隔符
除了每一行的分隔符,我們還能通過lineSeparator自定義行分隔符,默認的行分隔符是換行符"\n"; 也可以通過encoding屬性定義文件的編碼格式,默認采用"UTF-8"
4.6. Method
上述提到的參數源都比較簡單,而且有一個共同的缺陷:那就是非常困難或者無法去構建復雜對象的參數化測試
想要提供更復雜的參數去滿足參數化測試其中一個辦法就是使用一個方法作為參數源
讓我們使用@MethodSource驗證下isBlank方法
我們提供給 @MethodSource 的參數值需要是一個有效的方法名
所以接下來我們需要編寫一個provideStringsForIsBlank方法,定義一個返回參數流的靜態方法(注意一定要是靜態方法)
這里我們返回了一個參數的stream,但是并不是所有的方法返回值都需要這個。舉例來說,我們可以返回任何集合的結果(eg.List)
如果我們僅需要在每個測試用例執行的時候提供的參數類型都是一種,我們也可以不使用Arguments,而直接使用對應類型,如下:
@ParameterizedTest @MethodSource // hmm, no method name ... void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument(String input) {assertTrue(Strings.isBlank(input)); }private static Stream<String> isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument() {return Stream.of(null, "", " "); }當我們沒有在@MethodSource注解里指定方法名稱的時候,JUnit會檢索當前測試類,找到和當前測試方法同名的方法作為方法源
在某些時候,我們需要在不同的測試類之間共享一些參數,此時我們就可以在@MethodSource里通過指定方法的全限定名來指定非本測試類的方法源,如下
class StringsUnitTest {@ParameterizedTest@MethodSource("com.baeldung.parameterized.StringParams#blankStrings")void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource(String input) {assertTrue(Strings.isBlank(input));} }public class StringParams {static Stream<String> blankStrings() {return Stream.of(null, "", " ");} }使用#號隔開類的全限定名和方法名,我們就能指定非本類的靜態方法組作為方法源
4.7. Custom Argument Provider
另一種更好的方式是自己實現ArgumentsProvider接口,以參數類作為參數源
class BlankStringsArgumentsProvider implements ArgumentsProvider {@Overridepublic Stream<? extends Arguments> provideArguments(ExtensionContext context) {return Stream.of(Arguments.of((String) null), Arguments.of(""), Arguments.of(" ") );} }定義好參數類之后,我們就可以在測試用例上添加@ArgumentsSource注解指定參數類
@ParameterizedTest @ArgumentsSource(BlankStringsArgumentsProvider.class) void isBlank_ShouldReturnTrueForNullOrBlankStringsArgProvider(String input) {assertTrue(Strings.isBlank(input)); }接下來讓我們通過自定義注解的這一更簡潔的方式來實現自定義參數源
4.8. Custom Annotation
如果我們通過一個靜態變量來加載測試參數呢?就像下面這樣
static Stream<Arguments> arguments = Stream.of(Arguments.of(null, true), // null strings should be considered blankArguments.of("", true),Arguments.of(" ", true),Arguments.of("not blank", false) );@ParameterizedTest @VariableSource("arguments") void isBlank_ShouldReturnTrueForNullOrBlankStringsVariableSource(String input, boolean expected) {assertEquals(expected, Strings.isBlank(input)); }但是實際上,JUnit 5并不支持這種寫法。不過我們可以自己實現一下
首先,我們可以創建一個注解,如下:
@Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @ArgumentsSource(VariableArgumentsProvider.class) public @interface VariableSource {/*** The name of the static variable*/String value(); }接著,我們需要想辦法去獲取到注解里的參數信息并執行參數測試。JUnit 5提供兩個接口以實現上述需求 :
- AnnotationConsumer接口提供方法獲取注解里的信息
- ArgumentsProvider幫助我們提供測試參數
所以,我們接下來要做的就是定義一個實現了上述接口的VariableArgumentsProvider類,獲取指定的靜態變量作為參數化測試的參數
class VariableArgumentsProvider implements ArgumentsProvider, AnnotationConsumer<VariableSource> {private String variableName;@Overridepublic Stream<? extends Arguments> provideArguments(ExtensionContext context) {return context.getTestClass().map(this::getField).map(this::getValue).orElseThrow(() -> new IllegalArgumentException("Failed to load test arguments"));}//從注解獲取對應的參數名稱@Overridepublic void accept(VariableSource variableSource) {variableName = variableSource.value();}//從測試類根據參數名稱獲取Field字段信息private Field getField(Class<?> clazz) {try {return clazz.getDeclaredField(variableName);} catch (Exception e) {return null;}}//獲取參數值@SuppressWarnings("unchecked")private Stream<Arguments> getValue(Field field) {Object value = null;try {field.setAccessible(true);value = field.get(null);} catch (Exception ignored) {}return value == null ? null : (Stream<Arguments>) value;} }這樣就可以執行了,是不是很神奇!
5. 參數轉換Argument Conversion
5.1. 隱式轉換
下面寫了一個使用@CsvSource的參數化測試,不過實際使用到的參數是枚舉類Month
@ParameterizedTest @CsvSource({"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) // Pssing strings void someMonths_Are30DaysLongCsv(Month month) {final boolean isALeapYear = false;assertEquals(30, month.length(isALeapYear)); }按理說這應該執行錯誤,畢竟枚舉值和@CsvSource的定義格式不一樣。但事實上這個參數是可以正常通過的
所以,JUnit 5會將字符串類型的參數轉化成對應的枚舉類型。為了支持這種情形,JUnit Jupiter提供了一系列隱式的類型轉換器
轉換過程取決于對應方法聲明的參數類型,這個隱式轉換可以將字符串對象轉換成下列類型 :
- UUID
- Locale
- LocalDate, LocalTime, LocalDateTime, Year, Month, etc.
- File and Path
- URL and URI
- Enum subclasses
5.2. 顯式轉換
有時候我們需要一個自定義的顯式轉換器來做參數轉換
假設我們需要把yyyy/mm/dd格式的字符串轉換成LocalDate實例。首先,我們需要實現ArgumentConverter 接口,如下
class SlashyDateConverter implements ArgumentConverter {@Overridepublic Object convert(Object source, ParameterContext context)throws ArgumentConversionException {if (!(source instanceof String)) {throw new IllegalArgumentException("The argument should be a string: " + source);}try {String[] parts = ((String) source).split("/");int year = Integer.parseInt(parts[0]);int month = Integer.parseInt(parts[1]);int day = Integer.parseInt(parts[2]);return LocalDate.of(year, month, day);} catch (Exception e) {throw new IllegalArgumentException("Failed to convert", e);}} }我們可以使用@ConvertWith(XXX.class) 來指定轉換器
@ParameterizedTest @CsvSource({"2018/12/25,2018", "2019/02/11,2019"}) void getYear_ShouldWorkAsExpected(@ConvertWith(SlashyDateConverter.class) LocalDate date, int expected) {assertEquals(expected, date.getYear()); }6. 參數構造器Argument Accessor
默認情形下,提供給參數化測試的每個參數都是一個獨立的方法參數。因此,如果我們想要驗證一個復雜參數的參數源,這個方法的參數列表就會變得很大而且難以理解
一種解決辦法就是把所有的參數構造成ArgumentsAccessor 的實例并且通過索引和類型定位參數
比方說,下面有一個Person的類:
class Person {String firstName;String middleName;String lastName;// constructorpublic String fullName() {if (middleName == null || middleName.trim().isEmpty()) {return String.format("%s %s", firstName, lastName);}return String.format("%s %s %s", firstName, middleName, lastName);} }接著,為了測試其fullName方法,我們需要傳入四個參數 : firstName, middleName, lastName,和預期的fullName. 我們可以使用ArgumentsAccesso來給測試方法的參數賦值而不需要聲明每一個參數,如下:
@ParameterizedTest @CsvSource({"Isaac,,Newton,Isaac Newton", "Charles,Robert,Darwin,Charles Robert Darwin"}) void fullName_ShouldGenerateTheExpectedFullName(ArgumentsAccessor argumentsAccessor) {String firstName = argumentsAccessor.getString(0);String middleName = (String) argumentsAccessor.get(1);String lastName = argumentsAccessor.get(2, String.class);String expectedFullName = argumentsAccessor.getString(3);Person person = new Person(firstName, middleName, lastName);assertEquals(expectedFullName, person.fullName()); }這里,我們根據所有需要的參數構建一個ArgumentsAccessor實例,然后在測試方法的方法體內,根據每個參數的下標取得對應的參數。另外對于這個簡單的構造器,可以通過getXX方法進行類型轉換.
- getString(index) 根據指定下標獲取元素并轉換成String類型,其余的getXX方法類型,都可以轉換成對應的類型
- get(index) 根據指定下標獲取一個Object元素,用戶自己進行類型轉化
- get(index, type) 根據指定下標獲取元素后再轉化成指定的類型
7. 參數聚合Argument Aggregator
使用前面提到的參數構造器很可能會使得測試代碼的可讀性和可重復性降低。為了解決這個問題,我們還可以使用自定義可重復的聚合器.
為了實現上述功能,我們需要實現ArgumentsAggregator接口 :
class PersonAggregator implements ArgumentsAggregator {@Overridepublic Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context)throws ArgumentsAggregationException {return new Person(accessor.getString(1), accessor.getString(2), accessor.getString(3));} }接著我們需要使用 @AggregateWith 注解,如下 :
@ParameterizedTest @CsvSource({"Isaac Newton,Isaac,,Newton", "Charles Robert Darwin,Charles,Robert,Darwin"}) void fullName_ShouldGenerateTheExpectedFullName(String expectedFullName,@AggregateWith(PersonAggregator.class) Person person) {assertEquals(expectedFullName, person.fullName()); }我們使用PersonAggregator來代替最后的三個參數并且通過這三個參數實例化Persion對象。
8. 自定義展示名稱Customizing Display Names
默認情下,參數化測試的展示名字會由一個執行下標和測試方法的字符串參數組成,就像下面:
├─ someMonths_Are30DaysLongCsv(Month) │ │ ├─ [1] APRIL │ │ ├─ [2] JUNE │ │ ├─ [3] SEPTEMBER │ │ └─ [4] NOVEMBER不過,我們也可以通過@ParameterizedTest 的name屬性自己定義參數化測試的展示名字:
@ParameterizedTest(name = "{index} {0} is 30 days long") @EnumSource(value = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) void someMonths_Are30DaysLong(Month month) {final boolean isALeapYear = false;assertEquals(30, month.length(isALeapYear)); }這樣執行參數化測試展示的名稱可讀性會更好,如下:
├─ someMonths_Are30DaysLong(Month) │ │ ├─ 1 APRIL is 30 days long │ │ ├─ 2 JUNE is 30 days long │ │ ├─ 3 SEPTEMBER is 30 days long │ │ └─ 4 NOVEMBER is 30 days long當我們自定義展示名字的時候可以使用下述占位符:
- {index} 該占位符會被調用下標替換,從1開始,第一個執行的下標為1,第二個為2 這樣
- {arguments} 占位符用來表示以逗號為分隔符的參數集合
- {0}, {1}, … 是單獨參數的占位符
9. 結論
在這篇文章里,我們針對JUnit 5的參數化測試的細節進行了深度研究。
我們認識到參數化測試和普通的單元測試不太一樣,主要體現在兩方面:
當然,目前為止我們知道JUnit提供了很多工具幫助我們將參數轉換成自定義的目標類型或者使用自定義的測試名字。
同樣的,上述示例代碼可在我們的GitHub項目上找到,你們可以自行去下載測試!
第一次翻譯,有些術語可能翻譯的不是很好,有問題的地方歡迎指正!
原文鏈接:
Guide to JUnit 5 Parameterized Tests
總結
以上是生活随笔為你收集整理的单元测试之JUnit 5 参数化测试使用手册的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 记一次关于mock Systemc.cu
- 下一篇: IP地址分类及CIDR划分方法