変更に強いプロダクトを支えるためのコスパの良い単体テスト戦略

2024/06/05に公開

はじめに

Septeni Japan株式会社でプロダクト開発を行なっているエンジニアの千葉です。

本記事では、コストパフォーマンスの良い単体テスト戦略について、オニオンアーキテクチャを採用したタスク管理アプリケーションのAPIを例に説明します。

前提

  • 業務系のWebアプリケーションにおけるテスト

この記事を読んでいただきたい方々

  • 単体テストの重要性を理解しているが、なんとなくテストカバレッジ100%を目指している開発者の方
  • プロダクションコードを変更するたびにテストコードの修正が多くなり、ストレスを感じている開発者の方
  • リファクタリングを行う際にテストコードの修正が多く発生し、本当に挙動が変わっていないか不安を感じている開発者の方

コストパフォーマンスの良い単体テスト戦略とは?

一般的に、テストコードを書くことで開発中にバグを検知できる範囲が広がりますが、テストの作成やプロダクションコードの変更時にかかるテストの修正コストも増えていきます。

特に、外部サービスの呼び出しやDBとの接続を担うインフラストラクチャ層などの外部ライブラリに依存している部分のユニットテストは非常に書きづらいことが多くテストの作成に時間がかかります。その割にはテストによって守れる価値がビジネスインパクトの低い部分だったりします。

ビジネスインパクトの小さいバグを発生させないためにテストのメンテナンスコストを上げて変更のリードタイムを長くするのは、無駄にプロダクトの成長を遅くし顧客への価値提供を遅らせてしまうことになり、コストパフォーマンスが悪いと考えています。

そのため、ビジネスインパクトの小さいバグに対しては、テストのメンテナンスコストを上げずに変更のリードタイムを短くすることでバグ対応のリードタイムを短くすることがコストパフォーマンスの観点で重要だと考えています。

コストパフォーマンスの良い単体テスト戦略とは、ビジネスインパクトの大きいバグを防ぎつつ、変更に伴うテストの修正コストを最小限に抑えるために、単体テストを「重要なビジネスロジック」に限定することを指します。(グラフ1のオレンジの部分)


グラフ1

また、テストカバレッジに関しては80%〜90%程度が適切とされていますが、重要なビジネスロジックの量によって適正値は異なるので、カバレッジ目標は厳格に定める必要はないと考えています。

参考になる情報として、以下のサイトがあります。

https://bliki-ja.github.io/TestCoverage
https://testing.googleblog.com/2020/08/code-coverage-best-practices.html

どう実現するか?

では、コストパフォーマンスの良い単体テスト戦略をオニオンアーキテクチャを採用したタスク管理アプリのタスク完了APIの例を使って説明していきます。

なぜオニオンアーキテクチャか?

テストを書く対象を「重要なビジネスロジック」に限定するにはビジネスインパクトの大きいロジックが分離され、テストが容易な状態になっている必要があります。

そのため、上記観点をクリアしているクリーンアーキテクチャやヘキサゴナルアーキテクチャでも問題ないのですが、シンプルで分かりやすいという点でオニオンアーキテクチャを採用しました。

重要なビジネスロジックは基本的に

  • オニオンアーキテクチャであればドメインモデル
  • クリーンアーキテクチャであればエンティティ
  • ヘキサゴナルアーキテクチャであればアプリケーション

に書くことになります。


https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/

テストを書く対象のプロダクションコード

以下を使ったコードで説明していきます。

  • Kotlin
  • Springboot
  • Ktorm(Kotlin製ORM)

コントローラー

@RestController
@RequestMapping("/tasks")
class TaskController(private val taskService: TaskService) {

    @PutMapping("/{taskId}/complete")
    fun complete(@PathVariable taskId: String): ResponseEntity<TaskResponse> {
        val sessionUserId = UserId("xxxxxxxx") // 今回の主眼ではないのでセッションユーザーは雑にハードコードしています。
        val result = taskService.complete(taskId, sessionUserId)
        return result.fold(
            onFailure = {
                ResponseEntity.badRequest().build() }, // 今回の主眼ではないのでエラーハンドリングは雑です
            onSuccess = { task -> ResponseEntity.ok(TaskResponse.from(task)) }
        )
    }
}

サービス

@Service
class TaskService(private val taskRepository: TaskRepository) {

    fun complete(taskId: String, sessionUserId: UserId): Result<Task> {
        val task = taskRepository.findBy(taskId) ?: return Result.failure(Error("Task is not found. taskId: $taskId"))

        val completedTask = task.complete(LocalDateTime.now(), sessionUserId)
        taskRepository.update(completedTask)
        return Result.success(completedTask)
    }

}

リポジトリ

interface TaskRepository {
    fun findBy(taskId: String): Task?
    fun update(task: Task): Int
}

@Repository
class TaskRDBRepository(private val database: Database) : TaskRepository {
    override fun findBy(taskId: String): Task? {
        val task = database.from(TaskTable).select().where { TaskTable.id eq taskId }.map { TaskTable.createEntity(it) }
            .firstOrNull()
        return task?.let { Task.fromEntity(task) }
    }

    override fun update(task: Task): Int {
        return database.update(TaskTable) {
            set(it.title, task.title)
            set(it.status, task.status.name)
            set(it.completedAt, task.completedAt)
            where { it.id eq task.id }
        }
    }
}

ドメインモデル

data class Task(
    val id: String,
    val title: String,
    val createdBy: UserId,
    val status: TaskStatus,
    val completedAt: LocalDateTime?
) {

    fun complete(completedAt: LocalDateTime, userId: UserId): Task {

        if (this.status != TaskStatus.InProgress) {
            throw Exception("完了にできるステータスはInProgressのみです。")
        }

        if (userId != createdBy) {
            throw Exception("他のユーザーのタスクを更新できません。")
        }

        return Task(
            id = this.id,
            title = this.title,
            createdBy = this.createdBy,
            status = TaskStatus.Completed,
            completedAt = completedAt
        )
    }

    companion object {
        fun fromEntity(entity: TaskEntity): Task {
            return Task(
                id = entity.id,
                title = entity.title,
                createdBy = UserId(entity.createdBy),
                status = TaskStatus.valueOf(entity.status),
                completedAt = entity.completedAt
            )
        }
    }
}

どういうテストコードを書くか?

今回のタスク完了というにおいて重要ビジネスロジックは以下とします。

  • 完了状態にできるのは
    • 進行中(InProgress)のタスクのみ
    • 自分のタスクのみ

上記を踏まえて「どのレイヤーにどういうテストを書くか」をまとめた表です。

例での呼称 / オニオンアーキテクチャでのレイヤー テストを書くかどうか テスト観点 テスト内容
コントローラー(全体) / UI ⭕️ APIの振る舞いが期待した通りになっているか 実施のDBを利用した正常系のテストとドメインサービス、ドメインモデルで担保できない異常系のテスト
サービス/ Application Service - -
ドメインサービス/ Domain Service ⭕️ 重要なビジネスロジックの正当性 基本的にパターン網羅 ※今回は単純な処理なので省略
ドメインモデル/ Domain Model ⭕️ 重要なビジネスロジックの正当性 基本的にパターン網羅
リポジトリ/ Infrastructure - -

重要なビジネスロジックを含んだDomain Modelだけでなく、APIの全体的な振る舞いをテストする理由は、以下のようなケースで重大なバグが発生する可能性があるからです。

  • ドメインモデルを使用せずにAPIを実装した場合の極端なケース
  • リポジトリやサービスで意図しないバグが発生した場合

前提として、リポジトリ層はドメインモデルの値をそのまま永続化するようなシンプルなコードにすることで、意図しないバグを発生させにくい状態を作ることが望ましいです。

また、重要なビジネスロジックをドメインモデルだけで表現できない場合、複雑なロジックはドメインサービス層に記述されることがあります。そのため、ドメインサービスにもテストを書く必要があります。

実際のテストコード例

コントローラー

@SpringBootTest
@Import(TestConfig::class)
class TaskControllerTest(taskController: TaskController, database: Database) : FunSpec() {

    @Autowired
    lateinit var timeProvider: TimeProvider

    init {

        database.useConnection { connection ->
            val statement = connection.createStatement()
            statement.execute(
                """
                  CREATE TABLE IF NOT EXISTS tasks (
                    id VARCHAR(255) NOT NULL PRIMARY KEY,
                    title VARCHAR(255) NOT NULL,
                    status VARCHAR(255) NOT NULL,
                    created_at TIMESTAMP NOT NULL,
                    created_by VARCHAR(255) NOT NULL,
                    completed_at TIMESTAMP
                  );
                """
            )
        }

        afterTest {
            database.useConnection { connection ->
                val statement = connection.createStatement()
                statement.execute("DELETE FROM tasks")
            }
        }

        context("#complete") {
            context("ステータスがInProgressであるタスクの場合") {
                test("ステータスが完了かつ完了日時が現在時間でセットされたタスクが作成されること") {

                    // 前提データ作成
                    val taskId = "task-id"
                    val newTask = TaskEntity {
                        id = taskId
                        title = "test"
                        status = TaskStatus.InProgress.name
                        createdBy = "xxxxxxxx"
                        createdAt = LocalDateTime.of(2024, 5, 23, 0, 0)
                    }
                    database.sequenceOf(TaskTable).add(newTask)

                    val responseEntity = taskController.complete(taskId)

                    responseEntity.statusCode shouldBe HttpStatus.OK

                    val task = database.sequenceOf(TaskTable).find { it.id eq taskId }

                    withClue("Taskテーブルのレコード状態が正しいこと") {
                        assertSoftly {
                            task?.title shouldBe newTask.title
                            task?.status shouldBe TaskStatus.Completed.name
                            task?.completedAt shouldBe timeProvider.now()
                            task?.createdBy shouldBe newTask.createdBy
                        }
                    }
                }
            }

            context("指定したタスクが存在しない場合") {
                val targetTaskId = "XXXXXXXXXXXXXXX"
                val taskId = "task-id"
                test("エラーを返す") {

                    // 前提データ作成
                    val newTask = TaskEntity {
                        id = taskId
                        title = "test"
                        status = TaskStatus.InProgress.name
                        createdBy = "xxxxxxxx"
                        createdAt = LocalDateTime.of(2024, 5, 23, 0, 0)
                    }
                    database.sequenceOf(TaskTable).add(newTask)

                    val responseEntity = taskController.complete(targetTaskId)

                    responseEntity.statusCode shouldBe HttpStatus.BAD_REQUEST
                }
            }
        }
    }
}

ドメインモデル

class TaskSpec : FunSpec({
    context("#complete") {

        val baseTask = Task(
            id = "XXXXXXXXX",
            title = "test",
            createdBy = UserId("-"),
            status = TaskStatus.Pending,
            completedAt = null
        )
        val completedAt = LocalDateTime.of(2024, 5, 22, 10 ,45)

        context("自分のタスクの場合") {
            val sessionUserId =  UserId("abc")
            val taskCreatorUserId =  sessionUserId

            context("ステータスがInProgressの場合") {
                val status = TaskStatus.InProgress

                test("ステータスがCompleteかつ完了日時がセットされたTaskを返すこと") {
                    val task = baseTask.copy(createdBy = taskCreatorUserId, status = status)

                    val actual = task.complete(completedAt, sessionUserId)
                    val expected = task.copy(status = TaskStatus.Completed, completedAt = completedAt)

                    actual shouldBe expected
                }
            }

            context("ステータスがPendingの場合") {
                val status = TaskStatus.Pending

                test("エラーになること") {
                    val task = baseTask.copy(createdBy = taskCreatorUserId, status = status)

                    shouldThrowMessage("完了にできるステータスはInProgressのみです。") {
                        task.complete(completedAt, sessionUserId)
                    }
                }
            }

            context("ステータスがCompletedの場合") {
                val status = TaskStatus.Completed

                test("エラーになること") {
                    val task = baseTask.copy(createdBy = taskCreatorUserId, status = status)

                    shouldThrowMessage("完了にできるステータスはInProgressのみです。") {
                        task.complete(completedAt, sessionUserId)
                    }
                }
            }
        }

        context("他人のタスクの場合") {
            val sessionUserId =  UserId("abc")
            val taskCreatorUserId =  UserId("xxxxx")
            test("エラーになること") {
                val task = baseTask.copy(createdBy = taskCreatorUserId, status = TaskStatus.InProgress)

                shouldThrowMessage("他のユーザーのタスクを更新できません。") {
                    task.complete(completedAt, sessionUserId)
                }
            }
        }
    }
})

リファクタリングを行った場合、テストコードにどれくらいの変更量が発生するか

現状では、テストコードによってビジネスロジックを保護しており、誤った実装をしたとしてもテスト実行により気づくことができます。(例:完了済みのタスクに対して再度完了処理を行う)

しかし、まだまだ改善の余地があるので以下を狙ったリファクタリングをしようと思います。

  • 実装のより早い段階で誤った実装に気づけるようにすることで変更のリードタイムを短くする
  • ドメインモデルの振る舞いや制約を分かりやすくする
  • 守備範囲を保ったままテストコードを減らすことでテストのメンテナンスコストを下げる

この場合、テストコードにどれくらいの変更が必要かを見てみましょう。

プロダクションコードの変更

まずは、どのようなリファクタリングを行ったかを見ていきます。

ドメインモデル

ステータスごとに型を分けることで、各状態でどんな振る舞いが可能かを分かりやすくしました。
それぞれの型には、そのステータスに関連するメソッドが用意されています。
型に用意されていないメソッドは呼ぶことができないため、各メソッド内でステータスのチェックを行う必要がありません。

これにより、コードの可読性が向上し、ステータスに関連する振る舞いが明確になりました。

@@ -5,42 +5,89 @@ import webapptraining.backend.models.task.entities.TaskEntity
 import webapptraining.backend.models.user.UserId
 import java.time.LocalDateTime
 
-data class Task(
-    val id: String,
-    val title: String,
-    val createdBy: UserId,
-    val status: TaskStatus,
-    val completedAt: LocalDateTime?
-) {
-
-    fun complete(completedAt: LocalDateTime, userId: UserId): Task {
-
-        if (userId != createdBy) {
-            throw Exception("他のユーザーのタスクを更新できません。")
+
+sealed interface Task {
+    val id: String
+    val title: String
+    val createdBy: UserId
+
+    fun status(): TaskStatus {
+        return when (this) {
+            is PendingTask -> TaskStatus.Pending
+            is InProgressTask -> TaskStatus.InProgress
+            is CompletedTask -> TaskStatus.Completed
+        }
+    }
+
+    data class PendingTask(
+        override val id: String,
+        override val title: String,
+        override val createdBy: UserId
+    ) : Task {
+
+        fun start(): InProgressTask {
+            TODO()
+        }
+    }
+
+    data class InProgressTask(
+        override val id: String,
+        override val title: String,
+        override val createdBy: UserId
+    ) : Task {
+
+        fun pause(): PendingTask {
+            TODO()
         }
 
-        if (this.status != TaskStatus.InProgress) {
-            throw Exception("完了にできるステータスはInProgressのみです。")
+        fun complete(completedAt: LocalDateTime, userId: UserId): CompletedTask {
+
+            if(userId != createdBy) {
+                throw Exception("他のユーザーのタスクを更新できません。")
+            }
+
+            return CompletedTask(
+                id = this.id,
+                title = this.title,
+                createdBy = this.createdBy,
+                completedAt = completedAt
+            )
         }
+    }
 
-        return Task(
-            id = this.id,
-            title = this.title,
-            createdBy = this.createdBy,
-            status = TaskStatus.Completed,
-            completedAt = completedAt
-        )
+    data class CompletedTask(
+        override val id: String,
+        override val title: String,
+        override val createdBy: UserId,
+        val completedAt: LocalDateTime
+    ) : Task {
+
+        fun restart(): InProgressTask {
+            TODO()
+        }
     }

サービス

@@ -12,9 +12,14 @@ class TaskService(private val taskRepository: TaskRepository, private val timePr
     fun complete(taskId: String, sessionUserId: UserId): Result<Task> {
         val task = taskRepository.findBy(taskId) ?: return Result.failure(Error("Task is not found. taskId: $taskId"))
 
-        val completedTask = task.complete(timeProvider.now(), sessionUserId)
-        taskRepository.update(completedTask)
-
-        return Result.success(completedTask)
+        return when (task) {
+            is Task.InProgressTask -> {
+                val completedTask = task.complete(timeProvider.now(), sessionUserId)
+                taskRepository.update(completedTask)
+                return Result.success(completedTask)
+            }
+            is Task.PendingTask -> Result.failure(Error("未開始のタスクは完了できません。"))
+            is Task.CompletedTask -> Result.failure(Error("既に完了済みのタスクです。"))
+        }
     }
 }

リポジトリ

@@ -17,8 +17,8 @@ class TaskRDBRepository(private val database: Database) : TaskRepository {
     override fun update(task: Task): Int {
         return database.update(TaskTable) {
             set(it.title, task.title)
-            set(it.status, task.status.name)
-            set(it.completedAt, task.completedAt)
+            set(it.status, task.status().name)
+            set(it.completedAt, if (task is Task.CompletedTask) task.completedAt else null)
             where { it.id eq task.id }
         }
     }

テストコードの変更

次に、テストコードにどのような変更が必要かを見ていきます。

コントローラー

変更不要です。
コントローラーのユニットテストを変更せずに実行したところ、テストが見事にパスしました。
これにより、リファクタリングによってコードの挙動が変わっていないことを確認できました。

ドメインモデル

class TaskSpec : FunSpec({
-    context("#complete") {
 
-        val baseTask = Task(
-            id = "XXXXXXXXX",
-            title = "test",
-            createdBy = UserId("-"),
-            status = TaskStatus.Pending,
-            completedAt = null
-        )
-        val completedAt = LocalDateTime.of(2024, 5, 22, 10 ,45)
 
-        context("自分のタスクの場合") {
-            val sessionUserId =  UserId("abc")
-            val taskCreatorUserId =  sessionUserId
+    context("#InProgressTask") {
+        context("#complete") {
 
-            context("ステータスがInProgressの場合") {
-                val status = TaskStatus.InProgress
+            val sessionUserId = UserId("abc")
 
-                test("ステータスがCompleteかつ完了日時がセットされたTaskを返すこと") {
-                    val task = baseTask.copy(createdBy = taskCreatorUserId, status = status)
+            val inProgressTask = InProgressTask(
+                id = "12345",
+                title = "test",
+                createdBy = UserId("-"),
+            )
+            val completedAt = LocalDateTime.of(2024, 5, 22, 10, 45)
 
-                    val actual = task.complete(completedAt, sessionUserId)
-                    val expected = task.copy(status = TaskStatus.Completed, completedAt = completedAt)
+            context("自分のタスクの場合") {
 
-                    actual shouldBe expected
-                }
-            }
-
-            context("ステータスがPendingの場合") {
-                val status = TaskStatus.Pending
+                val targetInProgressTask = inProgressTask.copy(createdBy = sessionUserId)
 
                 test("エラーになること") {
-                    val task = baseTask.copy(createdBy = taskCreatorUserId, status = status)
 
-                    shouldThrowMessage("完了にできるステータスはInProgressのみです。") {
-                        task.complete(completedAt, sessionUserId)
-                    }
+                    val actual = targetInProgressTask.complete(completedAt, sessionUserId)
+                    val expected = CompletedTask(
+                        id = "12345",
+                        title = "test",
+                        createdBy = sessionUserId,
+                        completedAt = completedAt,
+                    )
+
+                    actual shouldBe expected
                 }
             }
 
-            context("ステータスがCompletedの場合") {
-                val status = TaskStatus.Completed
 
-                test("エラーになること") {
-                    val task = baseTask.copy(createdBy = taskCreatorUserId, status = status)
-
-                    shouldThrowMessage("完了にできるステータスはInProgressのみです。") {
-                        task.complete(completedAt, sessionUserId)
-                    }
-                }
-            }
-        }
+            context("他人のタスクの場合") {
+                val targetInProgressTask = inProgressTask.copy(createdBy = UserId("XXXXXXXXXX"))
 
-        context("他人のタスクの場合") {
-            val sessionUserId =  UserId("abc")
-            val taskCreatorUserId =  UserId("xxxxx")
-            test("エラーになること") {
-                val task = baseTask.copy(createdBy = taskCreatorUserId, status = TaskStatus.InProgress)
+                test("エラーになること") {
 
-                shouldThrowMessage("他のユーザーのタスクを更新できません。") {
-                    task.complete(completedAt, sessionUserId)
+                    shouldThrowMessage("他のユーザーのタスクを更新できません。") {
+                        targetInProgressTask.complete(completedAt, sessionUserId)
+                    }
                 }
             }
         }
     }
+
 })

今回のリファクタリングではInProgressTaskというモデルにcompleteメソッドを生やしたので、
PendingやCompletedの状態でcompleteメソッドを呼び出した場合の異常テストケースを書く必要がなくなりました。

テストコードが少なくなるとその分メンテナンスコストが低くなるので、テストコード量が減るのは嬉しいことですね!

結果

メインのリファクタリング対象であるドメインモデルのテストコードを変更するだけで済みました。

全レイヤーのテストを書いた場合は、テストコードにどれくらいの変更量が発生するか

それでは次に、全レイヤーのテストを書いた場合にテストコードにどれくらいの変更量が発生するかを見ていきます。

テストコード

各レイヤーにテストを書くので、テスト対象の責務以外はモックを利用します。

コントローラー

@SpringBootTest
@Import(TestConfig::class)
class TaskControllerTest(timeProvider: TimeProvider) : FunSpec( {

    val taskService: TaskService = mockk()
    val taskController = TaskController(taskService)

    context("#complete") {
        context("ステータスがInProgressであるタスクの場合") {
            test("ステータスが完了かつ完了日時が現在時間でセットされたタスクが作成されること") {

                val taskId = "1234"
                val sessionUserId = UserId("xxxxxxxx")

                val task = Task(id = taskId, title = "some task", status = TaskStatus.InProgress, createdBy = sessionUserId, completedAt = null)
                every { taskService.complete(taskId, sessionUserId) } answers { Result.success(task) }
                val actual = taskController.complete(taskId)

                actual.statusCode shouldBe HttpStatus.OK

                val expectedBody = TaskResponse(id="1234", title="some task", createdBy="xxxxxxxx", status="Completed", completedAt=timeProvider.now())
                actual.body shouldBe expectedBody
            }
        }

        context("指定したタスクが存在しない場合") {
            test("エラーを返す") {

                val taskId = "1234"
                val sessionUserId = UserId("xxxxxxxx")

                every { taskService.complete(taskId, sessionUserId) } answers { Result.failure(Error("Task is not found. taskId: $taskId")) }

                val actual = taskController.complete(taskId)

                actual.statusCode shouldBe HttpStatus.BAD_REQUEST
            }
        }
    }
}

サービス

package webapptraining.backend.services

import config.TestConfig
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import webapptraining.backend.models.enum.TaskStatus
import webapptraining.backend.models.task.Task
import webapptraining.backend.models.user.UserId
import webapptraining.backend.repositories.TaskRepository
import webapptraining.backend.utils.TimeProvider

@SpringBootTest
@Import(TestConfig::class)
class TaskServiceTest(timeProvider: TimeProvider): FunSpec( {

    val taskRepository: TaskRepository = mockk()
    val taskService = TaskService(taskRepository, timeProvider)

    context("#complete") {

        context("タスクが存在する場合") {
            test("タスクの完了処理が呼ばれ、完了後のタスクを返すこと") {
                val taskId = "someTaskId"
                val sessionUserId = UserId("someUserId")
                val task = Task(id = taskId, title = "some task", status = TaskStatus.InProgress, createdBy = sessionUserId, completedAt = null)
                val completedTask = task.copy(status = TaskStatus.Completed, completedAt = timeProvider.now())
                every { taskRepository.findBy(taskId) } returns task
                every { taskRepository.update(completedTask) } answers { 1 }

                val actual = taskService.complete(taskId, sessionUserId)

                verify(exactly = 1) { taskRepository.update(completedTask) }
                actual shouldBe Result.success(completedTask)
            }
        }

        context("タスクが存在しない場合") {
            test("エラーが返ること") {
                val taskId = "someTaskId"
                val sessionUserId = UserId("someUserId")
                every { taskRepository.findBy(taskId) } returns null

                val actual = taskService.complete(taskId, sessionUserId)

                actual.isFailure shouldBe true
                actual.exceptionOrNull()?.message shouldBe "Task is not found. taskId: $taskId"
            }
        }
    }
})

リポジトリ

@SpringBootTest
@Import(TestConfig::class)
class TaskRDBRepositorySpec(taskRepository: TaskRepository, database: Database, timeProvider: TimeProvider) :
    FunSpec() {

    init {

        database.useConnection { connection ->
            val statement = connection.createStatement()
            statement.execute(
                """
					CREATE TABLE IF NOT EXISTS tasks (
						id VARCHAR(255) NOT NULL PRIMARY KEY,
						title VARCHAR(255) NOT NULL,
						status VARCHAR(255) NOT NULL,
						created_at TIMESTAMP NOT NULL,
						created_by VARCHAR(255) NOT NULL,
						completed_at TIMESTAMP
					);
				"""
            )
        }

        afterTest {
            database.useConnection { connection ->
                val statement = connection.createStatement()
                statement.execute("DELETE FROM tasks")
            }
        }

        context("#update") {
            test("指定されたタスクが永続化できていること") {

                // 前提データ作成
                val taskId = "task-id"
                val newTask = TaskEntity {
                    id = taskId
                    title = "test"
                    status = TaskStatus.InProgress.name
                    createdBy = "xxxxxxxx"
                    createdAt = LocalDateTime.of(2024, 5, 23, 0, 0)
                }
                database.sequenceOf(TaskTable).add(newTask)

                val targetTask = Task(
                    id = taskId,
                    title = "test",
                    completedAt = LocalDateTime.of(2024, 6, 23, 0, 0),
                    createdBy = UserId("xxxxxxxx"),
                    status = TaskStatus.InProgress
                )

                val result = taskRepository.update(targetTask)

                result shouldBe 1

                val task = database.sequenceOf(TaskTable).find { it.id eq taskId }
                withClue("Taskテーブルのレコード状態が正しいこと") {
                    assertSoftly {
                        task?.title shouldBe targetTask.title
                        task?.status shouldBe targetTask.status.name
                        task?.completedAt shouldBe targetTask.completedAt
                        task?.createdBy shouldBe targetTask.createdBy.value
                    }
                }
            }
        }
    }
}

リファクタリング後のテストコードの変更

先ほどのリファクタリングを行った際にテストコードにどのような変更が入るかを見ていきます。

コントローラー

@@ -29,13 +28,18 @@ class TaskControllerTest(timeProvider: TimeProvider) : FunSpec( {
                 val taskId = "1234"
                 val sessionUserId = UserId("xxxxxxxx")
 
-                val task = Task(id = taskId, title = "some task", status = TaskStatus.InProgress, createdBy = sessionUserId, completedAt = null)
+                val task = CompletedTask(
+                    id = taskId,
+                    title = "some task",
+                    createdBy = sessionUserId,
+                    completedAt=timeProvider.now()
+                )
                 every { taskService.complete(taskId, sessionUserId) } answers { Result.success(task) }
                 val actual = taskController.complete(taskId)
 
                 actual.statusCode shouldBe HttpStatus.OK
 
-                val expectedBody = TaskResponse(id="1234", title="some task", createdBy="xxxxxxxx", status="InProgress", completedAt=null)
+                val expectedBody = TaskResponse(id="1234", title="some task", createdBy="xxxxxxxx", status="Completed", completedAt=timeProvider.now())
                 actual.body shouldBe expectedBody
             }
         }

サービス

@@ -27,8 +26,16 @@ class TaskServiceTest(timeProvider: TimeProvider): FunSpec( {
             test("タスクの完了処理が呼ばれ、完了後のタスクを返すこと") {
                 val taskId = "someTaskId"
                 val sessionUserId = UserId("someUserId")
-                val task = Task(id = taskId, title = "some task", status = TaskStatus.InProgress, createdBy = sessionUserId, completedAt = null)
-                val completedTask = task.copy(status = TaskStatus.Completed, completedAt = timeProvider.now())
+                val task = InProgressTask(
+                    id = taskId,
+                    title = "some task",
+                    createdBy = sessionUserId,
+                )
+                val completedTask = CompletedTask(
+                    id = taskId,
+                    title = "some task",
+                    createdBy = sessionUserId,
+                    completedAt = timeProvider.now())
                 every { taskRepository.findBy(taskId) } returns task
                 every { taskRepository.update(completedTask) } answers { 1 }

@@ -51,5 +58,40 @@ class TaskServiceTest(timeProvider: TimeProvider): FunSpec( {
                 actual.exceptionOrNull()?.message shouldBe "Task is not found. taskId: $taskId"
             }
         }
+
+        context("未開始タスクの場合") {
+            test("エラーが返ること") {
+                val taskId = "someTaskId"
+                val sessionUserId = UserId("someUserId")
+                every { taskRepository.findBy(taskId) } returns PendingTask(
+                    id = taskId,
+                    title = "some task",
+                    createdBy = sessionUserId,
+                )
+
+                val actual = taskService.complete(taskId, sessionUserId)
+
+                actual.isFailure shouldBe true
+                actual.exceptionOrNull()?.message shouldBe "未開始のタスクは完了できません。"
+            }
+        }
+
+        context("完了済みタスクの場合") {
+            test("エラーが返ること") {
+                val taskId = "someTaskId"
+                val sessionUserId = UserId("someUserId")
+                every { taskRepository.findBy(taskId) } returns CompletedTask(
+                    id = taskId,
+                    title = "some task",
+                    createdBy = sessionUserId,
+                    completedAt = timeProvider.now(),
+                )
+
+                val actual = taskService.complete(taskId, sessionUserId)
+
+                actual.isFailure shouldBe true
+                actual.exceptionOrNull()?.message shouldBe "既に完了済みのタスクです。"
+            }
+        }
     }
 })

ドメインモデル

ビジネスロジックのみに書いた場合と同様

リポジトリ

@@ -66,12 +64,11 @@ class TaskRDBRepositorySpec(taskRepository: TaskRepository, database: Database,
                 }
                 database.sequenceOf(TaskTable).add(newTask)
 
-                val targetTask = Task(
+                val targetTask = CompletedTask(
                     id = taskId,
                     title = "test",
                     completedAt = LocalDateTime.of(2024, 6, 23, 0, 0),
                     createdBy = UserId("xxxxxxxx"),
-                    status = TaskStatus.InProgress
                 )
 
                 val result = taskRepository.update(targetTask)
@@ -82,7 +79,7 @@ class TaskRDBRepositorySpec(taskRepository: TaskRepository, database: Database,
                 withClue("Taskテーブルのレコード状態が正しいこと") {
                     assertSoftly {
                         task?.title shouldBe targetTask.title
-                        task?.status shouldBe targetTask.status.name
+                        task?.status shouldBe targetTask.status().name
                         task?.completedAt shouldBe targetTask.completedAt
                         task?.createdBy shouldBe targetTask.createdBy.value
                     }

結果

ドメインモデル以外のコントローラー、サービス、リポジトリの各層でテストの変更が必要になりました。 今回の例は単純な処理を使用しているため、実際のAPIではさらに多くのテストコードの変更が必要になると思います。

では、先ほどのテスト戦略と比較して、今回書いたテストのリグレッションへの耐性(テストの守備力)はどうでしょうか?

各層のテストでは、重複する確認項目を避けるために、各レイヤーの責務に限定したモックを使用したテストを書きました。そのため、タスク完了API全体の動作が変わっていないかを確認することができません。

モックを使用すると、将来の変更でサービスやリポジトリのメソッドの処理が変更された場合に、正しく動作するかを確認できないテストになってしまいます。つまり、リグレッションへの耐性(テストの守備力)が低いテストとなっています。

全レイヤーにテストを書くことで内部品質が向上したように感じるかもしれませんが、実際にはリグレッションへの耐性が低くなり、変更コストも高くなることがあります。今回の例で改めて確認できたかと思います。

そのような観点から、ビジネスロジックに焦点を絞った単体テストと統合テストの組み合わせは、コストパフォーマンスが良いと言えますね。

結論

以下のアプローチを実践することで、ビジネスインパクトの大きいバグを防ぎつつ、変更に伴うテストの修正コストを最小限に抑えるコストパフォーマンスの良い単体テスト戦略を説明してきました。

  • 重要なビジネスロジックを扱うレイヤーに焦点を当てて単体テストを書く
  • 期待する振る舞いを含む統合テストをデータベースまで利用して書く

これらのアプローチを実践することで、リファクタリングを行った際に以下のような効果が得られます。

テスト自体の修正を最小限に抑えるため、変更のリードタイムが短くなる
振る舞い自体のテストは変更が不要なため、リファクタリングによるデグレーションが発生していないことを確実に確認できる

仕様変更を行う際にも同様の効果が得られます。
また、TDD(テスト駆動開発)を実践する場合は、サイクルごとのリファクタリングフェーズで振る舞いが変わっていないことを確認できるため、リファクタリングに対する自信を持ちながら迅速に変更を行うことができるでしょう。

さいごに

今回はバックエンド側のテストに絞って考えを書きましたが、

E2Eテストやフロントエンドの様々なテスト手法(ex. Storybookを使ったコンポーネントテストやビジュアルリグレッションテスト)も含めてコスパの良いテスト戦略を模索中なので、考えがまとまったらまたアウトプットしていこうと思います。

単体テストについて理解を深めたい方は単体テストの考え方-使い方を読むと良いです。

「良い単体テストを構成する4本の柱」をはじめとして良い単体テストを書くために必要な考え方を体系的に分かりやすく説明している本なので興味がある方は是非読んでみてください。

参考書籍

We are hiring

Septeni Japanでは、一緒にプロダクト開発組織を盛り上げてくれる仲間を募集しています!
ご興味のある方は以下リンクから応募していただき、カジュアル面談を通じて働く環境や仲間を知っていただければと思います!

その際、応募フォームの「知ったきっかけ」に「テックブログ」と記載いただければと思います。
https://septeni-recruitment.snar.jp/jobboard/detail.aspx?id=D3Sa2fnGT-Q5Qe8T-ivLbg

Discussion