欢迎光临散文网 会员登陆 & 注册

JUnit 5 参数化测试

2023-04-25 10:25 作者:信码由缰  | 我要投稿


JUnit 5参数化测试

目录

  • 设置

  • 我们的第一个参数化测试

  • 参数来源

    • @ValueSource

    • @NullSource & @EmptySource

    • @MethodSource

    • @CsvSource

    • @CsvFileSource

    • @EnumSource

    • @ArgumentsSource

    • 参数转换

    • 参数聚合

  • 奖励

  • 总结

如果您正在阅读这篇文章,说明您已经熟悉了JUnit

让我为您概括一下JUnit——在软件开发中,我们开发人员编写的代码可能是设计一个人的个人资料这样简单,也可能是在银行系统中进行付款这样复杂。在开发这些功能时,我们倾向于编写单元测试。顾名思义,单元测试的主要目的是确保代码的小、单独部分按预期功能工作。如果单元测试执行失败,这意味着该功能无法按预期工作。编写单元测试的一种工具是JUnit。这些单元测试程序很小,但是非常强大,并且可以快速执行。如果您想了解更多关于JUnit 5(也称为JUnit Jupiter)的信息,请查看这篇JUnit5的文章

现在我们已经了解了JUnit,接下来让我们聚焦于JUnit 5中的参数化测试。参数化测试可以解决在为任何新/旧功能开发测试框架时遇到的最常见问题。

  • 编写针对每个可能输入的测试用例变得更加容易。

  • 单个测试用例可以接受多个输入来测试源代码,有助于减少代码重复。

  •  通过使用多个输入运行单个测试用例,我们可以确信已涵盖所有可能的场景,并维护更好的代码覆盖率。

开发团队通过利用方法和类来创建可重用且松散耦合的源代码。传递给代码的参数会影响其功能。例如,计算器类中的sum方法可以处理整数和浮点数值。JUnit 5引入了执行参数化测试的能力,可以使用单个测试用例测试源代码,该测试用例可以接受不同的输入。这样可以更有效地进行测试,因为在旧版本的JUnit中,必须为每种输入类型创建单独的测试用例,从而导致大量的代码重复。

示例代码

本文附带有在 GitHub上 的一个可工作的示例代码。

设置

就像疯狂泰坦灭霸喜欢访问力量一样,您可以使用以下Maven依赖项来访问JUnit5中参数化测试的力量:

<dependency>

    <groupId>org.junit.jupiter</groupId>

    <artifactId>junit-jupiter-params</artifactId>

    <version>5.9.2</version>

    <scope>test</scope>

</dependency>

让我们来写些代码,好吗?

我们的第一个参数化测试

 现在,我想向您介绍一个新的注解 @ParameterizedTest。顾名思义,它告诉JUnit引擎使用不同的输入值运行此测试。

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.params.ParameterizedTest;

import org.junit.jupiter.params.provider.ValueSource;

public class ValueSourceTest {

    @ParameterizedTest

    @ValueSource(ints = { 2, 4 })

    void checkEvenNumber(int number) {

        assertEquals(0, number % 2,

         "Supplied number is not an even number");

    }

}

在上面的示例中,注解@ValueSource为 checkEvenNumber() 方法提供了多个输入。假设我们使用JUnit4编写相同的代码,即使它们的结果(断言)完全相同,我们也必须编写2个测试用例来覆盖输入2和4。

当我们执行 ValueSourceTest 时,我们会看到什么:

ValueSourceTest

|_ checkEvenNumber

|_ [1] 2

|_ [2] 4

这意味着 checkEvenNumber() 方法将使用2个输入值执行。

在下一节中,让我们学习一下JUnit5框架提供的各种参数来源。

参数来源

JUnit5提供了许多参数来源注释。下面的章节将简要概述其中一些注释并提供示例。

@ValueSource

 @ValueSource是一个简单的参数源,可以接受单个字面值数组。@ValueSource支持的字面值类型有shortbyteintlongfloatdoublecharbooleanStringClass

@ParameterizedTest

@ValueSource(strings = { "a1", "b2" })

void checkAlphanumeric(String word) {

    assertTrue(StringUtils.isAlphanumeric(word),

             "Supplied word is not alpha-numeric");

}

@NullSource & @EmptySource

假设我们需要验证用户是否已经提供了所有必填字段(例如在登录函数中需要提供用户名和密码)。我们使用注解来检查提供的字段是否为 null,空字符串或空格。

  • 在单元测试中使用 @NullSource 和 @EmptySource 可以帮助我们提供带有 null、空字符串和空格的数据源,并验证源代码的行为。

@ParameterizedTest

@NullSource

void checkNull(String value) {

    assertEquals(null, value);

}

@ParameterizedTest

@EmptySource

void checkEmpty(String value) {

    assertEquals("", value);

}

  • 我们还可以使用 @NullAndEmptySource 注解来组合传递 null 和空输入。

@ParameterizedTest

@NullAndEmptySource

void checkNullAndEmpty(String value) {

    assertTrue(value == null || value.isEmpty());

}

  • 另一个传递 null、空字符串和空格输入值的技巧是结合使用 @NullAndEmptySource 注解,以覆盖所有可能的负面情况。该注解允许我们从一个或多个测试类的工厂方法中加载输入,并生成一个参数流。

@ParameterizedTest

@NullAndEmptySource

@ValueSource(strings = { " ", " " })

void checkNullEmptyAndBlank(String value) {

    assertTrue(value == null || value.isBlank());

}

@MethodSource

该注解允许我们从一个或多个测试类的工厂方法中加载输入,并生成一个参数流。

  • 显式方法源 - 测试将尝试加载提供的方法。

// Note: The test will try to load the supplied method

@ParameterizedTest

@MethodSource("checkExplicitMethodSourceArgs")

void checkExplicitMethodSource(String word) {

assertTrue(StringUtils.isAlphanumeric(word),

"Supplied word is not alpha-numeric");

}

static Stream<String> checkExplicitMethodSourceArgs() {

return Stream.of("a1",

"b2");

}

  • 隐式方法源 - 测试将搜索与测试类匹配的源方法。

// Note: The test will search for the source method

// that matches the test-case method name

@ParameterizedTest

@MethodSource

void checkImplicitMethodSource(String word) {

    assertTrue(StringUtils.isAlphanumeric(word),

"Supplied word is not alpha-numeric");

}

static Stream<String> checkImplicitMethodSource() {

return Stream.of("a1",

"b2");

}

  • 多参数方法源 - 我们必须将输入作为参数流传递。测试将按照索引顺序加载参数。

// Note: The test will automatically map arguments based on the index

@ParameterizedTest

@MethodSource

void checkMultiArgumentsMethodSource(int number, String expected) {

    assertEquals(StringUtils.equals(expected, "even") ? 0 : 1, number % 2);

}

static Stream<Arguments> checkMultiArgumentsMethodSource() {

    return Stream.of(Arguments.of(2, "even"),

     Arguments.of(3, "odd"));

}

  • 外部方法源 - 测试将尝试加载外部方法。

// Note: The test will try to load the external method

@ParameterizedTest

@MethodSource(

"source.method.ExternalMethodSource#checkExternalMethodSourceArgs")

void checkExternalMethodSource(String word) {

    assertTrue(StringUtils.isAlphanumeric(word),

"Supplied word is not alpha-numeric");

}

package source.method;

import java.util.stream.Stream;

public class ExternalMethodSource {

    static Stream<String> checkExternalMethodSourceArgs() {

        return Stream.of("a1",

         "b2");

    }

}

@CsvSource

该注解允许我们将参数列表作为逗号分隔的值(即 CSV 字符串字面量)传递,每个 CSV 记录都会导致执行一次参数化测试。它还支持使用 useHeadersInDisplayName属性跳过 CSV 标头。

@ParameterizedTest

@CsvSource({ "2, even",

"3, odd"})

void checkCsvSource(int number, String expected) {

    assertEquals(StringUtils.equals(expected, "even")

     ? 0 : 1, number % 2);

}

@CsvFileSource

该注解允许我们使用类路径或本地文件系统中的逗号分隔值(CSV)文件。与 @CsvSource 类似,每个 CSV 记录都会导致执行一次参数化测试。它还支持各种其他属性 -numLinesToSkipuseHeadersInDisplayNamelineSeparatordelimiterString等。

示例 1: 基本实现

@ParameterizedTest

@CsvFileSource(

files = "src/test/resources/csv-file-source.csv",

numLinesToSkip = 1)

void checkCsvFileSource(int number, String expected) {

    assertEquals(StringUtils.equals(expected, "even")

                 ? 0 : 1, number % 2);

}

src/test/resources/csv-file-source.csv

NUMBER, ODD_EVEN

2, even

3, odd

示例2:使用属性

@ParameterizedTest

@CsvFileSource(

    files = "src/test/resources/csv-file-source_attributes.csv",

    delimiterString = "|",

    lineSeparator = "||",

    numLinesToSkip = 1)

void checkCsvFileSourceAttributes(int number, String expected) {

    assertEquals(StringUtils.equals(expected, "even")

? 0 : 1, number % 2);

}

src/test/resources/csv-file-source_attributes.csv

|| NUMBER | ODD_EVEN ||

|| 2      | even ||

|| 3      | odd     ||

@EnumSource

该注解提供了一种方便的方法来使用枚举常量作为测试用例参数。支持的属性包括:

  • value - 枚举类类型,例如 ChronoUnit.class

package java.time.temporal;

public enum ChronoUnit implements TemporalUnit {

    SECONDS("Seconds", Duration.ofSeconds(1)),

    MINUTES("Minutes", Duration.ofSeconds(60)),

HOURS("Hours", Duration.ofSeconds(3600)),

    DAYS("Days", Duration.ofSeconds(86400)),

    //12 other units

}

 ChronoUnit 是一个包含标准日期周期单位的枚举类型。

@ParameterizedTest

@EnumSource(ChronoUnit.class)

void checkEnumSourceValue(ChronoUnit unit) {

assertNotNull(unit);

}

在此示例中,@EnumSource 将传递所有16个 ChronoUnit 枚举值作为参数。

  • names - 枚举常量的名称或选择名称的正则表达式,例如 DAYS 或 ^.*DAYS$

@ParameterizedTest

@EnumSource(names = { "DAYS", "HOURS" })

void checkEnumSourceNames(ChronoUnit unit) {

    assertNotNull(unit);

}

@ArgumentsSource

该注解提供了一个自定义的可重用ArgumentsProviderArgumentsProvider的实现必须是外部类或静态嵌套类。

  • 外部参数提供程序

public class ArgumentsSourceTest {

    @ParameterizedTest

    @ArgumentsSource(ExternalArgumentsProvider.class)

    void checkExternalArgumentsSource(int number, String expected) {

        assertEquals(StringUtils.equals(expected, "even")

                    ? 0 : 1, number % 2,

                    "Supplied number " + number +

                    " is not an " + expected + " number");

    }

}

public class ExternalArgumentsProvider implements ArgumentsProvider {

    @Override

    public Stream<? extends Arguments> provideArguments(

        ExtensionContext context) throws Exception {

        return Stream.of(Arguments.of(2, "even"),

             Arguments.of(3, "odd"));

    }

}

  • 静态嵌套参数提供程序

public class ArgumentsSourceTest {

    @ParameterizedTest

    @ArgumentsSource(NestedArgumentsProvider.class)

    void checkNestedArgumentsSource(int number, String expected) {

        assertEquals(StringUtils.equals(expected, "even")

? 0 : 1, number % 2,

                 "Supplied number " + number +

                    " is not an " + expected + " number");

    }

    static class NestedArgumentsProvider implements ArgumentsProvider {

        @Override

        public Stream<? extends Arguments> provideArguments(

            ExtensionContext context) throws Exception {

            return Stream.of(Arguments.of(2, "even"),

     Arguments.of(3, "odd"));

        }

    }

}

参数转换

首先,想象一下如果没有参数转换,我们将不得不自己处理参数数据类型的问题。 

源方法: Calculator 

public int sum(int a, int b) {

    return a + b;

}

测试用例:

@ParameterizedTest

@CsvSource({ "10, 5, 15" })

void calculateSum(String num1, String num2, String expected) {

    int actual = calculator.sum(Integer.parseInt(num1),

                                Integer.parseInt(num2));

    assertEquals(Integer.parseInt(expected), actual);

}

如果我们有String参数,而我们正在测试的源方法接受Integers,则在调用源方法之前,我们需要负责进行此转换。

JUnit5 提供了不同的参数转换方式

  • 扩展原始类型转换

@ParameterizedTest

@ValueSource(ints = { 2, 4 })

void checkWideningArgumentConversion(long number) {

    assertEquals(0, number % 2);

}

使用 @ValueSource(ints = { 1, 2, 3 }) 进行参数化测试时,可以声明接受 int、long、float 或 double 类型的参数。

  • 隐式转换

@ParameterizedTest

@ValueSource(strings = "DAYS")

void checkImplicitArgumentConversion(ChronoUnit argument) {

    assertNotNull(argument.name());

}

JUnit5提供了几个内置的隐式类型转换器。转换取决于声明的方法参数类型。例如,用@ValueSource(strings = "DAYS")注释的参数化测试会隐式转换为类型ChronoUnit的参数。

  • 回退字符串到对象的转换

@ParameterizedTest

@ValueSource(strings = { "Name1", "Name2" })

void checkImplicitFallbackArgumentConversion(Person person) {

    assertNotNull(person.getName());

}

public class Person {

    private String name;

    public Person(String name) {

        this.name = name;

    }

    //Getters & Setters

}

JUnit5提供了一个回退机制,用于自动将字符串转换为给定目标类型,如果目标类型声明了一个适用的工厂方法或工厂构造函数。例如,用@ValueSource(strings = { "Name1", "Name2" })注释的参数化测试可以声明接受一个类型为Person的参数,其中包含一个名为name且类型为string的单个字段。

  • 显式转换

@ParameterizedTest

@ValueSource(ints = { 100 })

void checkExplicitArgumentConversion(

    @ConvertWith(StringSimpleArgumentConverter.class) String argument) {

    assertEquals("100", argument);

}

public class StringSimpleArgumentConverter extends SimpleArgumentConverter {

    @Override

    protected Object convert(Object source, Class<?> targetType)

        throws ArgumentConversionException {

        return String.valueOf(source);

    }

}

如果由于某种原因,您不想使用隐式参数转换,则可以使用@ConvertWith注释来定义自己的参数转换器。例如,用@ValueSource(ints = { 100 })注释的参数化测试可以声明接受一个类型为String的参数,使用StringSimpleArgumentConverter.class将整数转换为字符串类型。

参数聚合

@ArgumentsAccessor

默认情况下,提供给@ParameterizedTest方法的每个参数对应于一个方法参数。因此,当提供大量参数的参数源可以导致大型方法签名时,我们可以使用ArgumentsAccessor而不是声明多个参数。类型转换支持如上面的隐式转换所述。

@ParameterizedTest

@CsvSource({ "John, 20",

         "Harry, 30" })

void checkArgumentsAccessor(ArgumentsAccessor arguments) {

    Person person = new Person(arguments.getString(0),

                             arguments.getInteger(1));

    assertTrue(person.getAge() > 19, person.getName() + " is a teenager");

}

自定义聚合器

我们看到ArgumentsAccessor可以直接访问@ParameterizedTest方法的参数。如果我们想在多个测试中声明相同的ArgumentsAccessor怎么办?JUnit5通过提供自定义可重用的聚合器来解决此问题。

  • @AggregateWith

@ParameterizedTest

@CsvSource({ "John, 20",

             "Harry, 30" })

void checkArgumentsAggregator(

    @AggregateWith(PersonArgumentsAggregator.class) Person person) {

    assertTrue(person.getAge() > 19, person.getName() + " is a teenager");

}

public class PersonArgumentsAggregator implements ArgumentsAggregator {

    @Override

    public Object aggregateArguments(ArgumentsAccessor arguments,

        ParameterContext context) throws ArgumentsAggregationException {

        return new Person(arguments.getString(0),

arguments.getInteger(1));

    }

}

实现ArgumentsAggregator接口并通过@AggregateWith注释在@ParameterizedTest方法中注册它。当我们执行测试时,它会将聚合结果作为对应测试的参数提供。ArgumentsAggregator的实现可以是外部类或静态嵌套类。

额外福利

由于您已经阅读完文章,我想给您一个额外的福利 - 如果您正在使用像Fluent assertions for java这样的断言框架,则可以将java.util.function.Consumer作为参数传递,其中包含断言本身。

@ParameterizedTest

@MethodSource("checkNumberArgs")

void checkNumber(int number, Consumer<Integer> consumer) {

    consumer.accept(number);    

}

static Stream<Arguments> checkNumberArgs() {    

    Consumer<Integer> evenConsumer =

            i -> Assertions.assertThat(i % 2).isZero();

    Consumer<Integer> oddConsumer =

            i -> Assertions.assertThat(i % 2).isEqualTo(1);

    return Stream.of(Arguments.of(2, evenConsumer),

         Arguments.of(3, oddConsumer));

}

总结

JUnit5的参数化测试功能通过消除重复测试用例的需要,提供多次使用不同输入运行相同测试的能力,实现了高效的测试。这不仅为开发团队节省了时间和精力,而且还增加了测试过程的覆盖范围和有效性。此外,该功能允许对源代码进行更全面的测试,因为可以使用更广泛的输入进行测试,从而增加了识别任何潜在的错误或问题的机会。总体而言,JUnit5的参数化测试是提高代码质量和可靠性的有价值的工具。

【注】本文译自: JUnit 5 Parameterized Tests (reflectoring.io)


JUnit 5 参数化测试的评论 (共 条)

分享到微博请遵守国家法律