在静态成员中展平 JUnit 测试 类

Flatten JUnit tests in static member classes

对于我的一个项目,我使用 JUnit 5 来​​测试反射代码,这需要大量 classes 用于测试用例。将它们全部放在一个范围内并试图智能地命名它们几乎是不可能的,所以我希望将测试方法和被测试的类型都放在静态成员 class 中。这样做将允许我在每个测试中重用 XY 等名称,并使被测试的类型靠近测试它们的代码。 (成员 classes 必须是静态的,这样我才能添加接口)

如果我只是添加静态 classes 测试 运行 开箱即用,但在最终报告中我最终将所有成员 classes 单独列出,所以我希望能够 "flatten" 将它们全部合并到报告中的单个 class 中。

这是我想要实现的示例:(我实际上是在 Kotlin 中编写测试,但这是等效的 Java 代码)

class MethodTests {
    static class WhateverName {
        interface X {}
        class Y implements X {}

        @Test
        void something_withInterfaceSupertype_shouldReturnThing() {
            // ...
        }

        @Test
        void other_withInterfaceSupertype_shouldReturnThing() {
            // ...
        }
    }
    static class WhateverOtherName {
        interface X {
            void m();
        }
        class Y implements X {
            void m() {}
        }

        @Test
        void something_withInterfaceSupertype_andMethodImpl_shouldReturnOtherThing() {
            // ...
        }
    }

    // This might actually be even better, since I wouldn't need `WhateverName`
    @Test // not valid, but maybe I could annotate the class instead of the method?
    static class something_withInterfaceSupertype_shouldReturnThing_2ElectricBoogaloo {
        interface X {}
        class Y implements X {}

        @Test
        void runTest() {
            // ...
        }
    }

}

目前 IDEA 中的测试报告最终结构如下:

- MethodTests
  - someRoot_testHere
- MethodTests$WhateverName 
  - something_withInterfaceSupertype_shouldReturnThing
  - other_withInterfaceSupertype_shouldReturnThing
- MethodTests$WhateverOtherName 
  - something_withInterfaceSupertype_andMethodImpl_shouldReturnOtherThing
- MethodTests$something_withInterfaceSupertype_shouldReturnThing_2ElectricBoogaloo
  - runTest

但我希望测试报告的结构如下:

- MethodTests
  - someRoot_testHere
  - something_withInterfaceSupertype_shouldReturnThing
  - other_withInterfaceSupertype_shouldReturnThing
  - something_withInterfaceSupertype_andMethodImpl_shouldReturnOtherThing
  - something_withInterfaceSupertype_shouldReturnThing_2ElectricBoogaloo

我尝试在成员 classes 上使用 @DisplayName,但它只是导致报告中的名称重复。到目前为止,我想我可能想使用扩展,但经过一些研究后,我还没有找到任何方法来更改 class 测试在使用它们的报告中列出。

也许您可以重新组织输出文件和索引,或者使用 xsl/xslt 或其他形式的 post 处理来处理它们。另外,这个 article 可能很有趣。

经过更多挖掘,我几乎可以使用 dynamic tests:

实现我想要的
class MethodsTest {

    @TestFactory
    Iterator<DynamicTest> flat() {
        return FlatTestScanner.scan(this);
    }

    @Test
    void rootTest() {
    }

    @FlatTest
    static class singleTestClass implements TestClass {
        void run() {
            // ...
        }
    }

    static class Whatever {
        @FlatTest
        void multiTestClass_1() {
            // ...
        }

        @FlatTest
        void multiTestClass_2() {
            // ...
        }
    }
}

最终的报告结构不是很完美,但它非常接近我想要的:

- MethodsTest
  - flat()
    - singleTestClass
    - multiTestClass_1
    - multiTestClass_2
  - rootTest

这是实现这一目标的代码。它的工作原理是扫描所有已声明的 类 以查找带注释的方法并抓取任何本身带注释的方法,然后为它们创建动态测试,确保 specify their source URIs。它在 Kotlin 中,但需要一些工作才能翻译成 Java:

import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.DynamicTest
import java.net.URI

/**
 * Useful for having separate class scopes for tests without having fragmented reports.
 *
 * @see FlatTestScanner.scan
 */
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
annotation class FlatTest

object FlatTestScanner {
    /**
     * Returns dynamic tests to run all the flat tests declared in the passed class. This currently only works with
     * static inner classes.
     *
     * - Annotated functions in inner classes will be run
     * - Annotated inner classes will have their `run()` methods run
     *
     * To use this create a method in the outer class annotated with [@TestFactory][org.junit.jupiter.api.TestFactory]
     * and return the result of passing `this` to this method. This will return matches from superclasses as well.
     *
     * ```java
     * @TestFactory
     * Iterator<DynamicTest> flat() {
     *     return FlatTestScanner.scan(this)
     * }
     * ```
     */
    @JvmStatic
    fun scan(obj: Any): Iterator<DynamicTest> {
        val classes = generateSequence<Class<*>>(obj.javaClass) { it.superclass }
            .flatMap { it.declaredClasses.asSequence() }
            .toList()
        val testMethods = classes.asSequence()
            .map { clazz ->
                clazz to clazz.declaredMethods.filter { m -> m.isAnnotationPresent(FlatTest::class.java) }
            }
            .filter { (_, methods) -> methods.isNotEmpty() }
            .flatMap { (clazz, methods) ->
                val instance = clazz.newInstance()
                methods.asSequence().map { m ->
                    val name = m.getAnnotation(DisplayName::class.java)?.value ?: m.name

                    m.isAccessible = true
                    DynamicTest.dynamicTest(name, URI("method:${clazz.canonicalName}#${m.name}")) {
                        try {
                            m.invoke(instance)
                        } catch(e: InvocationTargetException) {
                            e.cause?.also { throw it } // unwrap assertion failures
                        }
                    }
                }
            }

        val testClasses = classes.asSequence()
            .filter { it.isAnnotationPresent(FlatTest::class.java) }
            .map {
                val name = it.getAnnotation(DisplayName::class.java)?.value ?: it.simpleName

                val instance = it.newInstance()
                val method = it.getDeclaredMethod("run")
                method.isAccessible = true
                DynamicTest.dynamicTest(name, URI("method:${it.canonicalName}#run")) {
                    try {
                        method.invoke(instance)
                    } catch(e: InvocationTargetException) {
                        e.cause?.also { throw it } // unwrap assertion failures
                    }
                }
            }
        return (testMethods + testClasses).iterator()
    }
}