Jak zarządzać zasobami testów jednostkowych w Kotlin, takimi jak uruchamianie / zatrzymywanie połączenia z bazą danych lub wbudowany serwer elastycznego wyszukiwania?

94

W moich testach Kotlin JUnit chcę uruchamiać / zatrzymywać wbudowane serwery i używać ich w moich testach.

Próbowałem użyć @Beforeadnotacji JUnit w metodzie w mojej klasie testowej i działa dobrze, ale nie jest to właściwe zachowanie, ponieważ uruchamia każdy przypadek testowy zamiast tylko raz.

Dlatego chcę użyć @BeforeClassadnotacji w metodzie, ale dodanie jej do metody powoduje błąd mówiący, że musi ona znajdować się w metodzie statycznej. Wydaje się, że Kotlin nie ma metod statycznych. A potem to samo dotyczy zmiennych statycznych, ponieważ muszę zachować odniesienie do wbudowanego serwera w celu wykorzystania w przypadkach testowych.

Jak więc utworzyć tę wbudowaną bazę danych tylko raz dla wszystkich moich przypadków testowych?

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

    ...
}

Uwaga: to pytanie jest celowo napisane i udzielone przez autora ( pytania z własną odpowiedzią), tak aby odpowiedzi na często zadawane tematy Kotlin były obecne w SO.

Jayson Minard
źródło
2
JUnit 5 może obsługiwać metody niestatyczne dla tego przypadku użycia, patrz github.com/junit-team/junit5/issues/419#issuecomment-267815529 i możesz dać +1 mojemu komentarzowi, aby pokazać, że deweloperzy Kotlin są zainteresowani takimi ulepszeniami.
Sébastien Deleuze

Odpowiedzi:

161

Twoja klasa testów jednostkowych zwykle potrzebuje kilku rzeczy do zarządzania współdzielonym zasobem dla grupy metod testowych. A w Kotlinie możesz używać @BeforeClassi @AfterClassnie w klasie testowej, ale raczej w jej obiekcie towarzyszącym wraz z @JvmStaticadnotacją .

Struktura klasy testowej wyglądałaby następująco:

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
}  

Biorąc pod uwagę powyższe, warto przeczytać o:

  • obiekty towarzyszące - podobne do obiektu Class w Javie, ale pojedynczy obiekt na klasę, który nie jest statyczny
  • @JvmStatic - adnotacja, która zamienia metodę obiektu towarzyszącego w metodę statyczną w klasie zewnętrznej na potrzeby współpracy języka Java
  • lateinit- umożliwia varpóźniejszą inicjalizację właściwości, gdy masz dobrze zdefiniowany cykl życia
  • Delegates.notNull()- można użyć zamiast lateinitdla właściwości, która powinna być ustawiona przynajmniej raz przed odczytaniem.

Oto pełniejsze przykłady klas testowych dla Kotlin, które zarządzają zasobami osadzonymi.

Pierwsza jest kopiowana i modyfikowana z testów Solr-Undertow , a przed uruchomieniem przypadków testowych konfiguruje i uruchamia serwer Solr-Undertow. Po uruchomieniu testów czyści wszystkie pliki tymczasowe utworzone przez testy. Zapewnia również poprawność zmiennych środowiskowych i właściwości systemu przed uruchomieniem testów. Pomiędzy testami zwalnia wszelkie tymczasowo obciążone rdzenie Solr. 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
}

I kolejny startowy AWS DynamoDB lokalnie jako osadzona baza danych (skopiowana i nieznacznie zmodyfikowana z wbudowanego Uruchamiania AWS DynamoDB-local ). Ten test musi java.library.pathzhakować, zanim cokolwiek się stanie, w przeciwnym razie lokalny DynamoDB (używając sqlite z bibliotekami binarnymi) nie zostanie uruchomiony. Następnie uruchamia serwer do współdzielenia dla wszystkich klas testowych i czyści tymczasowe dane między testami. 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

}

UWAGA: niektóre części przykładów są skracane...

Jayson Minard
źródło
0

Zarządzanie zasobami z wywołaniami zwrotnymi przed / po w testach ma oczywiście swoje zalety:

  • Testy są „atomowe”. Test jest wykonywany jako całość ze wszystkimi wywołaniami zwrotnymi. Nie zapomnisz uruchomić usługi zależności przed testami i zamknąć ją po zakończeniu. Jeśli zostanie to wykonane poprawnie, wywołania zwrotne wykonania będą działać w każdym środowisku.
  • Testy są niezależne. Nie ma żadnych zewnętrznych danych ani faz konfiguracji, wszystko jest zawarte w kilku klasach testowych.

Ma też pewne wady. Jednym z ważnych z nich jest to, że zanieczyszcza kod i sprawia, że ​​narusza on zasadę pojedynczej odpowiedzialności. Testy teraz nie tylko testują coś, ale wykonują ciężką inicjalizację i zarządzanie zasobami. W niektórych przypadkach może to być w porządku (np. Podczas konfigurowaniaObjectMapper ), ale modyfikowanie java.library.pathlub tworzenie innych procesów (lub wbudowanych baz danych w procesie) nie jest tak niewinne.

Dlaczego nie traktować tych usług jako zależności dla twojego testu kwalifikującego się do „wstrzyknięcia”, jak opisano w 12factor.net .

W ten sposób uruchamiasz i inicjalizujesz usługi zależności gdzieś poza kodem testowym.

Obecnie wirtualizacja i kontenery są prawie wszędzie, a większość maszyn programistów jest w stanie uruchomić Dockera. Większość aplikacji ma wersję dokeryzowaną: Elasticsearch , DynamoDB , PostgreSQL i tak dalej. Docker to idealne rozwiązanie dla usług zewnętrznych, których potrzebują Twoje testy.

  • Może to być skrypt, który jest uruchamiany ręcznie przez programistę za każdym razem, gdy chce wykonać testy.
  • Może to być zadanie uruchamiane przez narzędzie do budowania (np. Gradle ma awesome dependsOni finalizedByDSL do definiowania zależności). Zadanie może oczywiście wykonać ten sam skrypt, który programista wykonuje ręcznie przy użyciu powłok / procesów.
  • Może to być zadanie uruchamiane przez IDE przed wykonaniem testu . Ponownie, może używać tego samego skryptu.
  • Większość dostawców CI / CD ma pojęcie „usługi” - zewnętrznej zależności (procesu), która działa równolegle z twoją kompilacją i jest dostępna za pośrednictwem zwykłego SDK / konektora / API: Gitlab , Travis , Bitbucket , AppVeyor , Semaphore ,…

To podejście:

  • Zwalnia kod testowy od logiki inicjalizacji. Twoje testy będą tylko testować i nic więcej.
  • Oddziela kod i dane. Można teraz dodać nowy przypadek testowy, dodając nowe dane do usług zależności za pomocą natywnego zestawu narzędzi. Oznacza to, że w przypadku baz danych SQL będziesz używać SQL, w przypadku Amazon DynamoDB będziesz używać interfejsu wiersza polecenia do tworzenia tabel i umieszczania elementów.
  • Jest bliżej kodu produkcyjnego, w którym oczywiście nie uruchamiasz tych usług podczas uruchamiania "głównej" aplikacji.

Oczywiście ma swoje wady (w zasadzie stwierdzenia, od których zacząłem):

  • Testy nie są bardziej „atomowe”. Usługa zależności musi zostać w jakiś sposób uruchomiona przed wykonaniem testu. Sposób jego uruchamiania może być różny w różnych środowiskach: maszyna programisty lub CI, IDE lub CLI narzędzia do budowania.
  • Testy nie są samodzielne. Teraz twoje dane początkowe mogą być nawet spakowane w obrazie, więc ich zmiana może wymagać przebudowania innego projektu.
szaleniec
źródło