关于junit:如何管理Kotlin中的单元测试资源,例如启动/停止数据库连接还是嵌入式弹性搜索服务器?

How do I manage unit test resources in Kotlin, such as starting/stopping a database connection or an embedded elasticsearch server?

在我的Kotlin JUnit测试中,我想启动/停止嵌入式服务器并在我的测试中使用它们。

我尝试在我的测试类中的方法上使用JUnit @Before注释,它工作正常,但它不是正确的行为,因为它运行每个测试用例而不是一次。

因此,我想在方法上使用@BeforeClass注释,但将其添加到方法会导致错误,说它必须在静态方法上。 Kotlin似乎没有静态方法。 然后同样适用于静态变量,因为我需要保留对嵌入式服务器的引用,以便在测试用例中使用。

那么如何为我的所有测试用例创建一次这个嵌入式数据库呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyTest {
    @Before fun setup() {
       // works in that it opens the database connection, but is wrong
       // since this is per test case instead of being shared for all
    }

    @BeforeClass fun setupClass() {
       // what I want to do instead, but results in error because
       // this isn't a static method, and static keyword doesn't exist
    }

    var referenceToServer: ServerType // wrong because is not static either

    ...
}

注意:这个问题是由作者故意编写和回答的(自答案问题),因此常见问题的Kotlin主题的答案存在于SO中。


您的单元测试类通常需要一些东西来管理一组测试方法的共享资源。在Kotlin中,您可以不在测试类中使用@BeforeClass@AfterClass,而是在其伴随对象中使用@JvmStatic注释。

测试类的结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class MyTestClass {
    companion object {
        init {
           // things that may need to be setup before companion class member variables are instantiated
        }

        // variables you initialize for the class just once:
        val someClassVar = initializer()

        // variables you initialize for the class later in the @BeforeClass method:
        lateinit var someClassLateVar: SomeResource

        @BeforeClass @JvmStatic fun setup() {
           // things to execute once and keep around for the class
        }

        @AfterClass @JvmStatic fun teardown() {
           // clean up after this class, leave nothing dirty behind
        }
    }

    // variables you initialize per instance of the test class:
    val someInstanceVar = initializer()

    // variables you initialize per test case later in your @Before methods:
    var lateinit someInstanceLateZVar: MyType

    @Before fun prepareTest() {
        // things to do before each test
    }

    @After fun cleanupTest() {
        // things to do after each test
    }

    @Test fun testSomething() {
        // an actual test case
    }

    @Test fun testSomethingElse() {
        // another test case
    }

    // ...more test cases
}

鉴于上述情况,您应该阅读:

  • 伴随对象 - 类似于Java中的Class对象,但每个类的单例非静态
  • @JvmStatic - 一个注释,它将伴随对象方法转换为Java互操作的外部类上的静态方法
  • lateinit - 允许稍后在具有明确定义的生命周期时初始化var属性
  • Delegates.notNull() - 可以代替lateinit用于在读取之前应至少设置一次的属性。

以下是管理嵌入式资源的Kotlin测试类的更完整示例。

第一个是从Solr-Undertow测试中复制和修改的,在测试用例运行之前,配置并启动Solr-Undertow服务器。测试运行后,它会清除测试创建的所有临时文件。它还确保在运行测试之前环境变量和系统属性是正确的。在测试用例之间,它卸载任何临时加载的Solr内核。考试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class TestServerWithPlugin {
    companion object {
        val workingDir = Paths.get("test-data/solr-standalone").toAbsolutePath()
        val coreWithPluginDir = workingDir.resolve("plugin-test/collection1")

        lateinit var server: Server

        @BeforeClass @JvmStatic fun setup() {
            assertTrue(coreWithPluginDir.exists(),"test core w/plugin does not exist $coreWithPluginDir")

            // make sure no system properties are set that could interfere with test
            resetEnvProxy()
            cleanSysProps()
            routeJbossLoggingToSlf4j()
            cleanFiles()

            val config = mapOf(...)
            val configLoader = ServerConfigFromOverridesAndReference(workingDir, config) verifiedBy { loader ->
                ...
            }

            assertNotNull(System.getProperty("solr.solr.home"))

            server = Server(configLoader)
            val (serverStarted, message) = server.run()
            if (!serverStarted) {
                fail("Server not started: '$message'")
            }
        }

        @AfterClass @JvmStatic fun teardown() {
            server.shutdown()
            cleanFiles()
            resetEnvProxy()
            cleanSysProps()
        }

        private fun cleanSysProps() { ... }

        private fun cleanFiles() {
            // don't leave any test files behind
            coreWithPluginDir.resolve("data").deleteRecursively()
            Files.deleteIfExists(coreWithPluginDir.resolve("core.properties"))
            Files.deleteIfExists(coreWithPluginDir.resolve("core.properties.unloaded"))
        }
    }

    val adminClient: SolrClient = HttpSolrClient("http://localhost:8983/solr/")

    @Before fun prepareTest() {
        // anything before each test?
    }

    @After fun cleanupTest() {
        // make sure test cores do not bleed over between test cases
        unloadCoreIfExists("tempCollection1")
        unloadCoreIfExists("tempCollection2")
        unloadCoreIfExists("tempCollection3")
    }

    private fun unloadCoreIfExists(name: String) { ... }

    @Test
    fun testServerLoadsPlugin() {
        println("Loading core 'withplugin' from dir ${coreWithPluginDir.toString()}")
        val response = CoreAdminRequest.createCore("tempCollection1", coreWithPluginDir.toString(), adminClient)
        assertEquals(0, response.status)
    }

    // ... other test cases
}

另一个启动AWS DynamoDB本地作为嵌入式数据库(从运行AWS DynamoDB本地嵌入式中略微复制和修改)。此测试必须在发生任何其他事情之前破解java.library.path,否则本地DynamoDB(使用带有二进制库的sqlite)将无法运行。然后它启动一个服务器来共享所有测试类,并在测试之间清理临时数据。考试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class TestAccountManager {
    companion object {
        init {
            // we need to control the"java.library.path" or sqlite cannot find its libraries
            val dynLibPath = File("./src/test/dynlib/").absoluteFile
            System.setProperty("java.library.path", dynLibPath.toString());

            // TEST HACK: if we kill this value in the System classloader, it will be
            // recreated on next access allowing java.library.path to be reset
            val fieldSysPath = ClassLoader::class.java.getDeclaredField("sys_paths")
            fieldSysPath.setAccessible(true)
            fieldSysPath.set(null, null)

            // ensure logging always goes through Slf4j
            System.setProperty("org.eclipse.jetty.util.log.class","org.eclipse.jetty.util.log.Slf4jLog")
        }

        private val localDbPort = 19444

        private lateinit var localDb: DynamoDBProxyServer
        private lateinit var dbClient: AmazonDynamoDBClient
        private lateinit var dynamo: DynamoDB

        @BeforeClass @JvmStatic fun setup() {
            // do not use ServerRunner, it is evil and doesn't set the port correctly, also
            // it resets logging to be off.
            localDb = DynamoDBProxyServer(localDbPort, LocalDynamoDBServerHandler(
                    LocalDynamoDBRequestHandler(0, true, null, true, true), null)
            )
            localDb.start()

            // fake credentials are required even though ignored
            val auth = BasicAWSCredentials("fakeKey","fakeSecret")
            dbClient = AmazonDynamoDBClient(auth) initializedWith {
                signerRegionOverride ="us-east-1"
                setEndpoint("http://localhost:$localDbPort")
            }
            dynamo = DynamoDB(dbClient)

            // create the tables once
            AccountManagerSchema.createTables(dbClient)

            // for debugging reference
            dynamo.listTables().forEach { table ->
                println(table.tableName)
            }
        }

        @AfterClass @JvmStatic fun teardown() {
            dbClient.shutdown()
            localDb.stop()
        }
    }

    val jsonMapper = jacksonObjectMapper()
    val dynamoMapper: DynamoDBMapper = DynamoDBMapper(dbClient)

    @Before fun prepareTest() {
        // insert commonly used test data
        setupStaticBillingData(dbClient)
    }

    @After fun cleanupTest() {
        // delete anything that shouldn't survive any test case
        deleteAllInTable<Account>()
        deleteAllInTable<Organization>()
        deleteAllInTable<Billing>()
    }

    private inline fun <reified T: Any> deleteAllInTable() { ... }

    @Test fun testAccountJsonRoundTrip() {
        val acct = Account("123",  ...)
        dynamoMapper.save(acct)

        val item = dynamo.getTable("Accounts").getItem("id","123")
        val acctReadJson = jsonMapper.readValue<Account>(item.toJSON())
        assertEquals(acct, acctReadJson)
    }

    // ...more test cases

}

注意:示例的某些部分缩写为...


显然,在测试中回调之前/之后管理资源有其优点:

  • 测试是"原子的"。测试作为一个整体执行与所有回调一个人不会忘记在测试之前启动依赖服务并在完成后关闭它。如果操作正确,执行回调将适用于任何环境。
  • 测试是独立的。没有外部数据或设置阶段,一切都包含在几个测试类中。

它也有一些缺点。其中一个重要的是它污染了代码并使代码违反了单一责任原则。测试现在不仅测试某些东西,还执行重量级初始化和资源管理。在某些情况下可能没问题(比如配置ObjectMapper),但修改java.library.path或生成其他进程(或进程内嵌入式数据库)并不是那么无辜。

为什么不将这些服务视为符合"注入"条件的测试的依赖项,如12factor.net所述。

这样,您可以在测试代码之外的某处启动和初始化依赖项服务。

如今虚拟化和容器几乎无处不在,大多数开发人员的机器都可以运行Docker。而且大多数应用程序都有一个dockerized版本:Elasticsearch,DynamoDB,PostgreSQL等。 Docker是您的测试所需的外部服务的完美解决方案。

  • 它可以是运行的脚本,每次她想要执行测试时由开发人员手动运行。
  • 它可以是由构建工具运行的任务(例如,Gradle具有用于定义依赖性的令人敬畏的dependsOnfinalizedBy DSL)。当然,任务可以执行开发人员使用shell-outs / process execs手动执行的相同脚本。
  • 它可以是在测试执行之前由IDE运行的任务。同样,它可以使用相同的脚本。
  • 大多数CI / CD提供商都有"服务"的概念 - 与您的构建并行运行的外部依赖(进程),可以通过它常用的SDK / connector / API访问:Gitlab,Travis,Bitbucket,AppVeyor,Semaphore,......

这种方法:

  • 从初始化逻辑中释放您的测试代码。您的测试只会测试,不做任何其他事情。
  • 解耦代码和数据。现在可以通过使用本机工具集将新数据添加到依赖服务中来添加新的测试用例。即对于SQL数据库,您将使用SQL,对于Amazon DynamoDB,您将使用CLI创建表并放置项目。
  • 更接近生产代码,当您的"主"应用程序启动时,您显然无法启动这些服务。

当然,它有它的缺点(基本上,我开始的陈述):

  • 测试不是更"原子"。必须在测试执行之前以某种方式启动依赖服务。它的启动方式可能在不同的环境中有所不同:开发人员的机器或CI,IDE或构建工具CLI。
  • 测试不是独立的。现在您的种子数据可能甚至打包在图像中,因此更改它可能需要重建不同的项目。