使用 JUnit 5.7 进行参数化测试:深入了解 @EnumSource    

     参数化测试允许开发人员使用一系列输入值高效地测试他们的代码。在 JUnit 测试领域,经验丰富的用户长期以来一直在努力解决实施这些测试的复杂问题。但随着 JUnit 5.7 的发布,测试参数化进入了一个新时代,为开发人员提供了一流的支持和增强的功能。让我们深入探讨 JUnit 5.7 为参数化测试带来的激动人心的可能性!

image

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 注解的同时,还必须使用所提供的几个源注解之一,说明从何处获取参数。参数来源通常被称为 "数据提供者"。

@RunWith(JUnit4.class)
@SpringBootTest
public class MyTest {

    @ParameterizedTest
     @ValueSource(ints = {1, 2, 3})
     public void testWithIntValue(int value) {
         // ...
     }

}

在此,我将不对其进行详细描述:JUnit 用户指南比我做得更好,但请允许我分享几点看法:

@ValueSource 只限于提供单个参数值。换句话说,测试方法不能有一个以上的参数,而且可以使用的类型也受到限制。
@CsvSource 在一定程度上解决了传递多个参数的问题,它将每个字符串解析为一条记录,然后作为参数逐字段传递。对于长字符串和/或大量参数,这很容易造成阅读困难。此外,可以使用的类型也受到了限制--稍后再详述。
在注解中声明实际值的所有来源都被限制为编译时常量(Java 注释的限制,而非 JUnit)。
@MethodSource 和 @ArgumentsSource 提供了(无类型的)n 个元组的流/集合,这些元组将作为方法参数传递。我们支持各种实际类型来表示 n 个元组的序列,但它们都不能保证符合方法的参数列表。这种源需要额外的方法或类,但对从哪里和如何获取测试数据没有限制。

正如您所看到的,可用的源类型从简单类型(使用简单,但功能有限)到最终需要更多代码才能运行的灵活类型不等。

Sidenote --这通常是良好设计的标志:只需少量代码就能实现基本功能,而当用于实现要求更高的用例时,增加额外的复杂性是合理的。 @EnumSource 似乎并不符合这种从简单到灵活的连续性假设。请看这个包含四个参数集的非复杂示例,每个参数集有两个值。

Note --虽然 @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());
     }

试想一下:硬编码的值列表严重限制了它的灵活性(不能使用外部数据或生成数据),而声明枚举所需的额外代码量又使它比 @CsvSource 更为冗长。

但这只是第一印象。我们将看到,当利用 Java 枚举的真正威力时,它会变得多么优雅。

附注:本文不涉及验证生产代码中的枚举。当然,无论您选择何种验证方式,都必须声明这些枚举。本文的重点是何时以及如何以枚举的形式表达测试数据。


何时使用
在某些情况下,枚举的性能比其他方法更好:

每次测试多个参数
       当您只需要一个参数时,您可能不想让 @ValueSource 以外的事情变得复杂。但一旦您需要多个参数,例如输入和预期结果,您就必须使用 @CsvSource、@MethodSource/@ArgumentsSource 或 @EnumSource。

在某种程度上,枚举可以让你 "偷渡 "任意数量的数据字段。

因此,当您将来需要添加更多测试方法参数时,只需在现有的枚举中添加更多的字段,而无需触动测试方法签名。当你在多个测试中重复使用数据提供者时,这一点就变得非常重要。

对于其他数据源,必须使用 ArgumentsAccessors 或 ArgumentsAggregators 才能获得枚举开箱即用的灵活性。

类型安全
对于 Java 开发人员来说,这应该是一个大问题。

从 CSV(文件或字面)、@MethodSource 或 @ArgumentsSource 读取的参数无法在编译时保证参数数量及其类型与签名相匹配。

显然,JUnit 会在运行时发出抱怨,但 IDE 不会提供任何代码帮助。

和以前一样,当你在多个测试中重复使用相同的参数时,这种情况就会增加。在将来扩展参数集时,使用类型安全的方法将大有裨益。

自定义类型
对于基于文本的数据源(如从 CSV 读取数据的数据源)来说,这主要是一个优势--文本中编码的值需要转换为 Java 类型。

如果要从 CSV 记录中实例化一个自定义类,可以使用 ArgumentsAggregator 来实现。但是,您的数据声明仍然不是类型安全的--方法签名和声明数据之间的任何不匹配都会在运行时 "聚合 "参数时弹出。更不用说,声明聚合器类会增加参数化工作所需的更多支持代码。为了避免额外的代码,我们更倾向于使用 @CsvSource 而不是 @EnumSource。

可文档化
      与其他方法不同,枚举源为参数集(枚举实例)及其包含的所有参数(枚举字段)都提供了 Java 符号。它们为以 JavaDoc 这种更自然的形式附加文档提供了一个直接的位置。

文档并非不能放置在其他地方,但顾名思义,它将被放置在离文档更远的地方,因此更难找到,也更容易过时。

但还有更多枚举。是类。

许多初级开发人员还没有意识到 Java 枚举的真正强大之处。

在其他编程语言中,枚举只是美化了的常量。但在 Java 中,它们是 Flyweight 设计模式的便捷小实现,具有完整类的(大部分)优点。

为什么这是件好事呢?

测试与夹具相关的行为

     与其他任何类一样,枚举可以添加方法。如果枚举测试参数在不同测试间重复使用,这就变得非常方便--相同的数据,只是测试方式略有不同。为了在不进行大量复制和粘贴的情况下有效使用参数,这些测试之间还需要共享一些辅助代码。这不是一个辅助类和几个静态方法所能 "解决 "的。附注:请注意,这样的设计会让人产生 "功能嫉妒"(Feature Envy)。测试方法--或者更糟糕的是,辅助类方法--必须从枚举对象中提取数据,才能对这些数据执行操作。虽然这是过程式编程的(唯一)方法,但在面向对象的世界里,我们可以做得更好。 在枚举声明中直接声明 "辅助 "方法,我们就可以把代码移到数据所在的地方。或者,用 OOP 术语来说,辅助方法将成为以枚举形式实现的测试夹具的 "行为"。这不仅会使代码更加习以为常(在实例上调用合理的方法而不是传递数据的静态方法),而且还能使枚举参数在测试用例中更容易地重复使用。

继承
枚举可以实现带有(默认)方法的接口。在合理使用的情况下,可以利用这一点在多个数据提供者(多个枚举)之间共享行为。我很容易想到的一个例子就是为正向测试和负向测试分别建立枚举。如果它们代表了类似的测试夹具,那么它们就有可能共享某些行为。

Talk is cheap空谈误国

     让我们用一个假设的源代码文件转换器测试套件来说明这一点,它与执行 Python 2 到 Python 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"));
         // Many, many others ...

        @Nonnull
         final String inFile;
         @CheckForNull
         final String expectedOutput;
         @CheckForNull
         final Exception expectedException;
         @Nonnull
         final Set<String> expectedWarnings;

        Conversion(@Nonnull String inFile, @Nonnull String expectedOutput, @NotNull Set<String> 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<String> 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);
         }
     }

使用枚举并不会限制数据的复杂程度。正如你所看到的,我们可以在枚举中定义几个方便的构造函数,因此声明新的参数集非常简洁。这就避免了使用长参数列表的情况,因为长参数列表中往往充满了许多 "空 "值(空值、空字符串或集合),让人不知道 7 号参数--也就是空值之一--究竟代表什么。请注意,枚举允许使用复杂类型(Set、RuntimeException),而没有任何限制或神奇的转换。传递此类数据也是完全类型安全的。实际上,你将有更多的数据样本需要验证,因此模板代码的数量相比之下就不那么重要了。

此外,还可以看看如何利用相同的枚举及其辅助方法编写相关测试:

    @ParameterizedTest
     @EnumSource
     // Upgrading files already upgraded always passes, makes no changes, issues no warnings.
     void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
         File actual = convert(con.getV3File());
         assertEquals(Set.of(), getLoggedWarnings());
         new FileAssert(actual).isEqualTo(con.getV3File());
     }

    @ParameterizedTest
     @EnumSource
     // Downgrading files created by upgrade procedure is expected to always pass without warnings.
     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() is a helper method that is shared by all FixtureXmls
     try (InputStream is = events.read()) {
         EventList el = consume(is);
         assertEquals(Set.of(...), el.getWarnings());
     }
}

现在,@EnumSource 不会成为你最常用的参数源之一,这是好事,因为过度使用它不会有任何好处。但在适当的情况下,知道如何使用它们所提供的一切,还是很方便的。



今天先到这儿,希望对云原生,技术领导力, 企业管理,系统架构设计与评估,团队管理, 项目管理, 产品管管,团队建设 有参考作用 , 您可能感兴趣的文章:
领导人怎样带领好团队
构建创业公司突击小团队
国际化环境下系统架构演化
微服务架构设计
视频直播平台的系统架构演化
微服务与Docker介绍
Docker与CI持续集成/CD
互联网电商购物车架构演变案例
互联网业务场景下消息队列架构
互联网高效研发团队管理演进之一
消息系统架构设计演进
互联网电商搜索架构演化之一
企业信息化与软件工程的迷思
企业项目化管理介绍
软件项目成功之要素
人际沟通风格介绍一
精益IT组织与分享式领导
学习型组织与企业
企业创新文化与等级观念
组织目标与个人目标
初创公司人才招聘与管理
人才公司环境与企业文化
企业文化、团队文化与知识共享
高效能的团队建设
项目管理沟通计划
构建高效的研发与自动化运维
某大型电商云平台实践
互联网数据库架构设计思路
IT基础架构规划方案一(网络系统规划)
餐饮行业解决方案之客户分析流程
餐饮行业解决方案之采购战略制定与实施流程
餐饮行业解决方案之业务设计流程
供应链需求调研CheckList
企业应用之性能实时度量系统演变

如有想了解更多软件设计与架构, 系统IT,企业信息化, 团队管理 资讯,请关注我的微信订阅号:

image

作者:Petter Liu
出处:http://www.cnblogs.com/wintersun/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。 该文章也同时发布在我的独立博客中-Petter Liu Blog。

posted on 2024-03-17 10:17  PetterLiu  阅读(23)  评论(0编辑  收藏  举报