Come si gestiscono le risorse di unit test in Kotlin, ad esempio l'avvio / l'arresto di una connessione al database o un server elasticsearch incorporato?


94

Nei miei test Kotlin JUnit, voglio avviare / arrestare i server incorporati e utilizzarli nei miei test.

Ho provato a utilizzare l' @Beforeannotazione JUnit su un metodo nella mia classe di test e funziona bene, ma non è il comportamento corretto poiché esegue tutti i casi di test invece di una sola volta.

Pertanto voglio utilizzare l' @BeforeClassannotazione su un metodo, ma aggiungerla a un metodo restituisce un errore che dice che deve essere su un metodo statico. Kotlin non sembra avere metodi statici. E poi lo stesso vale per le variabili statiche, perché ho bisogno di mantenere un riferimento al server incorporato da utilizzare nei casi di test.

Quindi come creo questo database incorporato una sola volta per tutti i miei casi di test?

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: questa domanda è stata scritta intenzionalmente e ha risposto dall'autore ( Self-Answered Questions ), in modo che le risposte agli argomenti di Kotlin più comuni siano presenti in SO.


2
JUnit 5 può supportare metodi non statici per quel caso d'uso, vedere github.com/junit-team/junit5/issues/419#issuecomment-267815529 e sentiti libero di fare +1 sul mio commento per mostrare agli sviluppatori Kotlin che sono interessati a tali miglioramenti.
Sébastien Deleuze

Risposte:


156

La tua classe di unit test di solito ha bisogno di alcune cose per gestire una risorsa condivisa per un gruppo di metodi di test. E in Kotlin puoi usare @BeforeClasse @AfterClassnon nella classe di test, ma piuttosto all'interno del suo oggetto associato insieme @JvmStaticall'annotazione .

La struttura di una classe di test sarebbe simile a:

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
}  

Dato quanto sopra, dovresti leggere su:

  • oggetti companion - simili all'oggetto Class in Java, ma un singleton per classe che non è statico
  • @JvmStatic - un'annotazione che trasforma un metodo oggetto compagno in un metodo statico sulla classe esterna per l'interoperabilità Java
  • lateinit- consente vardi inizializzare una proprietà in un secondo momento quando si dispone di un ciclo di vita ben definito
  • Delegates.notNull()- può essere utilizzato al posto di lateinituna proprietà che dovrebbe essere impostata almeno una volta prima di essere letta.

Di seguito sono riportati esempi più completi di classi di test per Kotlin che gestiscono le risorse incorporate.

Il primo viene copiato e modificato dai test Solr-Undertow e prima che i casi di test vengano eseguiti, configura e avvia un server Solr-Undertow. Dopo l'esecuzione dei test, elimina tutti i file temporanei creati dai test. Garantisce inoltre che le variabili di ambiente e le proprietà di sistema siano corrette prima dell'esecuzione dei test. Tra i casi di test, scarica tutti i core Solr caricati temporaneamente. Il test:

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 un altro avvio di AWS DynamoDB locale come database incorporato (copiato e leggermente modificato da Running AWS DynamoDB-local embedded ). Questo test deve hackerare il java.library.pathprima che accada qualsiasi altra cosa o DynamoDB locale (utilizzando sqlite con le librerie binarie) non verrà eseguito. Quindi avvia un server da condividere per tutte le classi di test e pulisce i dati temporanei tra i test. Il test:

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: alcune parti degli esempi sono abbreviate con...


0

La gestione delle risorse con callback prima / dopo nei test, ovviamente, ha i suoi vantaggi:

  • I test sono "atomici". Un test esegue nel complesso le cose con tutti i callback. Uno non dimenticherà di attivare un servizio di dipendenza prima dei test e spegnerlo dopo averlo fatto. Se eseguiti correttamente, i callback delle esecuzioni funzioneranno in qualsiasi ambiente.
  • I test sono autonomi. Non ci sono dati esterni o fasi di setup, tutto è contenuto in poche classi di test.

Ha anche alcuni svantaggi. Uno di questi è che inquina il codice e fa sì che il codice violi il principio di responsabilità unica. I test ora non solo testano qualcosa, ma eseguono un'inizializzazione e una gestione delle risorse pesanti. Può essere ok in alcuni casi (come configurare unObjectMapper ), ma modificare java.library.patho generare altri processi (o database incorporati in-process) non è così innocente.

Perché non trattare quei servizi come dipendenze per il tuo test idoneo per "injection", come descritto da 12factor.net .

In questo modo si avvia e si inizializzano i servizi di dipendenza da qualche parte al di fuori del codice di test.

Oggigiorno la virtualizzazione ei container sono quasi ovunque e la maggior parte delle macchine degli sviluppatori è in grado di eseguire Docker. E la maggior parte dell'applicazione ha una versione dockerizzata: Elasticsearch , DynamoDB , PostgreSQL e così via. Docker è una soluzione perfetta per i servizi esterni di cui hanno bisogno i tuoi test.

  • Può essere uno script che viene eseguito manualmente da uno sviluppatore ogni volta che desidera eseguire i test.
  • Può essere un'attività eseguita dallo strumento di compilazione (ad esempio Gradle ha un fantastico dependsOne finalizedByDSL per la definizione delle dipendenze). Un'attività, ovviamente, può eseguire lo stesso script che lo sviluppatore esegue manualmente utilizzando shell-out / esecuzioni di processo.
  • Può essere un'attività eseguita da IDE prima dell'esecuzione del test . Ancora una volta, può utilizzare lo stesso script.
  • La maggior parte dei fornitori di CI / CD ha una nozione di "servizio": una dipendenza (processo) esterna che viene eseguita in parallelo alla build ed è accessibile tramite il suo solito SDK / connettore / API: Gitlab , Travis , Bitbucket , AppVeyor , Semaphore , ...

Questo approccio:

  • Libera il codice di test dalla logica di inizializzazione. I tuoi test testeranno e non faranno più niente.
  • Disaccoppia codice e dati. È ora possibile aggiungere un nuovo scenario di test aggiungendo nuovi dati ai servizi di dipendenza con il suo set di strumenti nativo. Ad esempio, per i database SQL utilizzerai SQL, per Amazon DynamoDB utilizzerai la CLI per creare tabelle e inserire elementi.
  • È più vicino a un codice di produzione, in cui ovviamente non si avvia quei servizi all'avvio dell'applicazione "principale".

Certo, ha i suoi difetti (fondamentalmente, le affermazioni da cui sono partito):

  • I test non sono più "atomici". Il servizio di dipendenza deve essere avviato in qualche modo prima dell'esecuzione del test. Il modo in cui viene avviato può essere diverso nei diversi ambienti: macchina dello sviluppatore o CI, IDE o CLI dello strumento di compilazione.
  • I test non sono autonomi. Ora i tuoi dati seed possono essere persino impacchettati all'interno di un'immagine, quindi cambiarli potrebbe richiedere la ricostruzione di un progetto diverso.
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.