Wie verwalte ich Unit-Test-Ressourcen in Kotlin, z. B. das Starten / Stoppen einer Datenbankverbindung oder eines eingebetteten Elasticsearch-Servers?

89

In meinen Kotlin JUnit-Tests möchte ich eingebettete Server starten / stoppen und sie in meinen Tests verwenden.

Ich habe versucht, die JUnit- @BeforeAnnotation für eine Methode in meiner Testklasse zu verwenden, und sie funktioniert einwandfrei, ist jedoch nicht das richtige Verhalten, da jeder Testfall statt nur einmal ausgeführt wird.

Daher möchte ich die @BeforeClassAnnotation für eine Methode verwenden, aber das Hinzufügen zu einer Methode führt zu einem Fehler, der besagt, dass es sich um eine statische Methode handeln muss. Kotlin scheint keine statischen Methoden zu haben. Gleiches gilt für statische Variablen, da ich für die Verwendung in den Testfällen einen Verweis auf den eingebetteten Server behalten muss.

Wie erstelle ich diese eingebettete Datenbank nur einmal für alle meine Testfälle?

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

    ...
}

Hinweis: Diese Frage wurde absichtlich vom Autor geschrieben und beantwortet ( Selbst beantwortete Fragen ), sodass die Antworten auf häufig gestellte Kotlin-Themen in SO vorhanden sind.

Jayson Minard
quelle
2
JUnit 5 unterstützt möglicherweise nicht statische Methoden für diesen Anwendungsfall. Weitere Informationen finden Sie unter github.com/junit-team/junit5/issues/419#issuecomment-267815529. Sie können auch +1 meinen Kommentar abgeben, um zu zeigen, dass Kotlin-Entwickler an solchen Verbesserungen interessiert sind.
Sébastien Deleuze

Antworten:

145

Ihre Unit-Test-Klasse benötigt normalerweise einige Dinge, um eine gemeinsam genutzte Ressource für eine Gruppe von Testmethoden zu verwalten. Und in Kotlin können Sie @BeforeClassund @AfterClassnicht in der Testklasse, sondern innerhalb des Begleitobjekts zusammen mit der @JvmStaticAnnotation verwenden .

Die Struktur einer Testklasse würde folgendermaßen aussehen:

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
}  

Vor diesem Hintergrund sollten Sie Folgendes lesen:

  • Begleitobjekte - ähnlich dem Class-Objekt in Java, jedoch ein Singleton pro Klasse, der nicht statisch ist
  • @JvmStatic - Eine Annotation, die eine Companion-Objektmethode in eine statische Methode für die äußere Klasse für Java Interop umwandelt
  • lateinit- Ermöglicht vardie spätere Initialisierung einer Eigenschaft, wenn Sie einen genau definierten Lebenszyklus haben
  • Delegates.notNull()- kann anstelle lateiniteiner Eigenschaft verwendet werden, die vor dem Lesen mindestens einmal festgelegt werden sollte.

Hier finden Sie ausführlichere Beispiele für Testklassen für Kotlin, die eingebettete Ressourcen verwalten.

Der erste wird aus Solr-Undertow-Tests kopiert und geändert. Bevor die Testfälle ausgeführt werden, wird ein Solr-Undertow-Server konfiguriert und gestartet. Nach dem Ausführen der Tests werden alle durch die Tests erstellten temporären Dateien bereinigt. Außerdem wird sichergestellt, dass Umgebungsvariablen und Systemeigenschaften korrekt sind, bevor die Tests ausgeführt werden. Zwischen den Testfällen werden alle temporär geladenen Solr-Kerne entladen. Der 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
}

Und ein weiterer Start von AWS DynamoDB local als eingebettete Datenbank (kopiert und leicht geändert von Running AWS DynamoDB-local embedded ). Dieser Test muss das hacken, java.library.pathbevor etwas anderes passiert, sonst wird die lokale DynamoDB (mit SQLite mit Binärbibliotheken) nicht ausgeführt. Anschließend wird ein Server gestartet, der für alle Testklassen freigegeben wird, und temporäre Daten zwischen den Tests werden bereinigt. Der 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

}

HINWEIS: Einige Teile der Beispiele sind mit abgekürzt...

Jayson Minard
quelle
0

Das Verwalten von Ressourcen mit Vorher / Nachher-Rückrufen in Tests hat natürlich folgende Vorteile:

  • Tests sind "atomar". Ein Test wird als Ganzes mit allen Rückrufen ausgeführt. Man wird nicht vergessen, einen Abhängigkeitsdienst vor den Tests zu starten und ihn anschließend abzuschließen. Bei ordnungsgemäßer Ausführung funktionieren Ausführungsrückrufe in jeder Umgebung.
  • Tests sind in sich geschlossen. Es gibt keine externen Daten oder Einrichtungsphasen, alles ist in wenigen Testklassen enthalten.

Es hat auch einige Nachteile. Eine wichtige davon ist, dass sie den Code verschmutzt und den Code gegen das Prinzip der Einzelverantwortung verstößt. Tests testen jetzt nicht nur etwas, sondern führen auch eine Schwergewichtsinitialisierung und Ressourcenverwaltung durch. In einigen Fällen kann dies in Ordnung sein (z. B. beim Konfigurieren einesObjectMapper ), aber das Ändern java.library.pathoder Laichen anderer Prozesse (oder eingebetteter In-Process-Datenbanken) ist nicht so unschuldig.

Warum nicht diese Dienste als Abhängigkeiten für den Test , die für „injection“ behandeln, wie beschrieben durch 12factor.net .

Auf diese Weise starten und initialisieren Sie Abhängigkeitsdienste außerhalb des Testcodes.

Heutzutage sind Virtualisierung und Container fast überall und die meisten Entwicklermaschinen können Docker ausführen. Und die meisten Anwendungen haben eine Docker-Version: Elasticsearch , DynamoDB , PostgreSQL und so weiter. Docker ist eine perfekte Lösung für externe Services, die Ihre Tests benötigen.

  • Es kann sich um ein Skript handeln, das jedes Mal von einem Entwickler manuell ausgeführt wird, wenn er Tests ausführen möchte.
  • Es kann eine Aufgabe sein, die vom Build-Tool ausgeführt wird (z. B. hat Gradle eine großartige dependsOnund finalizedByDSL-Funktion zum Definieren von Abhängigkeiten). Eine Aufgabe kann natürlich dasselbe Skript ausführen, das der Entwickler manuell mithilfe von Shell-Outs / Prozess-Execs ausführt.
  • Dies kann eine Aufgabe sein, die von der IDE vor der Testausführung ausgeführt wird . Auch hier kann das gleiche Skript verwendet werden.
  • Die meisten CI / CD-Anbieter haben den Begriff "Service" - eine externe Abhängigkeit (Prozess), die parallel zu Ihrem Build ausgeführt wird und auf die über das übliche SDK / Connector / API zugegriffen werden kann : Gitlab , Travis , Bitbucket , AppVeyor , Semaphore , ...

Dieser Ansatz:

  • Befreit Ihren Testcode von der Initialisierungslogik. Ihre Tests werden nur testen und nichts mehr tun.
  • Entkoppelt Code und Daten. Das Hinzufügen eines neuen Testfalls kann jetzt durch Hinzufügen neuer Daten zu Abhängigkeitsdiensten mit dem nativen Toolset erfolgen. Dh für SQL-Datenbanken verwenden Sie SQL, für Amazon DynamoDB verwenden Sie CLI, um Tabellen zu erstellen und Elemente zu platzieren.
  • Befindet sich näher an einem Produktionscode, bei dem Sie diese Dienste offensichtlich nicht starten, wenn Ihre "Haupt" -Anwendung gestartet wird.

Natürlich hat es seine Mängel (im Grunde die Aussagen, von denen ich ausgegangen bin):

  • Tests sind nicht mehr "atomar". Der Abhängigkeitsdienst muss vor der Testausführung gestartet werden. Die Art und Weise, wie es gestartet wird, kann in verschiedenen Umgebungen unterschiedlich sein: Entwicklercomputer oder CI, IDE oder Build-Tool-CLI.
  • Tests sind nicht in sich geschlossen. Jetzt können Ihre Startdaten sogar in ein Bild gepackt werden. Wenn Sie sie ändern, müssen Sie möglicherweise ein anderes Projekt neu erstellen.
verrückter Kopf
quelle