用csv文件进行Junit5的参数化测试

一些函数式的接口,给定输入、期待特定输出,没有太多副作用,特别适合参数化测试(Parameterized Test)。 JUnit5提供了多种参数化测试的形式,本文着重介绍CsvSourceCsvFileSource

本文以leetcode上的第一个问题——Two Sum——为例,解释如何用JUnit5做参数化测试。

问题描述与输入输出

Given an array of integers, return indices of the two numbers such that they add up to a specific target.

You may assume that each input would have exactly one solution, and you may not use the same element twice.

Example:

Given nums = [2, 7, 11, 15], target = 9,

Because nums[0] + nums[1] = 2 + 7 = 9, return [0, 1].

给定输入nums = [2, 7, 11, 15]target = 9,期待输出为[0, 1],因为:

$$ nums[0] + nums[1] = 2 + 7 = 9 $$

被测试Java代码的形式如下:

public class Solution {
    public int[] twoSum(int[] nums, int target) {
        // Ignore the implementation code
    }
}

一般测试手段

一般的测试手段,就是一个测试写一个方法(Method)。

class TwoSumTest {
    void twoSum0() {
        int[] nums = new int[]{2, 7, 11, 15};
        int target = 9;
        int[] expect = new int[]{0, 1};

        TwoSum solution = new TwoSum();
        int[] result = solution.twoSum(nums, target);
        assertArrayEquals(expect, result);
    }

    void twoSum1() {
        int[] nums = new int[]{3, 2, 3};
        int target = 6;
        int[] expect = new int[]{0, 2};

        TwoSum solution = new TwoSum();
        int[] result = solution.twoSum(nums, target);
        assertArrayEquals(expect, result);
    }
}

显然,其中有大量的重复代码。 在新增一个测试case时,需要大量的复制粘贴,而且很不方便。

还有一种写法,是把所有测试全都写到一个方法中。 这相当于在一个测试case中,做了多组测试。 其问题在于,在某一组输入条件测试失败时,无法快速定位到是哪一组。 这种写法,还不如分开写。

CsvSource

CsvSourceJUnit5支持的一个注解(Annotation)。 它和ParameterizedTest一样,都属于额外的junit-jupiter-params这个Group。 使用前,需要在build.gradle中添加依赖。

ext.junitJupiterVersion = '5.1.0'

dependencies {
    ...
    testCompile "org.junit.jupiter:junit-jupiter-params:${junitJupiterVersion}"
}

使用示例如下:

class TwoSumTest {
    @ParameterizedTest
    @CsvSource({
            "'[1, 1]', 2, '[0, 1]'",
            "'[3, 2, 3]', 6, '[0, 2]'",
            "'[2, 7, 11, 15]', 9, '[0, 1]'"
    })
    void twoSum(
            @ConvertWith(String2int.class) int[] nums,
            int target,
            @ConvertWith(String2int.class) int[] expect
    ) {
        TwoSum solution = new TwoSum();
        assertArrayEquals(expect, solution.twoSum(nums, target));
    }
}

通过@CsvSource传入的每一项,就相当于一个csv文件的一行,是一个String。 每一行中,可以有若干列,这里是三列,代表测试函数的三个输入参数的一组值。 为了让值中包含分隔符,,需要用单引号''包含。

如果是基本数据类型,如intString之类,JUnit5会尝试直接转换。 而如果是不常见的数据类型,则需要使用@ConvertWith。 上一段代码中的@ConvertWith(String2int.class) int[] nums,,就是在用一个自定义类String2int.class,把String类型转换为int[]

class String2int implements ArgumentConverter {
    @Override
    public Object convert(Object source, ParameterContext context)
            throws ArgumentConversionException {
        try {
            String str = (String) source;
            str = str.trim().substring(1, str.length() - 1).trim();
            if (str.isEmpty()) return new int[]{};
            if (!str.contains(",")) return new int[]{Integer.parseInt(str)};
            return Arrays.stream(str.split(","))
                         .map(String::trim)
                         .mapToInt(Integer::parseInt).toArray();
        } catch (ClassCastException e) {
            throw new ArgumentConversionException("The source is not a String", e);
        } catch (NumberFormatException e) {
            throw new ArgumentConversionException("Some content in source is not int", e);
        }
    }
}

String2int.class需要实现ArgumentConverterconvert方法,进行Stringint[]的转换。

CsvFileSource

既然可以用csv结构的方式来输入测试参数,那么可以不可以用csv文件来作为数据源? 当然可以。 JUnit5通过CsvFileSource,支持直接输入一个csv文件。

    @ParameterizedTest
    @CsvFileSource(resources = "/two_sum.csv", numLinesToSkip = 1)
    void twoSum(
            @ConvertWith(String2int.class) int[] nums,
            int target,
            @ConvertWith(String2int.class) int[] expect
    ) {
        assertArrayEquals(expect, solution.twoSum(nums, target));
    }

其中,resources = "/two_sum.csv"是指定csv文件,这个文件在测试代码的resources目录下。 按照默认的Gradle目录结构(如下),two_sum.csv需要放在src/test/resources/目录下。

src
├── main
│   ├── java
│   └── resources
└── test
    ├── java
    └── resources

numLinesToSkip = 1是略过文件第1行,因为第一行通常是csv列名。 以下为csv文件内容示例,与CsvSource那边略有不同:

nums,target,expect
"[1, 1]",2,"[0, 1]"
"[3, 2, 3]",6,"[0, 2]"
"[2, 7, 11, 15]",9,"[0, 1]"

这里选用[2, 7, 11, 15]这种形式的字符串来作为输入源,是借鉴了Python的表达方式。 实际上,CsvSource的形式非常易于不同语言之间共享测试case。

无论是CsvSource还是CsvFileSource,如果某一个单元格的内容为空,比如三个参数都为空的一行,,,在参数转换时会得到null。 如果为了避免null而使用空字符串"",则需要在CsvSource中用"'','',''"、在CsvFileSource中用"","",""。 可以参考《CsvSource with empty strings instead of null · Issue #1014 · junit-team/junit5》。

测试结果

无论参数化测试怎么写,都可以在执行gradle test时,得到以下形式的测试结果。

$ gradle test

> Task :junitPlatformTest
╷
└─ JUnit Jupiter ✔
   └─ TwoSumTest ✔
      └─ twoSum(int[], int, int[]) ✔
         ├─ [1] [1, 1], 2, [0, 1] ✔
         ├─ [2] [3, 2, 3], 6, [0, 2] ✔
         └─ [3] [2, 7, 11, 15], 9, [0, 1] ✔

Test run finished after 219 ms

这种表达非常简单明了,容易定位问题。

总结

本文介绍的CsvSourceCsvFileSource,虽然细节上有些差异,但本质上是一回事。 在实际使用中,CsvFileSource可能更实用些。 单独的csv文件,更容易用规范的方法来写入(如Excel)或生成(如Python)。

而类似CsvSource的形式,优点是简单易用。 但还有更简单易用的办法,如ValueSource,直接把类型明确的测试case写在注解中。 此外,还有EnumSource,通过额外写一个enum来携带数据;或MethodSource,写一个Method来生成数据。 这三种形式,和CsvSource的写法非常类似,本文不再赘述。 (可以参考官方文档,或《JUnit 5 - Parameterized Tests - blog@CodeFX》。)

凡是需要参数化测试的地方,说明测试case数量不少,而且会需要随时增加。 比较下来,CsvFileSource才是最合适的。 而其它形式,尤其是ValueSource,可能更适合在快速开发的过程中,临时用一下。


相关笔记