🫖

Kotlin(Spring Boot)のテストコードで @SpringBootTest を利用して DI する

2022/11/18に公開

概要

Kotlin の Spring Boot で DI されていないためうまくテストできていない問題に遭遇しました。
普段のユニットテストでは Spring Boot の機能を頼らずにスタブを作成することで対応していますが、Spring Data を利用するため、どうしても DI が必要でした。
そこで、本記事では Kotlin の Spring Boot のテストコードで DI する方法を紹介します。
まず、インジェクションの種類と@SpringBootTestアノテーションについてを解説したあとに、テストコードで DI するソースコードを紹介します。

前提

本記事で登場するインジェクションと @SpringBootTest について解説します。

インジェクションについて

DI には、コンストラクタインジェクション、フィールドインジェクション、セッターインジェクションの 3 つがあります。
コンストラクタインジェクションとフィールドインジェクションはそれぞれ以下のように実装します。

コンストラクタインジェクション
// コンストラクタインジェクションをするクラス
@Service
class SampleService(val sampleRepository: SampleRepository) { // @Repository アノテーションがついた、SampleRepositoryImpl で初期化する
    fun do() {
        sampleService.execute()
    }
}

// コンストラクタインジェクション対象のクラス
interface SampleRepository {
    fun execute() : Unit
}

@Repository
class SampleRepositoryImpl : SampleRepository {
    fun execute() : Unit {
        // 何かやる
    }
}

フィールドインジェクション
// コンストラクタインジェクションをするクラス
@Service
class SampleService {
    // @Repository アノテーションがついた、SampleRepositoryImpl で初期化する
    @Autowired
    lateinit var val sampleRepository: SampleRepository

    fun do() {
        sampleService.execute()
    }
}

// コンストラクタインジェクション対象のクラス
interface SampleRepository {
    fun execute() : Unit
}

@Repository
class SampleRepositoryImpl : SampleRepository {
    fun execute() : Unit {
        // 何かやる
    }
}

Spring Framework ではコンストラクタインジェクションが推奨されています。理由は以下です。

● インジェクション対象のオブジェクトを不変にすることができる
● 依存している対象がコンストラクタに並べられるため、クラスの責務が多くなってきたときに気付きやすい
● 循環依存を防ぐことができる
● テストコードで、Spring Framework に依存させない(コンテナを使用しない)形でインジェクションするオブジェクトの差し替えができる

出典:Kotlin サーバーサイドプログラミング実践開発

そのため、基本的にはコンストラクタインジェクションを選択して良いと考えています。
あくまで紹介として、本記事ではテスト環境でコンストラクタインジェクション、フィールドインジェクションをする方法を紹介します。

@SpringBootTest アノテーション

@SpringBootTestは、名前の通りテスト環境でも Spring Framework の機能を使うためのアノテーションです。
プロダクト環境では DI コンテナによって、アノテーションによって結び付けられたクラスを DI することで、インスタンス化されます。
通常のテストでは DI コンテナが動作しないため、自動でインスタンス化されません。
アノテーションを使ったオブジェクトを呼び出さないユニットテストや DB テストでは問題ありませんが、必要なときもあります。
@SpringBootTestはそのような場合に用います。

@SpringBootTest の例
class SampleTest {
    // 以下のクラスは DI が使える
    // DI の仕方は後述
    @SpringBootTest
    class InnerSampleTest {
        // テスト
    }
}

サンプルコード

本項目では、テストコードで DI が必要になった背景、プロダクトコード、テストコードをそれぞれ紹介します。
ソースコードの全体は以下の GitHub に載せました。

https://github.com/Msksgm/kotlin-spring-boot-test-with-injection

背景とプロダクションコード

テスト環境で DI が必要になった背景から解説します。
Spring Data というライブラリを調査していました。
Spring Data JDBC はテーブルのカラムに該当するエンティティを定義し、CrudRepository インタフェースに継承させることで、CRUD 操作が可能です。加えてプロパティ名から推論したメソッドを定義することで、相当する処理を暗黙的に実装してくれます。

@Table("customer")
data class Customer(
    @Id val id: Int?,
    val firstName: String,
    val lastName: String,
)

interface SpringDataCustomerEntityRepository : CrudRepository<Customer, Int> {
    fun findByFirstName(firstName: String): Customer?
}

ここで、1 つ疑問が発生しました。CrudRepository インタフェースを継承したインタフェースをドメイン層におくのは適切なのかです。
考えた結果、エンティティはテーブルをそのまま表現しているので、ドメイン層におくのは適切でないと考えました。
そこで、もう 1 つインタフェースを定義し、具象クラスの引数に SpringData のインタフェースを配置することで、ドメイン層に配置しなくて済むのではないかと考えました。

ドメイン層に配置するインタフェース
interface CustomerRepository {
    fun save(customer: Customer)

    fun findByFirstName(firstName: String): Customer?
}

インフラ層に配置する実装クラス
@Repository // SpringData のインタフェースを引数に持たせることで、ドメイン層のインタフェースは外部をしらない
class CustomerRepositoryImpl(val springDataCustomerEntityRepository: SpringDataCustomerEntityRepository) :
    CustomerRepository {
    override fun save(customer: Customer) {
        springDataCustomerEntityRepository.save(customer)
    }

    override fun findByFirstName(firstName: String): Customer? {
        return springDataCustomerEntityRepository.findByFirstName(firstName)
    }
}

動作自体は問題ありませんでした。
しかし、CustomerRepositoryImplを対象とした DB テストでは、引数のSpringDataCustomerEntityRepository型は具象クラスである必要が生まれました。
そこで、本記事の調査を行い執筆しました。

テストコードで DI

テストコードで DI する方法を紹介します。
CustomerRepositoryImpl にはインスタンス化されたクラスを渡す必要があるため、当然以下のコードはうごきません。

val customerRepository = CustomerRepositoryImpl(SpringDataCustomerEntityRepository)

インスタンス化するにも以下の方法は使えません。SpringDataCustomerEntityRepositoryはインタフェースだからです。

val springDataCustomerEntityRepository: SpringDataCustomerEntityRepository // Property must be initialized or be abstract
val springDataCustomerEntityRepository = SpringDataCustomerEntityRepository() // Interface does not have constructors

解決策は、テストコード上でインジェクションすることです。
コンストラクタインジェクションとフィールドインジェクションをテストで実践する方法を紹介します。

コンストラクタインジェクション

FindByFirstName クラスのシグネチャを参照してください。
引数に@Autowired constructorSpringDataCustomerEntityRepositoryを渡すことで、コンストラクタインジェクションを実現しています。
このように、テスト側のクラスで DI してあげることで動作します。
プロダクトコードと同様に、@Autowired constructor なしで実装可能と考えたのですが、手元ではできませんでした。理由を知っている方がいましたら教えてください。

    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    @DisplayName("FindByFirstName(firstname から検索)")
    @SpringBootTest
    class FindByFirstName @Autowired constructor(
        val springDataCustomerEntityRepository: SpringDataCustomerEntityRepository
    ) {
        @Test
        fun `正常系`() {
            /**
             * given:
             */
            val customerRepository = CustomerRepositoryImpl(springDataCustomerEntityRepository)

            // 以下テストが続きます
        }
    }

フィールドインジェクション

フィールドインジェクションでも、テストコードのクラスで DI することは変わりません。
@Autowiredlateinit varによってフィールドインジェクションします。
こちらはプロダクトコードと同様のソースコードになります。
lateinit によって、インスタンス化する直前まで初期化を遅延させることができます。
そのため、CustomerRepositoryImplの引数に渡すときにはインスタンス化された状態ですので、テストを実施可能です。

    @TestInstance(TestInstance.Lifecycle.PER_CLASS)
    @DisplayName("FindByFirstName(firstname から検索)")
    @SpringBootTest
    class FindByFirstName {
        @Autowired
        lateinit var springDataCustomerEntityRepository: SpringDataCustomerEntityRepository

        @Test
        fun `正常系`() {
            /**
             * given:
             */
            val customerRepository = CustomerRepositoryImpl(springDataCustomerEntityRepository)

            // 以下テストが続きます
        }
    }

まとめ

本記事では@SpringBootTest を用いて、テストコードで DI する方法を紹介しました。
テストのクラスに DI させることで、テスト上でもインスタンス化が可能です。
DI に依存しないユニットテストや DB テストを書くことが望ましいですが、必要になる瞬間があるので紹介しました。

参考

https://qiita.com/BooookStore/items/14c7bd559878991cf112

https://stackoverflow.com/questions/64264759/how-to-inject-a-service-component-in-spring-test-class-using-kotlin

Discussion