Как управлять ресурсами модульного тестирования в Kotlin, такими как запуск / остановка подключения к базе данных или встроенного сервера elasticsearch?

94

В моих тестах Kotlin JUnit я хочу запускать / останавливать встроенные серверы и использовать их в своих тестах.

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

Поэтому я хочу использовать @BeforeClassаннотацию для метода, но добавление ее к методу приводит к ошибке, в которой говорится, что она должна быть в статическом методе. Kotlin, похоже, не имеет статических методов. То же самое относится и к статическим переменным, потому что мне нужно сохранить ссылку на встроенный сервер для использования в тестовых примерах.

Итак, как мне создать эту встроенную базу данных только один раз для всех моих тестовых случаев?

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

    ...
}

Примечание: этот вопрос намеренно написан, и автор отвечает на него ( вопросы с ответами), так что ответы на часто задаваемые темы Kotlin присутствуют в SO.

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

Ответы:

156

Классу модульного тестирования обычно требуется несколько вещей для управления общим ресурсом для группы методов тестирования. И в Котлин вы можете использовать @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
}  

Учитывая вышесказанное, вам следует прочитать о:

  • сопутствующие объекты - похожи на объект класса в 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 есть awesome dependsOnи finalizedByDSL для определения зависимостей). Задача, конечно же, может выполнять тот же сценарий, который разработчик выполняет вручную с помощью командных интерпретаторов / исполняемых файлов процессов.
  • Это может быть задача, выполняемая IDE перед выполнением теста . Опять же, он может использовать тот же сценарий.
  • У большинства поставщиков CI / CD есть понятие «служба» - внешняя зависимость (процесс), которая работает параллельно с вашей сборкой и может быть доступна через обычный SDK / коннектор / API: Gitlab , Travis , Bitbucket , AppVeyor , Semaphore ,…

Этот подход:

  • Освобождает ваш тестовый код от логики инициализации. Ваши тесты будут только проверять и больше ничего не делать.
  • Разъединяет код и данные. Теперь добавить новый тестовый пример можно, добавив новые данные в службы зависимостей с помощью собственного набора инструментов. То есть для баз данных SQL вы будете использовать SQL, для Amazon DynamoDB вы будете использовать CLI для создания таблиц и размещения элементов.
  • Это ближе к производственному коду, где вы, очевидно, не запускаете эти службы при запуске вашего «основного» приложения.

Конечно, у него есть недостатки (в основном те утверждения, с которых я начал):

  • Тесты не более «атомарны». Службу зависимостей нужно как-то запустить до выполнения теста. Способ запуска может быть разным в разных средах: на машине разработчика или CI, IDE или CLI инструмента сборки.
  • Тесты не являются автономными. Теперь ваши исходные данные могут быть даже упакованы внутри изображения, поэтому для их изменения может потребоваться перекомпоновка другого проекта.
сумасшедший
источник