Як мені керувати ресурсами модульного тестування в Kotlin, такими як запуск / зупинка підключення до бази даних або вбудований еластичний пошуковий сервер?


93

У своїх тестах Kotlin JUnit я хочу запустити / зупинити вбудовані сервери та використовувати їх у своїх тестах.

Я спробував використати @Beforeанотацію JUnit для методу в моєму тестовому класі, і він працює нормально, але це неправильна поведінка, оскільки він запускає кожен тестовий приклад замість одного разу.

Тому я хочу використовувати @BeforeClassанотацію для методу, але додавання її до методу призводить до помилки, що вона повинна бути для статичного методу. У Котліна, схоже, немає статичних методів. І тоді це ж стосується статичних змінних, тому що мені потрібно тримати посилання на вбудований сервер навколо для використання в тестових випадках.

То як мені створити цю вбудовану базу даних лише один раз для всіх моїх тестових випадків?

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

    ...
}

Примітка: це питання навмисно написано і на нього відповідає автор ( Запитання , що дають відповіді на себе), так що відповіді на поширені теми Котліна містяться в SO.


2
JUnit 5 може підтримувати нестатичні методи для цього випадку використання, див. Github.com/junit-team/junit5/issues/419#issuecomment-267815529 і не соромтеся поставити +1 моєму коментарю, щоб показати розробникам Kotlin зацікавлені в таких вдосконаленнях.
Себастьян Дельоз,

Відповіді:


155

Вашому модульному тестовому класу зазвичай потрібно кілька речей для управління спільним ресурсом для групи методів тестування. А в Kotlin ви можете використовувати @BeforeClassі @AfterClassне в тестовому класі, а в межах об'єкта-супутника разом із @JvmStaticанотацією .

Структура тестового класу буде виглядати так:

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
}  

Враховуючи вищесказане, вам слід прочитати про:

  • об'єкти-супутники - подібні до об'єкта Class в Java, але одиничний для кожного класу, який не є статичним
  • @JvmStatic - анотація, яка перетворює метод об'єкта-супутника у статичний метод зовнішнього класу для взаємодії Java
  • lateinit- дозволяє varвластивість ініціювати пізніше, коли у вас є чітко визначений життєвий цикл
  • Delegates.notNull()- може використовуватися замість lateinitвластивості, яку слід встановити принаймні один раз перед читанням.

Ось повні приклади тестових класів для Kotlin, які управляють вбудованими ресурсами.

Перший копіюється та модифікується з тестів Solr-Undertow , а перед запуском тестових кейсів налаштовується та запускається сервер Solr-Undertow. Після запуску тестів він очищає всі тимчасові файли, створені тестами. Це також забезпечує правильність змінних середовища та властивостей системи перед запуском тестів. Між тестовими кейсами він вивантажує будь-які тимчасово завантажені ядра Solr. Тест:

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
}

І ще один початковий локальний AWS DynamoDB як вбудована база даних (скопійована та дещо змінена із запущеної AWS DynamoDB-локальної вбудованої ). Цей тест повинен зламати, java.library.pathперш ніж щось інше відбудеться, інакше локальний DynamoDB (використовуючи sqlite з двійковими бібліотеками) не буде запущений. Потім він запускає сервер для спільного використання для всіх тестових класів і очищає тимчасові дані між тестами. Тест:

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

}

ПРИМІТКА: деякі частини прикладів скорочено...


0

Очевидно, що управління ресурсами з до / після зворотних викликів у тестах має свої плюси:

  • Тести "атомні". Тест виконується в цілому з усіма зворотними викликами. Не забудемо запустити службу залежностей перед тестами і вимкнути її після її завершення. Якщо все зробити правильно, зворотний виклик виконання буде працювати в будь-якому середовищі.
  • Тести є самостійними. Немає зовнішніх даних або етапів налаштування, все міститься в декількох тестових класах.

У цього теж є деякі мінуси. Важливим з них є те, що це забруднює кодекс і змушує кодекс порушувати принцип єдиної відповідальності. Зараз тести не лише тестують щось, але виконують важку вагу ініціалізації та управління ресурсами. У деяких випадках це може бути нормально (наприклад, налаштуванняObjectMapper ), але модифікація java.library.pathабо поява інших процесів (або вбудованих баз даних, що вбудовуються в процес) не є настільки невинними.

Чому б не розглядати ці послуги як залежності для вашого тесту, придатного для "ін'єкції", як описано в 12factor.net .

Таким чином ви запускаєте та ініціалізуєте служби залежностей десь поза тестовим кодом.

У наш час віртуалізація та контейнери є майже скрізь, і більшість машин розробників можуть запускати Docker. І більшість додатків мають докеризовану версію: Elasticsearch , DynamoDB , PostgreSQL тощо. Docker - ідеальне рішення для зовнішніх служб, які потрібні вашим тестам.

  • Це може бути сценарій, який запускається, запускається розробником вручну кожного разу, коли вона хоче виконати тести.
  • Це може бути завдання, яке виконується інструментом побудови (наприклад, Gradle має чудові dependsOnта finalizedByDSL для визначення залежностей). Завдання, звичайно, може виконати той самий сценарій, який розробник виконує вручну, використовуючи командні оболонки / процеси.
  • Це може бути завдання, запущене IDE перед виконанням тесту . Знову ж таки, він може використовувати той самий сценарій.
  • Провайдери Більшість CI / CD є поняття «сервіс» - зовнішній залежностей (процес) , який проходить паралельно до вашої збірки і можуть бути доступні через це звичайний SDK / роз'єм / API: Gitlab , Travis , Bitbucket , AppVeyor , семафор , ...

Цей підхід:

  • Звільняє ваш тестовий код від логіки ініціалізації. Ваші тести будуть лише тестувати і більше нічого не робити.
  • Розв’язує код та дані. Тепер можна додати новий тестовий приклад, додавши нові дані до служб залежностей за допомогою його власного набору інструментів. Тобто для баз даних SQL ви будете використовувати SQL, для Amazon DynamoDB ви будете використовувати CLI для створення таблиць і розміщення елементів.
  • Це ближче до виробничого коду, де ви, очевидно, не запускаєте ці служби, коли запускається ваш "основний" додаток.

Звичайно, у нього є свої недоліки (в основному, твердження, з яких я почав):

  • Тести не більш "атомні". Службу залежностей потрібно якось запустити до виконання тесту. Спосіб його запуску може бути різним в різних середовищах: машина розробника або ІР, IDE або інструмент побудови CLI.
  • Тести не є самостійними. Тепер ваші початкові дані можуть бути навіть упаковані всередині зображення, тому їх зміна може зажадати відновлення іншого проекту.
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.