📚

良い単体試験の作り方

2025/01/24に公開

はじめに

テストを書いた後に、
不具合が起きていないのにまたテストを修正する必要が出てくる
こんな経験はないでしょうか?

  • テストを書いた後にリファクタするとテストがエラーになる
  • テストを書いた後に新規機能追加するとテストがエラーになる

これは何かがおかしいぞ・・・

テストの修正に工数が取られて、作業が進まない・・・困った・・・(泣)
さて、この問題を解決するにはどうすればいいでしょうか?

※この記事のサンプルコードはKotlinで書いています。

単体テストは実装の詳細を知らないように意識すると良い

最初に一番伝えたいことを記載します。

単体テストは実装の詳細を知らないように作ると良いです
内部実装と紐づくほどリファクタした時にテストが壊れやすくなるためです。

しかし、これはテスト対象がそれが実現できる設計になってないとできません。
実際の現場では設計の見直しから始めないとできない状況があり得ます。
なので、無理に単体テストは実装の詳細を知ってはいけないとルール化して運用するのではなく、
可能な限り目指しましょうという運用が良いでしょう。

※この記事は書籍である「単体テストの考え方/使い方」を参考にしています。

https://www.amazon.co.jp/単体テストの考え方-使い方-Vladimir-Khorikov/dp/4839981728

単体テストって何ですか?

以下の3つの条件を満たすものが単体テストになります。満たさないものは結合テスト、E2Eテストに分類されます。

  • 1単位の観察可能な振る舞いを検証すること
  • 実行時間が短いこと
  • 他のテストケースと隔離されていること

一つづつ解説しましょう。

1単位の観察可能な振る舞いを検証すること

「振る舞い」とはビジネス・サイドの人たちが有用であると考える何かで、単体テストではその1単位を確認します。これは抽象的な表現で、人によって解釈が異なることがあるかと思います。

「観察可能」というのは目標を達成するために公開されたメソッド、状態です。つまりpublicなメソッドやpublicなフィールドのことです。

privateメソッドやprivateフィールドなど該当しないものは実装の詳細となります。
単体テストでは実装の詳細と紐づくほどリファクタ耐性が低くなりテストが壊れやすくなります。

publicのみテストすることが理想ですが、適切な設計になっていないとできません。

  • テスト対象がデータベースなどのプロセス外依存が発生している
  • テスト対象が他クラスに対して依存が多すぎる
  • テスト対象の処理が多すぎる、複雑すぎる

こういうことが起きている可能性があります。他クラスへの依存度を減らしたり、複雑な処理を別クラスに切り出してそれをpublicとして公開したりしないと質の良いテストが作れません。

現場ではそういったリファクタする時間が取れないケースもあると思います。
そのため無理してpublicのみテストする運用にすると質の悪いテストが量産されます。

この状態になるぐらいであれば、テストを書かない方がマシかもしれません。publicのみのテストだと難しそうであれば、まずはprivateをpublicにしてテストするでもありかと思います。

実行時間が短い

Databaseやファイルの読み書き、インターネット通信が走る処理は実行時間が遅いため単体テストでは行いません。これらは結合テスト、E2Eテストで行いましょう。

他のテストケースと隔離されていること

1つのテストが他のテストに影響を与えてはいけません。例としてDatabaseの読み書きがそれに該当することがあります。2つのテストで同じDatabaseを参照し読み書きしているとテスト間で影響を与えます。

1つのテストで何をするのか

1つのテストで1単位の振る舞いの確認を行います。複数のクラスの処理が動くことはOKです。

一つのテストケースで複数回評価するのは問題ありません。
準備>実行>評価>評価>評価
しかし、準備や実行を複数回行うのは理解が難しくなるので、なるべく避けた方が良いです。
準備>実行>評価>実行>評価
準備>実行>評価>準備>実行>評価

テスト名について

テスト名は振る舞いの形となります。

例えば
fun sendEmail(mailAddress: String?): Boolean
に対しての単体テストがあったとして、

mailAddress is null returns false
と書くのではなく、

fail to send email if email address is not specified
非開発者でも理解できる容易な英語の文章で書くと良いです。
テスト名で書くことは実装の詳細ではなく1単位の振る舞いです。

他の良い例も記載しておきますのでご参考までに

shoud not allow withdraws when balance is empty
銀行口座の残高が空の時は引き出しを許可すべきではない

実装の詳細を知っている質の悪い単体テストを見てみよう

単体テストは実装の詳細を知らないように作れると良いです。
ここでは質の悪い単体テストを見てみましょう。

例えばベースとなる数値に値を足すソッドがあったとします。
ベースはデータベースから取得します。
sum()を単体テストする場合どうなるでしょう?

class Number(private val database: Database){
    fun sum(num: Int): Int {
      return  database.loadBaseNumber() + 1
    }
}

Numberクラスはdatabaseに依存しているので
databaseをテストダブルに置き換える必要が出てきますよね?
例えばこんな感じでしょうか。

    val mockDatabase = mockk<Database> {
        every { loadBaseNumber() } returns 1
    }
    val result = Number(mockDatabase).sum(1)
    assertEquals(result, 2)

これは良くない単体テストです。
何故かというとNumberクラスがloadBaseNumberというメソッドを
使っているという実装の詳細を知っているからです。

例えばloadBaseNumberに引数が追加されたらどうなるでしょうか?

class Number(private val database: Database){
    fun sum(num: Int): Int {
      // loadBasenumberに引数が追加された
      return  database.loadBaseNumber(MyClass()) + 1
    }
}

テスト側も修正をしないとテストが失敗するようになります。
sumメソッドは期待通り動いていても、テストが失敗するのです。

    val mockDatabase = mockk<Database> {
        // 引数を修正しないとエラーになる
        every { loadBaseNumber() } returns 1
    }
    val result = Number(mockDatabase).sum(1)
    assertEquals(result, 2)

「プログラムは期待通り動くがテストがFailする」
実装の詳細を知っているテストはリファクタ耐性が低く壊れやすいのです。

では壊れにくいテストを作ってみましょう。
テストをする時に実装の詳細を知らなくて済むように設計するのです。

class Number(private val baseNumber: Int) {
    fun sum(num: Int): Int {
        return baseNumber + num
    }
}

databaseを使用しないようにしました。
コンストラクタでベースの値を渡すようにしています。
このベースの値は不変ですので値を変更することはできません。

ではテストを書いてみましょう。

val result = Number(1).sum(1)
assertEquals(result, 2)

先ほどmockを使用していましたが、それを使わなくなり完結にかけるようになりました。
テストは実装の詳細を知らないように設計しているので、sum()がリファクタされても
テストは壊れません。

良い単体テストを書くためのコツ

単体テストは3つの手法があります。

  • 戻り値を確認するテスト
  • 状態を確認するテスト
  • オブジェクト間のやり取りを確認するテスト

この中で「戻り値を確認するテスト」が最も作成・保守コストが低いです。
例えば状態を確認するテストは、戻り値を確認するテストに比べて
テストの実行前に準備が必要になるためコード量が多くなる傾向があります。

そのため戻り値の確認をするテストを多く作れるようにすることが理想です。
戻り値の確認をするテストを作る場合は、
数学的関数を作ることができると良い単体テストが作成できます。
数学的関数は引数が同じであれば戻り値は常に同じものを返すことが特徴です。

学校で習ったあの数学をイメージして下さい。
y = x + 1

こういうの見たことありますよね?
xを引数、yを戻り値と捉えた場合、xが常に同じならばyも常に同じ値になります。

さらに、
数学的関数は全ての入力と出力がメソッド名、引数、戻り値の型で構成されるメソッド・シグネチャに明示されています。

例えば2つの数字を掛け算する関数があるとします。

fun multiply(a: Int, b: Int): Int {
    return a * b
}

これは引数が同じであれば何度呼び出されても同じ戻り値を返す数学的関数です。
これを可変フィールドを見るようにするとどうなるでしょうか?

var b = 1

fun multiply(a: Int): Int {
    return a * b
}

「b」は可変なので引数が同じでも戻り値は常に同じとは限らないです。

数学的関数は全ての入力と出力がメソッド名、引数、戻り値の型で構成されるメソッド・シグネチャに明示されています。

この「b」というのはメソッド・シグネチャに明示されていない隠れた入力です。

つまり隠れた入出力がないように設計ができると、良い単体テストを作ることができるのです。

隠れた入出力は例えば以下を指します。

[隠れた入力]

  • 可変フィールドを参照する
  • Database、ファイル、サーバーから取得する
  • LocalDateTime.now()のような現在時刻取得

[隠れた出力]

  • 可変フィールドを更新する
  • Database、ファイル、サーバー情報を更新する
  • 例外をthrowする

実装をするときは、単体テストをした場合どうなるかを想像しながら進めると良いです。
隠れた入出力無くせているか意識してみてください。

ちなみに単体テストの対象は「全て隠れた入出力を無くすべき」と言っているわけではありません。
なるべく無くせるように設計しましょうというスタンスで十分です。

Mockの使い所

Mockは以下の「オブジェクト間のやり取りを確認するテスト」で使います。

単体テストは3つの手法があります。

  • 戻り値を確認するテスト
  • 状態を確認するテスト
  • オブジェクト間のやり取りを確認するテスト

ただし、単体テストではアプリ内のやり取りでMockを使うことは推奨されていません。単体試験で見るべきことはクライアントが達成しようとしていることに繋がる振る舞いだからです。

ではどこでMock使えばいいのでしょうか?

結論としてはアプリケーションが外部とやり取りするコミュニケーションに対してMockを使うと良いです。このMockは振る舞いに関与しているためです。

例えばそのインターフェースが適切に呼ばれるかどうかのテストで使用します。
外部アプリとの境界線のInterfaceでMockを使います。

// mockInterFace.sendEmailは外部アプリとの境界.
// ここでは1回sendEmailが呼ばれていることを確認している.
verify(exactly = 1) { mockInterFace.sendEmail("hoge@gmail.com", "おはよう") }

実際のサンプルコードを見てみよう

例えば以下仕様があるとします。

仕様

  • お気に入りのフルーツをDatabaseに登録できる
  • Databaseに登録できる件数は最大10件まで
  • 10件を超えた場合は1番古い1件を削除する

まず、Databaseの読み書きなどの隠れた入出力はFavoriteFruitControllerクラスで行います。
このクラスは単体テストの対象とはなりません。
FavoriteFruitクラスが単体テストの対象になります。

// このクラスは隠れた入出力を行う
class FavoriteFruitController {
    fun add(favorite: String) {
        val currentFavoriteNum = Database().loadFavoriteNum()
        val fileAction =
            FavoriteFruit(currentFavoriteNum)
                .update("フルーツファイル", favorite)
        when (fileAction.type) {
            ActionType.ADD -> {
                // Databaseの書き込みを行う.
            }

            ActionType.DELETE -> {
                // Databaseの削除を行う.
            }
        }
    }
}

FavoriteFruitクラスは単体テストの対象となるクラスです。
このクラスは戻り値で決定を返します。
コンストラクタに現在のお気に入り登録数がありますが不変です。
update()の引数に何を渡そうと戻り値は常に同じとなる数学的関数となります。

// このクラスは単体テストの対象となる.
class FavoriteFruit(private val currentFavoriteNum: Int) {
    private val maxFavoriteNum = 10

    fun update(currentFileName: String, favorite: String): FileAction {
        if (currentFavoriteNum <= maxFavoriteNum) {
            return FileAdd(currentFileName, favorite)
        } else {
            return FileDelete(currentFileName)
        }
    }
}

interface  FileAction {
    val type: ActionType
}

data class FileAdd(val fileName: String, val favorite: String): FileAction{
    override val type: ActionType = ActionType.ADD
}

data class FileDelete(val fileName: String): FileAction{
    override val type: ActionType = ActionType.DELETE
}

単体テスト

テスト対象には隠れた入出力がないので、準備フェーズでフィールドの更新やテスト・ダブルの用意が不要となリます。 実装の詳細を知らないのでリファクタ耐性が高く、保守コストが低いです。

val fileName = "フルーツファイル"
val favorite = "りんご"
val fileAction = FavoriteFruit(9)
    .update(fileName, favorite)
assertEquals(fileAction, FileAdd(fileName, favorite))

サンプルコードのような設計に持っていくことが難しいケースが存在する

例えばUserクラスが決定を返す際に、特定の条件の時にDatabaseの情報が必要な時があるとします。
サンプルコードでは特別なユーザーかーどうかを判定しています。

class User(private val userId: Int) {
    fun isSpecial(): Boolean {
        if (isPremiumUserId()) {
            if (database.loadCurrentSeason() == SUMMER) {

            }
        } else {

この場合、単体テストの対象はDatabaseに依存したくないので事前にDatabaseの値を取得して引数に渡す対応ができます。

class User(private val userId: Int) {
    // 事前にDatabaseの値を読み込んで引数に渡す.
    fun isSpecial(season: Season?): Boolean {
        if (isPremiumUserId()) {
            if (season == SUMMER) {

            }
        } else {

しかし、isPremiumUserId()がfalseの時にもDatabaseにアクセスするのでパフォーマンスが犠牲になります。

では、パフォーマンスを犠牲にしないやり方を考えてみましょう。
一案として、Controller側の処理にif文を持ってきて判定させます。

// 事前にDatabaseの値を読み込んで引数に渡す.
class UserController {
    val user = User()
    var season: Season? = null
    if(user.isPremiumUserId()){
        season = database.loadCurrentSeason()
    }
    user.isSpecial(season)

しかし、このやり方だとController側が複雑になります。
パフォーマンスの問題は解決しましたが、別の問題が出てくるのです。

結局のところ全ての問題を解決する万能なアーキテクチャは存在しません

このケースになった場合は選択を迫られます。

  • パフォーマンスを犠牲にする
  • Controller側に判定を持ってきてControllerを複雑にする
  • 単体テストの対象に隠れた入出力を入れる

ケースバイケースで対応するしかありません。

Discussion