¿Cómo administro los recursos de prueba unitaria en Kotlin, como iniciar / detener una conexión de base de datos o un servidor de búsqueda elástica integrado?

94

En mis pruebas de Kotlin JUnit, quiero iniciar / detener servidores integrados y usarlos dentro de mis pruebas.

Intenté usar la @Beforeanotación JUnit en un método en mi clase de prueba y funciona bien, pero no es el comportamiento correcto ya que ejecuta todos los casos de prueba en lugar de solo una vez.

Por lo tanto, quiero usar la @BeforeClassanotación en un método, pero agregarla a un método da como resultado un error que dice que debe estar en un método estático. Kotlin no parece tener métodos estáticos. Y luego lo mismo se aplica a las variables estáticas, porque necesito mantener una referencia al servidor integrado para usar en los casos de prueba.

Entonces, ¿cómo creo esta base de datos integrada solo una vez para todos mis casos de prueba?

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

    ...
}

Nota: esta pregunta fue escrita y respondida intencionalmente por el autor ( Preguntas auto- respondidas ), de modo que las respuestas a los temas de Kotlin más frecuentes estén presentes en SO.

Jayson Minard
fuente
2
JUnit 5 puede admitir métodos no estáticos para ese caso de uso, consulte github.com/junit-team/junit5/issues/419#issuecomment-267815529 y no dude en hacer +1 en mi comentario para mostrar que los desarrolladores de Kotlin están interesados ​​en tales mejoras.
Sébastien Deleuze

Respuestas:

156

Su clase de prueba unitaria generalmente necesita algunas cosas para administrar un recurso compartido para un grupo de métodos de prueba. Y en Kotlin puede usar @BeforeClassy @AfterClassno en la clase de prueba, sino dentro de su objeto complementario junto con la @JvmStaticanotación .

La estructura de una clase de prueba se vería así:

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
}  

Dado lo anterior, debe leer sobre:

  • objetos complementarios : similar al objeto Class en Java, pero un singleton por clase que no es estático
  • @JvmStatic - una anotación que convierte un método de objeto complementario en un método estático en la clase externa para la interoperabilidad de Java
  • lateinit- permite que una varpropiedad se inicialice más tarde cuando tiene un ciclo de vida bien definido
  • Delegates.notNull()- se puede usar en lugar de lateinitpara una propiedad que debe establecerse al menos una vez antes de leerse.

A continuación, se muestran ejemplos más completos de clases de prueba para Kotlin que administran recursos integrados.

El primero se copia y modifica de las pruebas de Solr-Undertow , y antes de ejecutar los casos de prueba, se configura e inicia un servidor Solr-Undertow. Una vez ejecutadas las pruebas, limpia los archivos temporales creados por las pruebas. También garantiza que las variables de entorno y las propiedades del sistema sean correctas antes de que se ejecuten las pruebas. Entre los casos de prueba, descarga los núcleos Solr cargados temporalmente. La prueba:

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
}

Y otro que inicia AWS DynamoDB local como una base de datos integrada (copiada y modificada ligeramente de Ejecución de AWS DynamoDB local integrado ). Esta prueba debe piratear java.library.pathantes de que suceda cualquier otra cosa o DynamoDB local (usando sqlite con bibliotecas binarias) no se ejecutará. Luego, inicia un servidor para compartir con todas las clases de prueba y limpia los datos temporales entre las pruebas. La prueba:

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: algunas partes de los ejemplos se abrevian con...

Jayson Minard
fuente
0

La gestión de recursos con devoluciones de llamada antes / después en las pruebas, obviamente, tiene sus ventajas:

  • Las pruebas son "atómicas". Una prueba se ejecuta como un todo con todas las devoluciones de llamada. Uno no se olvidará de iniciar un servicio de dependencia antes de las pruebas y cerrarlo después de que esté hecho. Si se hace correctamente, las devoluciones de llamada de ejecuciones funcionarán en cualquier entorno.
  • Las pruebas son autónomas. No hay datos externos ni fases de configuración, todo está contenido en unas pocas clases de prueba.

También tiene algunas desventajas. Uno importante de ellos es que contamina el código y hace que el código viole el principio de responsabilidad única. Las pruebas ahora no solo prueban algo, sino que realizan una inicialización pesada y una gestión de recursos. Puede estar bien en algunos casos (como configurar unObjectMapper ), pero modificar java.library.patho generar otros procesos (o bases de datos integradas en proceso) no es tan inocente.

¿Por qué no tratar esos servicios como dependencias para su prueba elegibles para "inyección", como lo describe 12factor.net ?

De esta manera, inicia e inicializa los servicios de dependencia en algún lugar fuera del código de prueba.

Hoy en día, la virtualización y los contenedores están en casi todas partes y la mayoría de las máquinas de los desarrolladores pueden ejecutar Docker. Y la mayor parte de la aplicación tienen una versión dockerized: Elasticsearch , DynamoDB , PostgreSQL y así sucesivamente. Docker es una solución perfecta para los servicios externos que necesitan sus pruebas.

  • Puede ser un script que se ejecuta manualmente por un desarrollador cada vez que quiere ejecutar pruebas.
  • Puede ser una tarea ejecutada por la herramienta de compilación (por ejemplo, Gradle tiene impresionante dependsOny finalizedByDSL para definir dependencias). Una tarea, por supuesto, puede ejecutar el mismo script que el desarrollador ejecuta manualmente usando shell-outs / ejecutivos de procesos.
  • Puede ser una tarea ejecutada por IDE antes de la ejecución de la prueba . Nuevamente, puede usar el mismo script.
  • La mayoría de los proveedores de CI / CD tienen una noción de "servicio": una dependencia externa (proceso) que se ejecuta en paralelo a su compilación y se puede acceder a ella a través de su SDK / conector / API habitual: Gitlab , Travis , Bitbucket , AppVeyor , Semaphore , ...

Este enfoque:

  • Libera su código de prueba de la lógica de inicialización. Sus pruebas solo probarán y no harán nada más.
  • Desacopla código y datos. Ahora se puede agregar un nuevo caso de prueba agregando nuevos datos a los servicios de dependencia con su conjunto de herramientas nativo. Es decir, para bases de datos SQL usará SQL, para Amazon DynamoDB usará CLI para crear tablas y colocar elementos.
  • Está más cerca de un código de producción, donde obviamente no inicia esos servicios cuando se inicia su aplicación "principal".

Por supuesto, tiene sus defectos (básicamente, las declaraciones de las que he comenzado):

  • Las pruebas no son más "atómicas". El servicio de dependencia debe iniciarse de alguna manera antes de la ejecución de la prueba. La forma en que se inicia puede ser diferente en diferentes entornos: máquina del desarrollador o CI, IDE o herramienta de compilación CLI.
  • Las pruebas no son autónomas. Ahora sus datos semilla pueden incluso estar empaquetados dentro de una imagen, por lo que cambiarlos puede requerir la reconstrucción de un proyecto diferente.
loco
fuente