25

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

 3 years ago
source link: https://note.qidong.name/2018/05/junit5-parameterized/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

2018-05-23 17:06:51 +08  字数:2321  标签: Java Test

一些函数式的接口,给定输入、期待特定输出,没有太多副作用,特别适合参数化测试(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,可能更适合在快速开发的过程中,临时用一下。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK