如何在 Kotlin 中管理单元测试资源,例如启动/停止数据库连接或嵌入式弹性搜索服务器?

Posted

技术标签:

【中文标题】如何在 Kotlin 中管理单元测试资源,例如启动/停止数据库连接或嵌入式弹性搜索服务器?【英文标题】:How do I manage unit test resources in Kotlin, such as starting/stopping a database connection or an embedded elasticsearch server? 【发布时间】:2016-06-03 21:43:15 【问题描述】:

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

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

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

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

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

    ...

注意: 此问题由作者 (Self-Answered Questions) 有意编写和回答,因此常见的 Kotlin 主题的答案出现在 SO 中。

【问题讨论】:

JUnit 5 可能支持该用例的非静态方法,请参阅 github.com/junit-team/junit5/issues/419#issuecomment-267815529 并随时 +1 我的评论以表明 Kotlin 开发人员对此类改进感兴趣。 【参考方案1】:

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

测试类的结构如下:

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
  

鉴于以上内容,您应该阅读以下内容:

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

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

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

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 启动(从 Running AWS DynamoDB-local embedded 复制并稍作修改)。此测试必须在其他任何事情发生之前破解java.library.path,否则本地DynamoDB(使用带有二进制库的sqlite)将无法运行。然后它启动一个服务器来共享所有测试类,并清理测试之间的临时数据。测试:

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


注意:部分示例缩写为...

【讨论】:

【参考方案2】:

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

测试是“原子的”。测试通过所有回调作为一个整体执行 人们不会忘记在测试之前启动依赖服务并在完成后将其关闭。如果处理得当,执行回调将适用于任何环境。 测试是独立的。没有外部数据或设置阶段,所有内容都包含在几个测试类中。

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

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

这样你在测试代码之外的某个地方启动和初始化依赖服务。

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

可以是开发人员每次想要执行测试时手动运行的脚本。 它可以是由构建工具运行的任务(例如,Gradle 有很棒的 dependsOnfinalizedBy DSL 用于定义依赖项)。当然,任务可以执行开发人员使用 shell-outs / process execs 手动执行的相同脚本。 可以是task run by IDE before test execution。同样,它可以使用相同的脚本。 大多数 CI/CD 提供商都有“服务”的概念 — 与您的构建并行运行的外部依赖项(进程),可以通过其通常的 SDK/连接器/API 访问:Gitlab、Travis、 Bitbucket, AppVeyor, Semaphore, ...

这种方法:

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

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

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

【讨论】:

单元测试不应依赖外部资源。 我在答案顶部的专业部分提到了这一点。但是,不管你信不信,可能会有另一种观点,如下所述。 而且,严格来说,单元测试根本不应该使用数据库,它更多的是关于更高级别的测试金字塔。

以上是关于如何在 Kotlin 中管理单元测试资源,例如启动/停止数据库连接或嵌入式弹性搜索服务器?的主要内容,如果未能解决你的问题,请参考以下文章

Kotlin Multiplatform:如何在 iOS 的单元测试中模拟对象

如何在 Cordapp 中对服务和控制器(kotlin)进行单元测试?

Kotlin-multiplatform:如何执行 iOS 单元测试

如何使用 SpringBoot2、JUnit5 和 Kotlin 将配置属性注入单元测试

[在运行单元测试(UWP)时启动应用程序

如何使用 SQL Developer 管理带有回滚的单元测试