データベース接続や組み込みelasticsearchサーバーの開始/停止など、Kotlinでユニットテストリソースを管理するにはどうすればよいですか?


94

Kotlin JUnitテストでは、組み込みサーバーを起動/停止して、テスト内で使用したいと考えています。

@BeforeテストクラスのメソッドでJUnitアノテーションを使用しようとしましたが、正常に機能しますが、テストケースを1回だけではなくすべて実行するため、適切な動作ではありません。

したがって@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

    ...
}

注: この質問は、作成者が意図的に作成して回答しているため(Self-Answered Questions)、よくあるKotlinトピックへの回答がSOに表示されます。


2
JUnit 5は、そのユースケースで非静的メソッドをサポートする場合があります。github.com / junit-team / junit5 / issues / 419issuecomment-267815529を参照し、コメントを+1して、Kotlin開発者がそのような改善に関心を持っていることを示してください。
セバスチャン・ドゥルーズ

回答:


156

単体テストクラスは通常、テストメソッドのグループの共有リソースを管理するためにいくつかのものを必要とします。そして、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
}  

上記を考えると、あなたはについて読むべきです:

  • コンパニオンオブジェクト-JavaのClassオブジェクトに似ていますが、静的ではないクラスごとのシングルトン
  • @JvmStatic -コンパニオンオブジェクトメソッドをJava相互運用機能の外部クラスの静的メソッドに変換するアノテーション
  • lateinit -許可します varライフサイクルが明確に定義されている場合、後でプロパティを初期化ます
  • Delegates.notNull() -代わりに使用できます lateinit読み取る前に少なくとも1回設定する必要があるプロパティの。

埋め込みリソースを管理するKotlinのテストクラスのより完全な例を次に示します。

1つ目は、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ローカルを埋め込みデータベースとして開始するもう1つの方法(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

テストでの前後のコールバックを使用してリソースを管理することには、明らかに長所があります。

  • テストは「アトミック」です。テストはすべてのコールバックを使用して全体として実行されます。テストの前に依存関係サービスを起動し、テストの終了後にシャットダウンすることを忘れないでください。適切に実行された場合、実行コールバックはどの環境でも機能します。
  • テストは自己完結型です。外部データやセットアップフェーズはなく、すべてがいくつかのテストクラスに含まれています。

短所もあります。それらの重要な点の1つは、コードを汚染し、コードを単一責任の原則に違反させることです。テストは、何かをテストするだけでなく、重い初期化とリソース管理を実行するようになりました。場合によっては問題ない場合もありますが(の構成などObjectMapper)、変更しますjava.library.path別のプロセス(またはインプロセスの組み込みデータベース)をまたは生成することはそれほど無害ではありません。

12factor.netで説明されているように、これらのサービスを「インジェクション」の対象となるテストの依存関係として扱ってみませんか。

このようにして、テストコードの外部のどこかで依存関係サービス開始および初期化します。

今日、仮想化とコンテナーはほとんどどこにでもあり、ほとんどの開発者のマシンはDockerを実行できます。また、ほとんどのアプリケーションにはドッキングバージョンがあります:ElasticsearchDynamoDBPostgreSQLなど。Dockerは、テストに必要な外部サービスに最適なソリューションです。

  • これは、開発者がテストを実行するたびに手動で実行されるスクリプトである可能性があります。
  • ビルドツールで実行されるタスクにすることができます(たとえば、Gradleには素晴らしい機能がdependsOnありますfinalizedBy依存関係を定義するためのDSL)。もちろん、タスクは、開発者がシェルアウト/プロセス実行を使用して手動で実行するのと同じスクリプトを実行できます。
  • テスト実行前にIDEによって実行されるタスクである可能性があります。繰り返しますが、同じスクリプトを使用できます。
  • ほとんどのCI / CDプロバイダーには、「サービス」の概念があります。これは、ビルドと並行して実行され、通常のSDK /コネクタ/ APIを介してアクセスできる外部依存関係(プロセス)です:GitlabTravisBitbucketAppVeyorSemaphore、…

このアプローチ:

  • テストコードを初期化ロジックから解放します。あなたのテストはテストするだけで、それ以上何もしません。
  • コードとデータを分離します。新しいテストケースの追加は、ネイティブツールセットを使用して依存関係サービスに新しいデータを追加することで実行できるようになりました。つまり、SQLデータベースの場合はSQLを使用し、Amazon DynamoDBの場合はCLIを使用してテーブルを作成し、アイテムを配置します。
  • 「メイン」アプリケーションの起動時にこれらのサービスを明らかに開始しない本番コードに近いです。

もちろん、それには欠陥があります(基本的に、私が始めたステートメント):

  • テストはより「アトミック」ではありません。依存関係サービスは、テストを実行する前に何らかの方法で開始する必要があります。開発者のマシンまたはCI、IDE、ビルドツールCLIなど、環境によって開始方法が異なる場合があります。
  • テストは自己完結型ではありません。これで、シードデータがイメージ内にパックされることもあるため、シードデータを変更するには、別のプロジェクトを再構築する必要があります。
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.