Como faço para gerenciar recursos de teste de unidade em Kotlin, como iniciar / interromper uma conexão de banco de dados ou um servidor elasticsearch integrado?

94

Em meus testes Kotlin JUnit, quero iniciar / parar servidores incorporados e usá-los em meus testes.

Tentei usar a @Beforeanotação JUnit em um método em minha classe de teste e funciona bem, mas não é o comportamento correto, pois executa todos os casos de teste em vez de apenas uma vez.

Portanto, desejo usar a @BeforeClassanotação em um método, mas adicioná-la a um método resulta em um erro dizendo que deve ser em um método estático. Kotlin não parece ter métodos estáticos. E o mesmo se aplica a variáveis ​​estáticas, porque preciso manter uma referência ao servidor incorporado para uso nos casos de teste.

Então, como faço para criar esse banco de dados incorporado apenas uma vez para todos os meus casos de teste?

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

    ...
}

Observação: esta pergunta foi escrita e respondida intencionalmente pelo autor ( Perguntas auto-respondidas ), de modo que as respostas aos tópicos mais comuns do Kotlin estejam presentes no SO.

Jayson Minard
fonte
2
O JUnit 5 pode oferecer suporte a métodos não estáticos para esse caso de uso, consulte github.com/junit-team/junit5/issues/419#issuecomment-267815529 e fique à vontade para marcar meu comentário com +1 para mostrar que os desenvolvedores do Kotlin estão interessados ​​em tais melhorias.
Sébastien Deleuze

Respostas:

161

Sua classe de teste de unidade geralmente precisa de algumas coisas para gerenciar um recurso compartilhado para um grupo de métodos de teste. E em Kotlin você pode usar @BeforeClasse @AfterClassnão na classe de teste, mas sim dentro de seu objeto companheiro junto com a @JvmStaticanotação .

A estrutura de uma classe de teste seria assim:

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
}  

Diante do exposto, você deve ler sobre:

  • objetos complementares - semelhantes ao objeto Class em Java, mas um singleton por classe que não é estático
  • @JvmStatic - uma anotação que transforma um método de objeto complementar em um método estático na classe externa para interoperabilidade Java
  • lateinit- permite que uma varpropriedade seja inicializada mais tarde, quando você tiver um ciclo de vida bem definido
  • Delegates.notNull()- pode ser usado em vez de lateinitpara uma propriedade que deve ser definida pelo menos uma vez antes de ser lida.

Aqui estão exemplos mais completos de classes de teste para Kotlin que gerenciam recursos incorporados.

O primeiro é copiado e modificado dos testes Solr-Undertow e, antes que os casos de teste sejam executados, configura e inicia um servidor Solr-Undertow. Após a execução dos testes, ele limpa todos os arquivos temporários criados pelos testes. Ele também garante que as variáveis ​​de ambiente e as propriedades do sistema estejam corretas antes da execução dos testes. Entre os casos de teste, ele descarrega todos os núcleos Solr carregados temporariamente. O teste:

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
}

E outro iniciando o AWS DynamoDB local como um banco de dados incorporado (copiado e modificado ligeiramente de Executando o AWS DynamoDB-local incorporado ). Este teste deve hackear java.library.pathantes que qualquer coisa aconteça ou o DynamoDB local (usando sqlite com bibliotecas binárias) não será executado. Em seguida, ele inicia um servidor para compartilhar para todas as classes de teste e limpa os dados temporários entre os testes. O teste:

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

}

NOTA: algumas partes dos exemplos são abreviadas com...

Jayson Minard
fonte
0

Gerenciar recursos com callbacks antes / depois em testes, obviamente, tem seus prós:

  • Os testes são "atômicos". Um teste é executado como um todo com todos os retornos de chamada. Não se esqueça de ativar um serviço de dependência antes dos testes e desligá-lo depois de concluído. Se feito corretamente, os callbacks de execução funcionarão em qualquer ambiente.
  • Os testes são independentes. Não há dados externos ou fases de configuração, tudo está contido em algumas classes de teste.

Também tem alguns contras. Uma delas é que polui o código e faz com que o código viole o princípio da responsabilidade única. Os testes agora não apenas testam algo, mas executam uma inicialização pesada e gerenciamento de recursos. Pode ser bom em alguns casos (como configurar umObjectMapper ), mas modificar java.library.pathou gerar outros processos (ou bancos de dados embutidos em processo) não são tão inocentes.

Por que não tratar esses serviços como dependências para seu teste qualificado para "injeção", conforme descrito por 12factor.net .

Dessa forma, você inicia e inicializa os serviços de dependência em algum lugar fora do código de teste.

Hoje em dia, a virtualização e os contêineres estão em quase todos os lugares e a maioria das máquinas dos desenvolvedores pode executar o Docker. E a maior parte do aplicativo tem uma versão dockerized: ElasticSearch , DynamoDB , PostgreSQL e assim por diante. Docker é uma solução perfeita para serviços externos que seus testes precisam.

  • Pode ser um script que é executado manualmente por um desenvolvedor toda vez que ela deseja executar testes.
  • Pode ser uma tarefa executada pela ferramenta de construção (por exemplo, o Gradle tem um incrível dependsOne finalizedByDSL para definir dependências). Uma tarefa, é claro, pode executar o mesmo script que o desenvolvedor executa manualmente usando shell-outs / execs de processo.
  • Pode ser uma tarefa executada pelo IDE antes da execução do teste . Novamente, ele pode usar o mesmo script.
  • A maioria dos provedores de CI / CD tem uma noção de "serviço" - uma dependência externa (processo) que é executada em paralelo à sua construção e pode ser acessada por meio de seu SDK / conector / API usual : Gitlab , Travis , Bitbucket , AppVeyor , Semaphore , ...

Esta abordagem:

  • Libera seu código de teste da lógica de inicialização. Seus testes irão apenas testar e nada mais.
  • Desacopla código e dados. Adicionar um novo caso de teste agora pode ser feito adicionando novos dados aos serviços de dependência com seu conjunto de ferramentas nativas. Ou seja, para bancos de dados SQL, você usará SQL, para Amazon DynamoDB, você usará CLI para criar tabelas e colocar itens.
  • Está mais próximo de um código de produção, onde você obviamente não inicia esses serviços quando seu aplicativo "principal" é iniciado.

Claro, ele tem suas falhas (basicamente, as declarações das quais comecei):

  • Os testes não são mais "atômicos". O serviço de dependência deve ser iniciado de alguma forma antes da execução do teste. A forma como é iniciado pode ser diferente em diferentes ambientes: máquina do desenvolvedor ou CI, IDE ou ferramenta de construção CLI.
  • Os testes não são independentes. Agora, seus dados iniciais podem estar até mesmo compactados em uma imagem, portanto, alterá-los pode exigir a reconstrução de um projeto diferente.
cabeça louca
fonte