web-dev-qa-db-fra.com

Comment gérer les ressources de test d'unité dans Kotlin, telles que le démarrage/l'arrêt d'une connexion à une base de données ou un serveur elasticsearch intégré?

Dans mes tests Kotlin JUnit, je souhaite démarrer/arrêter les serveurs intégrés et les utiliser dans mes tests. 

J'ai essayé d'utiliser l'annotation JUnit @Before sur une méthode de ma classe de test et cela fonctionne bien, mais ce n'est pas le bon comportement car elle exécute tous les cas de test au lieu d'une seule fois. 

Par conséquent, je souhaite utiliser l'annotation @BeforeClass sur une méthode, mais son ajout à une méthode entraîne une erreur indiquant que celle-ci doit figurer sur une méthode statique. Kotlin ne semble pas avoir de méthodes statiques. Il en va de même pour les variables statiques, car je dois conserver une référence au serveur intégré pour pouvoir l'utiliser dans les cas de test.

Alors, comment puis-je créer cette base de données intégrée une seule fois pour tous mes cas de 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

    ...
}

Remarque: _ ​​cette question a été volontairement écrite et répondue par l'auteur ( Questions à réponse automatique ), de sorte que les réponses aux sujets couramment demandés sur Kotlin soient présentes dans SO.

59
Jayson Minard

Votre classe de test unitaire a généralement besoin de quelques éléments pour gérer une ressource partagée pour un groupe de méthodes de test. Et dans Kotlin, vous pouvez utiliser @BeforeClass et @AfterClass non dans la classe de test, mais au sein de son objet compagnon avec l'annotation @JvmStatic .

La structure d'une classe de test ressemblerait à ceci:

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
}  

Compte tenu de ce qui précède, vous devriez lire à propos de:

  • objets compagnon - similaire à l'objet Class en Java, mais un singleton par classe non statique
  • @JvmStatic - une annotation qui transforme une méthode d'objet compagnon en une méthode statique sur la classe externe pour l'interopérabilité Java
  • lateinit - permet à une propriété var d'être initialisée ultérieurement, lorsque votre cycle de vie est bien défini.
  • Delegates.notNull() - peut être utilisé à la place de lateinit pour une propriété devant être définie au moins une fois avant d'être lue.

Voici des exemples plus complets de classes de test pour Kotlin qui gèrent les ressources incorporées.

Le premier est copié et modifié à partir de tests Solr-Undertow , et avant l'exécution des scénarios de test, configure et démarre un serveur Solr-Undertow. Une fois les tests exécutés, il nettoie tous les fichiers temporaires créés par les tests. Cela garantit également que les variables d'environnement et les propriétés système sont correctes avant l'exécution des tests. Entre les cas de test, il décharge tous les cœurs Solr chargés temporairement. Le 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
}

Et un autre démarrant AWS DynamoDB local en tant que base de données intégrée (légèrement copié et légèrement modifié à partir de exécution de AWS DynamoDB-local embedded ). Ce test doit pirater le Java.library.path avant que quoi que ce soit ne se produise ou que DynamoDB local (utilisant sqlite avec des bibliothèques binaires) ne s'exécutera pas. Ensuite, il démarre un serveur à partager pour toutes les classes de test et nettoie les données temporaires entre les tests. Le 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

}

REMARQUE: certaines parties des exemples sont abrégées en ...

94
Jayson Minard

La gestion des ressources avec des rappels avant/après dans les tests a évidemment ses avantages:

  • Les tests sont "atomiques". Un test exécute l'ensemble des tâches avec tous les rappels. Il ne faut pas oublier d'activer un service de dépendance avant les tests et de l'arrêter après l'avoir fait. Si cela est fait correctement, les callbacks d’exécution fonctionneront sur n’importe quel environnement.
  • Les tests sont autonomes. Il n'y a pas de données externes ni de phases de configuration, tout est contenu dans quelques classes de test.

Il y a aussi des inconvénients. L'un d'entre eux est qu'il pollue le code et lui fait enfreindre le principe de responsabilité unique. Les tests maintenant testent non seulement quelque chose, mais effectuent une initialisation et une gestion des ressources lourdes. Cela peut être correct dans certains cas (comme configurer une ObjectMapper ), mais modifier Java.library.path ou créer d'autres processus (ou des bases de données intégrées en cours de traitement) ne sont pas si innocents.

Pourquoi ne pas traiter ces services comme des dépendances de votre test éligibles pour "injection", comme décrit par 12factor.net .

De cette façon, vous démarrez et initialisez les services de dépendance quelque part en dehors du code de test.

De nos jours, la virtualisation et les conteneurs sont presque partout et la plupart des machines de développement peuvent exécuter Docker. Et la plupart des applications ont une version dockerisée: Elasticsearch , DynamoDB , PostgreSQL et ainsi de suite. Docker est la solution idéale pour les services externes requis par vos tests.

  • Il peut s'agir d'un script exécuté manuellement par un développeur à chaque fois qu'il souhaite exécuter des tests.
  • Il peut s’agir d’une tâche exécutée par l’outil de construction (par exemple, Gradle a awesome dependsOn et finalizedBy DSL pour la définition des dépendances). Bien entendu, une tâche peut exécuter le même script que celui que le développeur exécute manuellement à l'aide de Shell-outs/process execs.
  • Il peut s'agir d'une tâche exécutée par IDE avant l'exécution du test . Encore une fois, il peut utiliser le même script.
  • La plupart des fournisseurs de CI/CD ont une notion de "service" - une dépendance externe (processus) qui fonctionne en parallèle de votre construction et est accessible via son SDK/connecteur/API habituel: Gitlab , Travis , Bitbucket , AppVeyor , Sémaphore ,…

Cette approche:

  • Libère votre code de test de la logique d'initialisation. Vos tests ne feront que tester et ne feront plus rien.
  • Découple le code et les données. L'ajout d'un nouveau scénario de test peut maintenant être effectué en ajoutant de nouvelles données aux services de dépendance avec son jeu d'outils natif. C'est à dire. pour les bases de données SQL, vous utiliserez SQL, pour Amazon DynamoDB, vous utiliserez la CLI pour créer des tables et placer des éléments.
  • Est plus proche d'un code de production, où vous ne démarrez évidemment pas ces services lorsque votre application "principale" démarre.

Bien sûr, il a ses défauts (en gros, les déclarations sur lesquelles je suis parti):

  • Les tests ne sont plus "atomiques". Le service de dépendance doit être démarré d'une manière ou d'une autre avant l'exécution du test. La manière dont il est démarré peut être différente dans différents environnements: machine du développeur ou CI, IDE ou outil de compilation CLI.
  • Les tests ne sont pas autonomes. Vos données de départ peuvent même être contenues dans une image. Par conséquent, vous devrez peut-être reconstruire un projet différent pour les modifier.
0
madhead