Kotlin(Spring Boot)のテストコードで @SpringBootTest を利用して DI する
概要
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 に依存させない(コンテナを使用しない)形でインジェクションするオブジェクトの差し替えができる
そのため、基本的にはコンストラクタインジェクションを選択して良いと考えています。
あくまで紹介として、本記事ではテスト環境でコンストラクタインジェクション、フィールドインジェクションをする方法を紹介します。
@SpringBootTest アノテーション
@SpringBootTest
は、名前の通りテスト環境でも Spring Framework の機能を使うためのアノテーションです。
プロダクト環境では DI コンテナによって、アノテーションによって結び付けられたクラスを DI することで、インスタンス化されます。
通常のテストでは DI コンテナが動作しないため、自動でインスタンス化されません。
アノテーションを使ったオブジェクトを呼び出さないユニットテストや DB テストでは問題ありませんが、必要なときもあります。
@SpringBootTest
はそのような場合に用います。
class SampleTest {
// 以下のクラスは DI が使える
// DI の仕方は後述
@SpringBootTest
class InnerSampleTest {
// テスト
}
}
サンプルコード
本項目では、テストコードで DI が必要になった背景、プロダクトコード、テストコードをそれぞれ紹介します。
ソースコードの全体は以下の GitHub に載せました。
背景とプロダクションコード
テスト環境で 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 constructor
とSpringDataCustomerEntityRepository
を渡すことで、コンストラクタインジェクションを実現しています。
このように、テスト側のクラスで 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 することは変わりません。
@Autowired
とlateinit 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 テストを書くことが望ましいですが、必要になる瞬間があるので紹介しました。
参考
Discussion