オニオンアーキテクチャにおけるテストコーディングガイド
はじめに
アドベントカレンダーも終盤に差し掛かってきましたね!
シリーズ2ではテストコードについて書こうと思います。
ここ2年間くらいで採用やイベントを通じて色々な会社のエンジニアとお話させてもらった中で、テストコードの書き方にはかなりブレがあるように感じました。
ユニットテストについてはあまりブレがなく、どの方もドメイン層のEntityやValueObjectについて網羅的に書きましょうとなっていました。
一方で、インテグレーションテストについて流派も考え方も様々なものがありました。
そこで、この記事ではオニオンアーキテクチャにおけるインテグレーションテストの実装方法をパターン別にまとめて、Pros/Consを考えてみようと思います。
今後のテスト設計のガイドとして役に立つと幸いです ☺️
オニオンアーキテクチャについては改めて説明しませんが、 little-handsさんの記事が大変わかりやすいので、ご一読いただけると良いかもです。
自分はテストコードを書くときには「単体テストの考え方/使い方」という本の影響をかなり受けているので、ちょっと偏った意見も含まれるかもしれませんが、ご了承ください 🙇
(とても良い本なので、もし読まれていない方がいれば年末に一読することをおすすめします!)
インテグレーションテストについて
パターンについて書き始める前にインテグレーションテストについて軽くおさらいしておきます。
インテグレーションテストとは、簡単に書くと「複数のクラスを組み合わせて想定通りの動作をしているか確認するテスト」のことです。
ユニットテストは単一のクラスの振る舞いを網羅的にテストするのに対して、インテグレーションテストは複数のクラスをテストするというのが大きな違いになるでしょう。
インテグレーションテストは「結合テスト」と呼ばれたりもします。
説明を聞いて「あぁ、結合テストのことね」となった方もいらっしゃるかもしれません。
ユニットテストは単一のクラスをテストするため、テストコードを書くコストも実行するコストも対して高くありません。
そのため、処理のパターンを網羅的にテストすることに向いています。
一方で、インテグレーションテストは複数のテストを組み合わせる必要があるため、テストを書くコストもテストデータを用意や実行にかかる時間も踏まえて実行コストが高くなりやすいものになります。
有名なもので「テストピラミッド」というものがあります。
https://gihyo.jp/dev/serial/01/savanna-letter/0005
テストピラミッドについては、t-wadaさんがこちらの記事で解説しています。
インテグレーションテストは闇雲にいっぱい書いて網羅すれば良いというものではなく、コストを踏まえた上でうまく使っていく必要があります。
オニオンアーキテクチャでインテグレーションテストをどう書くか?
ここからが本題です。
インテグレーションテストについてわかったところで、オニオンアーキテクチャではどのように書いていくと良いか考えてみます。
オニオンアーキテクチャの場合、各層に対して以下のような種類のオブジェクトが登場します。
- Presentation層
- Controller
- FileParser/Converter
- Application層
- ApplicationService
- Domain層
- Entity
- ValueObject
- DomainObject
- Infrastructure層
- Repository (Implement)
- QueryService (Implement)
https://little-hands.hatenablog.com/entry/2018/12/10/ddd-architecture
オニオンアーキテクチャに登場する各層のオブジェクトに対してどのようなテストを書いていくと良いか考えてみます。
Domain層
まずはオニオンアーキテクチャの根幹であるDomain層について具体例をもとに考えていきます。
題材はアドベントカレンダーを提供するサービスをもとに考えていきましょう。
Entity/ValueObject
以下のような AdventCalendar
Entityがあったとします。
アドベントカレンダーに以下の制約があるときに、枠を埋めるメソッドを考えてみましょう。
- 既に埋まっている枠は埋められない
- 既に過ぎている日付の枠は埋められない
data class AdventCalendar(
val adventCalendarId: AdventCalendarId,
val organizationId: OrganizationId,
val name: AdventCalendarName,
val year: Year,
val slots: Map<AdventCalendarDay, AdventCalendarSlot>
) {
// 日付を指定して枠を埋める
fun fillSlot(
day: AdventCalendarDay,
authorId: UserId,
title: AdventCalendarSlotTitle,
contentUrl: Url?
now: LocalDateTime,
): AdventCalendar {
// 既に埋まっている枠は埋められない
if (this.slots.hadKey(day)) {
throw AdventCalendarFillError.TheDayStillFilled(day)
}
// 既に過ぎてしまっている枠は埋められない
if (day.isOver(now)) {
throw AdventCalendarFillError.TheDayIsOver(day)
}
val newSlot = AdventCalendarSlot(day, authorId, title, contentUrl)
return this.copy(
slots = this.slots + mapOf(day to newSlot)
)
}
}
AdventCalendar
に対してユニットテストを網羅的に書くというのは異論がないと思います。
class AdventCalendarTest {
fun `既に埋まっている枠は埋められない`() {
// ...
}
fun `既に過ぎてしまっている枠は埋められない`() {
// ...
}
fun `まだ埋まっていないかつ、期日が過ぎていない枠は埋めることができる`() {
// ...
}
// ...
}
ValueObjectもEntityと同様に網羅的にユニットテストを書くという点は変わらないと思うので、これ以上は追求しないことにします。
DomainService
では、ドメインサービスのテストはどのように書くべきでしょうか?
ドメインサービスは集約単体では表現できないようなロジックを表現する際に利用します。
既に登録されているコンテンツ(url)は登録できないという制約をドメインサービスで表現してみましょう。
class AdventCalendarDuplicateContentCheker(
private val adventCalendarRepository,
) {
fun check(contentUrl: Url): Url {
val adventCalendars = adventCalendarRepository.list()
val existsContentUrls = adventCalendars.flatMap { it.slots.values }
return if (existsContentUrls.contains(contentUrl)) {
throw AdventCalendarDuplicateContentError()
} else {
contentUrl
}
}
}
DomainServiceのテストを書くときには3パターンの方法が考えられると思います。
- 単体テストを書くパターン
- インテグレーションテストを書くパターン
- テストを書かない
それぞれのパターンについて考えていきましょう。
1. 単体テストを書くパターン
Mockを使う場合は以下のように書けると思います。
class AdventCalendarDuplicateContentChekerTest {
val adventCalendarRepositoryMock: AdventCalendarRepository = mockk()
val adventCalendarDuplicateContentCheker = AdventCalendarDuplicateContentCheker(
adventCalendarRepository = adventCalendarRepositoryMock
)
@Test
fun `同じ年に重複する名前が存在しない場合はOK`() {
// given:
every { adventCalendarRepositoryMock.list() } returns emptyList()
}
@Test
fun `同じ年に重複する名前が存在する場合はエラー`() {
every { adventCalendarRepositoryMock.list() } returns listOf(AdventCalendar(/* ... */))
}
}
Mockを使ったテストのメリットはドメインサービス単体でテストが簡潔するため、網羅的にテストを書くことができる点になると思います。
一方で、AdventCalendarDuplicateContentCheker
が adventCalendarRepository.list
を使うという実装の詳細を知っている必要があるというデメリットも存在するかと思います。
実装の詳細がテストに書かれることで、振る舞いが変わらずとも実装内容が変わるとテストを修正する必要が出てきます。
例えば、 AdventCalendarRepository.list
ではなく、contentUrlの存在確認ができるQueryServiceを利用するようにリファクタリングしてみます。
class AdventCalendarDuplicateContentCheker(
private val adventCalendarContentUrlQueryService: AdventCalendarContentUrlQueryService,
) {
fun check(contentUrl: Url): Url {
val exists = adventCalendarContentUrlQueryService.existsContentUrl(url)
return if (exists) {
throw AdventCalendarDuplicateContentError()
} else {
contentUrl
}
}
}
振る舞いが変わらないので、デグレが発生していないことが保証されていると安心感があるのですが、Mockを利用する場合には実装内容に合わせてテストを修正する必要があります。
class AdventCalendarDuplicateContentChekerTest {
val adventCalendarContentUrlQueryServiceMock: AdventCalendarContentUrlQueryService = mockk()
val adventCalendarDuplicateContentCheker = AdventCalendarDuplicateContentCheker(
adventCalendarContentUrlQueryService = adventCalendarContentUrlQueryServiceMock
)
@Test
fun `同じ年に重複する名前が存在しない場合はOK`() {
// given:
every { adventCalendarContentUrlQueryServiceMock.existsContentUrl(any()) } returns false
// ...
}
@Test
fun `同じ年に重複する名前が存在する場合はエラー`() {
// given:
every { adventCalendarContentUrlQueryServiceMock.existsContentUrl(any()) } returns true
// ...
}
}
またMockを利用する場合には、 AdventCalendarRepository
や AdventCalendarContentUrlQueryService
の返り値が正しいことは確認できないため、信じることしかできません。
このような場合には AdventCalendarRepository
にもテストを書いておくことをオススメします。
2. IntegrationTestを書くパターン
次に、IntegrationTestを書くパターンを考えてみます。
IntegrationTestは「複数のクラスを組み合わせて想定通りの動作をしているか確認するテスト」でしたね。
今回のDomainServiceでは、 AdventCalendarDuplicateContentCheker
単体だけでなく、 AdventCalendarContentUrlQueryService
も組み合わせて想定どおりの動作を確認するインテグレーションテストを書くことになるかと思います。
RepositoryやQueryServiceを利用するインテグレーションテストを書く場合には、実際にデータベースにテストデータをテスト実行前に投入し、テスト可能な状態を作り上げる必要があります。
テストデータを投入する場合はテスト実行後にテストデータをcleanupするようにしておきましょう。
SpringBootを利用している場合は、 @Transactional
をつけておくとテスト実行後にトランザクションをロールバックしてくれます。
実際に書くと以下のようになるかと思います。
@SpringBootTest
@Transactional
class AdventCalendarDuplicateContentChekerIntegrationTest(
@Autowired private val adventCalendarDuplicateContentCheker: AdventCalendarDuplicateContentCheker,
@Autowired private val adventCalendarRepository: AdventCalendarRepository,
) {
@Test
fun `同じ年に重複する名前が存在しない場合はOK`() {
// given:
url = "https:~"
val adventCalendar = AdventCalendar(
slots = mapOf(AdventCalendarDay(1) to "https:---),
// ...
)
adventCalendarRepository.save(adventCalendar)
// when
val result = adventCalendarDuplicateContentCheker.check(url)
// then:
assertEquals(adventCalendar, result)
}
@Test
fun `同じ年に重複する名前が存在する場合はエラー`() {
url = "https:~"
val adventCalendar = AdventCalendar(
slots = mapOf(AdventCalendarDay(1) to url),
// ...
)
adventCalendarRepository.save(adventCalendar)
// when
val result: () -> Unit = { adventCalendarDuplicateContentCheker.check(adventCalendar) }
// then:
assertThrows<AdventCalendarDuplicateContentError>(result)
}
}
DomainServiceをIntegrationTestで書くメリットとしては、振る舞いをテストするため内部の実装が変更されても、振る舞いさえ変わらなければ、テストを修正する必要がありません。
振る舞いが変わらないことが期待されるようなリファクタリングを実施した際には、このテストがあることで安心感を得られるでしょう。
また、 AdventCalendarRepository
まで組み合わせたテストになるため、仮に AdventCalendarRepository
や AdventCalendarContentUrlQueryService
の実装にミスがあったとしてもこのテストで発見できる可能性があります。
一方で、デメリットとしてはデータベースにデータを投入する必要がありますので、当然ながらデータベースが必要になります。
CIなどで実行するにはデータベースを事前に起動し、マイグレーションを実行してテーブルを作成するなど事前の処理が必要になるため、実行時間は長くなるでしょう。
3. テストを書かない
最後は、DomainServiceについてはテストを書かない場合を考えてみましょう。
この場合、DomainServiceを呼び出すApplication層でのインテグレーションテストでDomainServiceの動作をテストすることになるかと思います。
今回の adventCalendarDuplicateContentCheker
のようにシンプルなDomainServiceであれば、テストを書かずにApplicationServiceのインテグレーションテストに任せるという選択をとっても良いかと思います。
ここはプロダクトやチームの考え方、対象とするDomainServiceの複雑度にもよってくるところだと思います。
Application層
次はApplication層について考えていきます。
先程のDomainServiceでも少し登場しましたね。
Application層ではPresentational層から呼び出されるApplicationServiceというオブジェクトが登場します。
ApplicationServiceはEntityの作成やRepositoryを利用して永続化を行うなどの責務を担います。
基本的にはドメインロジックのような複雑なロジックは基本的には持たずに、ドメイン層や他のApplicationServiceを呼び出して、ユースケースを実現します。
単体テストの考え方/使い方の中ではプロダクション・コードを4象限に分けて分類しています。
(この4象限については詳しく説明しません。知りたい方はぜひ本を読んでみてください!)
ApplicationServiceは基本的には、右下のコントローラーに当たる部分になるかと思います。
複数のコンポーネントが適切に連携できるような調整を行うことを責務として持つイメージです。
ApplicationService
では、ApplicationServiceについて具体例をもとに考えてみます。
少し複雑なものにするために、枠が埋まったら対象の AdventCalendar
を購読している人にメール通知が飛ぶようにしましょう。
AdventCalendarの枠を埋めるApplicationServiceを書いてみます。
@Transactional
class AdventCalendarSlotFiller(
private val adventCalendarRepository: AdventCalendarRepository,
private val adventCalendarDuplicateContentCheker: AdventCalendarDuplicateContentCheker,
private val adventCalendarSlotFilledNotifier: AdventCalendarSlotFilledNotifier,
) {
fun fill(
adventCalendarId: AdventCalendarId,
day: AdventCalendarDay,
authorId: UserId,
title: AdventCalendarSlotTitle,
url: AdventCalendarSlotUrl?
): AdventCalendar {
val adventCalendar = adventCalendarRepository.findById(adventCalendarId)
?: throw AdventCalendarNotExists("存在しないアドベントカレンダーです")
val checkedUrl = adventCalendarDuplicateContentCheker.check(url)
val now = LocalDateTime.now()
val filledAdventCalendar = adventCalendar.fillSlot(
day = day,
authorId = authorId,
title = title,
url = checkedUrl,
now = now,
)
adventCalendarRepository.save(filledAdventCalendar)
adventCalendarSlotFilledNotifier.notify(
adventCalendarId = adventCalendarId,
day = day,
title = title,
authorId = authorId,
url = url,
)
return filledAdventCalendar
}
}
特段ロジックは持ちませんが、アドベントカレンダーの枠を埋めるというユースケースをドメイン層のクラスを組み合わせて実現しています。
では、このApplicationServiceのテストはどのように書くと良いでしょうか?
これもDomainServiceと同じように以下のパターンが取れると思います。
- 単体テストを書くパターン
- インテグレーションテストを書くパターン
- テストを書かない
それぞれのパターンについて簡単に考えていきます。
1. 単体テストを書くパターン
ApplicationServiceで単体テストを書く場合は、ApplicationServiceのテストは「ユースケースを実現するために必要なクラスを適切に呼び出せているか?」を確認するものになると思います。
今回であれば、以下を正しく呼び出すことをテストすることになります。
- AdventCalendarRepository.findById
- AdventCalendarDuplicateContentCheker.check
- AdventCalendar.fillSlot
- AdventCalendarSlotFilledNotifier.notify
- AdventCalendarRepository.save
このようにすれば、ユースケースでの単体テストが実現できるためApplicationServiceの責務である「ユースケースを実現するために必要なクラスを適切に呼び出せているか?」をテストできると思います。
しかし、テストが実装の詳細を知っていることになるので、変更にはかなり弱いです。
また、ApplicationServiceの責務的にほぼ実装と同じ内容を書くことになるので、個人的にはこの方法はテストとしての効果は薄いと思っています。
2. インテグレーションテストを書くパターン
ApplicationServiceのテストでは「複数のコンポーネントを組み合わせてユースケースを実現」できていることを確認したいので、基本的にはインテグレーションテストを書くのが良いと考えています。
ApplicationServiceでインテグレーションテストを書く場合に検討すべきポイントは、Mockをどのように利用するかがあると思います。
論点としては、データベース接続をする Repository
や QueryService
に対してMockを利用するか?になると思います。
RepositoryをMockする場合には、 AdventCalendarDuplicateContentCheker
を直接Mockするのではなく、内部で利用している AdventCalendarContentUrlQueryService
をMockすることになると思います。
実装の詳細は知ることにはなりますが、 AdventCalendarDuplicateContentCheker
や `AdventCalendar` のような複数のクラスを組み合わせたインテグレーションテストを書くことができます。
一方で、データベース接続をMockしない場合を考えてみます。
実は単体テストの考え方/使い方で、どのような場合にモックを利用するべきかについて述べられています。
詳しくはぜひ本を読んでいただきたいです (第8章に詳しく書いてあります)。
結論から書くと、Mock対象を考える際には対象が管理下にない依存をMockとして置き換えることを推奨しています。
- 管理下にある依存: アプリケーションを通してのみアクセスできるもの
- ex. データベース
- 管理下にない依存: アプリケーションが制御できないもの
- ex. メールサービス、Auth0、メッセージ・バス
今回であれば、 AdventCalendarSlotFilledNotifier
が管理下にない依存になるため、 AdventCalendarSlotFilledNotifier
はMockをするが、それ以外はMockとせずにそのまま利用する形になります。
データベースも含めてApplicationServiceのテストとすることで、変更に強く、比較的広い範囲で動作がテストされている安心感を得ることができます。
一方で、ApplicationServiceでインテグレーションテストを書く際に注意すべき点としては、網羅性があります。
ユニットテストのように全パターンを網羅したテストを書こうと思うと、依存するクラス分組み合わせでパターンが生まれてしまうため、膨大な数になってしまいます。
これも単体テストの考え方/使い方にあるのですが、インテグレーションテストを書く場合は、以下の2つ種類のテストに絞って書くことが推奨されています。
- 最長のハッピーパス: すべてのプロセス外依存を経由してシナリオを実現するケース
- エッジケース: 単体テストでは検証できなかったすべての異常ケース
最長のハッピーパスは基本的に1つになるようなイメージです。
もし1つのケースで最長のハッピーパスを実現できない場合は、クラス設計を見直すことも検討しても良良い機会になると思います。
エッジケースについては全てのケースを書く必要がありますが、この勘所は結構難しいと思っています。
例えば、AdventCalendar.fillSlot
はエラーをスローしますが、そのテストについては AdventCalendar
の単体テストで書かれているため、インテグレーションテストでは取り扱わなくて良いものになります。
3. テストを書かない
最後にApplicationServiceでテストを書かないパターンを考えてみます。
この場合は、Presentational層でのIntegrationTestやE2EテストやでApplication層のテストを担う形になると思われます。
これについては、プロダクトの性質やチームの考え方にもよってくると思います。
個人的にはApplication層の役割的にインテグレーションテストを書くことをおすすめします。
Infrastructure層
次は、Infrastructure層について考えます。
Infrastructureでは、RepositoryやQueryServiceの実装クラスが配置されていると思います。
コネクションプールや外部サービスのクライアントのような外部プロセスへアクセスするためのオブジェクトを保持していることが多いと思います。
そのため基本的にRepository単体でなにかを実現することはありません。
Repositoryの責務としては、外部プロセスへアクセスするためのオブジェクトを駆使し、Domain層で定義されたinterfaceのメソッドを実装することになります。
基本的には、ほとんどロジックを持たずに外部サービスやクエリの構築に注力する形になると思われます。
クエリビルダーや外部サービスのクライアントに対してMockを利用することもできるのですが、ロジックを保持しないInfrasturcture層ではあまり効果の高いテストにはならないと思います。
そのため、Repositoryのテストは基本的にはインテグレーションテストになると思います。
ただし、ApplicationServiceやDomainServiceでRepositoryをMockせずにインテグレーションテストを書いている場合には、自ずとInfrastructure層のテストもされることになります。
組み合わせが少なく、単純なデータの出し入れしかしないような場合には、テストを書く必要がないこともあるでしょう。
これは単体テストの考え方/使い方の10章でも取り上げられている内容になります。
テストコードの保守コストの割には得られる退行に対する保護があまり備わらないとあります。
一方で、QueryServiceで引数が多い場合や、大きな集約で複数のテーブルに対して書き込みや読み込みが発生する場合には、インテグレーションテストを書いておくと安心できる場合もあります。
Infrastructure層では、本当にテストを書くべき複雑性があるかを検討するべきでしょう。
Presentational層
最後にPresentational層について考えます。
Presentational層では、Controller、Middleware、FileParser、Converterのようなオブジェクトが登場すると思います。
Presentational層では、HTTPリクエストをパースしてApplication層に渡す値を生成して、レスポンスを返すことが責務になります。
Controller
Controllerでテストはどのように書くべきでしょうか?
リクエストを受け取り、レスポンスを返すということが責務になるため、HTTPリクエストを実際に作成し、期待するレスポンスが受け取れるかをテストするようなIntegrationTestを書くこともできるでしょう。
しかし、Application層でApplicationサービスに対してインテグレーションテストを既に書いている場合には被る内容が多くなると思います。
Application層でインテグレーションテストを書いている場合には、ApplicationServiceをMockしてリクエストを正しくマッピングし、レスポンスを正しく生成できていることを単体テストとして書くということが検討できます。
FileParser/Converter
基本的にはPresentational層ではロジックを持たないように薄くできると良いのですが、ファイルアップロードされた場合などファイルをApplicationServiceで利用可能な形にパースするなど、複雑な処理が発生することもあります。
また、レスポンスを特定の形式で返すためにオブジェクトからファイルやJSONに変換するConverterが存在するケースもあると思います。
FileParserやConverterについては、複雑な処理になるケースもあるので、単体テストを書くと良いでしょう。
終わりに
書いて見たらかなり膨大になってしまいましたが、オニオンアーキテクチャにおける各層でのインテグレーションテストやユニットテストの書き方について、それぞれのメリット・デメリットを具体例を交えて紹介しました。
ChatGPT先生によるサマリが以下になります。
主なポイントの振り返り
- Domain層:
- EntityやValueObjectについては、ユニットテストを網羅的に書く。
- DomainServiceでは、ユニットテストとインテグレーションテストの使い分けが重要。
- 実装に依存しない振る舞いを重視したテスト設計がポイント。
- Application層:
- ApplicationServiceでは、ユースケースを正しく実現できることを確認するインテグレーションテストを推奨。
- Mockの活用に関しては、管理下にない依存のみをMockするというガイドラインを参考にする。
- Infrastructure層:
- 基本的にはインテグレーションテストを採用。
- 他層のインテグレーションテストでカバーできる部分もあるため、個別のテストが必要かどうかを慎重に見極める。
- Presentational層:
- ControllerやFileParserなどは基本的に薄い設計を心がけつつ、テスト対象としてはレスポンス生成やリクエストのマッピングに注力。
- 特に複雑な処理を伴うParserやConverterについては単体テストを必須とする。
もしテスト設計についてさらに深く学びたい場合、この記事で紹介した 「単体テストの考え方/使い方」 を手に取ることをお勧めします。実際のプロダクトでテストを設計・運用する際の実践的なガイドとして役立つはずです!
以上になります!皆さんのプロダクト開発やテストコード執筆に役立てていただければ幸いです。
素敵な年末と、実りある新年をお迎えください!
We Are Hiring!
ログラスではエンジニアを大募集しております!
この記事を通じてログラスに少しでもご興味を持った方や、詳しく話を聞いてみたいと思った方はPittaで面談公開しておりますので、面談組んで頂けますと嬉しいです!
(どんな話題でも大歓迎です!)
Discussion