LoginSignup
0
0

良い単体テストの作り方

Last updated at Posted at 2023-07-13

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

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

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

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

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

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

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

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

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

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

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

「観察可能」というのは目標を達成するために公開されたメソッド、状態です。つまりpublicなメソッドやpublicなフィールドのことです。
privateメソッドやprivateフィールドなど該当しないものは実装の詳細となります。
単体テストでは実装の詳細と紐づくほどリファクタ耐性が低くなりテストが壊れやすくなります。

publicのみテストすることが理想ですが、適切な設計になっていないとできません。
テストしようとしたら準備が多かったり、テスト内容が難解で理解しにくかったりする場合は

  • テスト対象が他クラスに対しての依存が多すぎる
  • テスト対象の処理が多すぎる、複雑すぎる

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

現場ではそういったリファクタする時間が取れないケースもあると思います。
そのため無理してpublicのみテストする運用にすると質の悪いテストが量産されます。
この状態になるぐらいであれば、テストを書かない方がマシかもしれません。publicのみのテストだと難しそうであれば、
まずはprivateをpublicにしてテストするでもありかと思います。

ちなみに、アプリと外部アプリとのInterfaceのテストで、そのInterfaceが呼ばれているかのテストを作成するのはありです。
何故なら外部アプリとのInterfaceはリファクタで変わることがないからです。

実行時間が短い

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

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

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

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

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

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

判断に迷いそうな、一つ具体例を出します。
「挨拶は有効である」という振る舞いを確認したいケースでテスト対象は以下であるとします。

class Greeting(val text: String) {
    fun isValid(): Boolean {
        return text == "おはよう" || text == "こんにちわ" ||
            text == "さようなら"
    }
}

有効であることをテストする場合はtextに「おはよう」「こんにちは」「さようなら」を入れて確認したいです。
このケースの場合はtextが「おはよう」のテストを1つ作り、「こんにちわ」のテストを1つ作り・・・ということはしなくて大丈夫です。
パラメーター化テストを検討しましょう。

@RunWith(Parameterized::class)
class ParameterizedTest(
    private val value: String,
) {

    @Test
    fun greeting_is_valid() {
        // プロパティに「おはよう」「こんにちわ」「さようなら」が次々と入る
        assertEquals(true, Greeting(value).isValid())
    }

    companion object {
        @Parameterized.Parameters
        @JvmStatic
        fun data(): List<String> {
            return listOf("おはよう","こんにちわ","さようなら")
        }
    }
}

この場合パラメーター化テストを作るのであれば有効なテキストと不正なテキストを一つのテストで確認するのではなく、分けるとわかりやすいでしょう。

良い単体テストができるように設計しよう

戦士クラスを例にします。
戦士クラスのパワーの仕様はベースパワーにレアリティを掛けた値です。
ベースパワーが2でレアリティが1なら戦士のパワーは2。
ベースパワーが2でレアリティが2なら戦士のパワーは4です。

class Warrior(private val database: Database){
    // 可変フィールド
    var rarity = 0
    fun getPower() = database.getBasePower * rarity
}

getPowerというメソッドの中では可変フィールドを参照して、かつDatabaseから読み取りもしています。
このメソッドを単体テストする場合はどのようにするでしょう?

まずテストの準備として

  • 可変フィールドに値を設定する
  • databaseをMockにする

といったことをするでしょう。これは実装詳細を知っているのでリファクタ耐性が低いと言えます。リファクタするとテストが壊れてしまいます。

例えばリファクタが発生して

class Warrior(private val database: Database){
    // 可変フィールド
    var rarity = 0
    fun getPower() = database.getWarriorBasePower * rarity
}

database.getWarriorBasePowerを使うようになった場合はどうでしょうか?Mockの設定を変更しないとテストがFailするでしょう。

「プログラムは期待通り動くがテストがFailする」

こういった状態が発生します。これは非常に良くない状態です。修正作業が発生することはもちろんですが、実際の現場の例として、
リファクタすると「嘘の警告」が出て最初は直していたが、多発し慣れていって、警告を無視したりテストを無効化して、
その結果重大なバグが製品に紛れ込んだという話があります。

テスト対象は隠れた入出力がない設計できると良い

メソッド・シグネチャから読み取れない隠れた入出力がないように設計ができると、とても良い単体テストを作ることができます。
隠れた入出力とは例えば以下を指します。

[隠れた入力]

  • 可変フィールドを参照する
  • Databaseを参照する

[隠れた出力]

  • 可変フィールドを更新する
  • Databaseを更新する
  • 例外をthrowする

テスト対象のメソッドが隠れた入出力をしない設計になっていると、リファクタ耐性が高く保守がしやすい単体テストが作成できます。

先ほどのgetPowerを例にします。隠れた入力をなくします。

class Warrior(private val basePower: Int, private val rarity: Int){
    fun getPower() = basePower * rarity
}

このメソッドをテストする場合、以下のようになります。

val warrior = Warrior(10,2)
assertEquals(20, warrior.getPower())

フィールドの設定、Mockの用意もなく実装の詳細を知らないのでリファクタ耐性に強いテストとなりました。
テストを作成するのも容易で、理解もしやすいです。

Mockの使い所

単体テストではアプリ内のやり取りでMockを使うことは推奨されていません。リファクタで変わるからです。
リファクタで変わることのない外部アプリとの境界線のInterfaceのみMockを使うのはOKです。
例えばそのインターフェースが適切に呼ばれるかどうかのテストで使用します。

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

書籍ではそのように紹介されていますが、アプリ内のやり取りでMockを禁止にすることを現場で強制するのはやめた方がいいです。
何故なら実現するためには根本的に設計を見直すところから始めないと厳しいケースがあるからです。
なので、これも同じで可能な限り目指しましょうが良いでしょう。

テスト名について

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

テスト名は実装の詳細を書くのではなく、例えば
fun sendEmail(mailAddress: String?): Boolean
に対しての単体テストがあったとして、

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

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

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

サンプルコードを極力簡単にして理解できることを目的としているので不自然ですが、以下仕様があるとします。

仕様

  • ユーザーはお気に入りの音楽を登録できる
  • 一度のお気に入りの登録で最大XX個のお気に入りを登録できる、XX個を超えた場合は何もしない
  • お気に入り登録はdatabaseに保存され、レコード数がXX件を超えると削除される

リファクタ前

class FavoriteMusic(
    private val maxFavoriteNum: Int,
    private val maxRecordIndex: Int,
    private val database: Database
) {
    fun addFavorite(favoriteList: List<String>) {
        val recordIndex = database.loadRecordIndex() + 1
        when {
            // お気に入り登録最大件数を超えたら何もしない.
            favoriteList.size > maxFavoriteNum -> return
            // 最大レコード数を超えたら削除.
            recordIndex > maxRecordIndex -> database.delete()
            // 新しいファイルにお気に入りを登録.
            else -> database.insert("file_name_$recordIndex", favoriteList)
        }
    }
}

このaddFavorieをテストする場合、databaseをMockにするので実装の詳細に依存します。
評価する場合、Mockのメソッドが呼ばれたかどうかのテストになり、これも良くありません。
何度も言いますが実装の詳細と紐づき過ぎているのです。これを良い単体テストができるように設計を見直します。

リファクタ後

// 各クラスの連携を指揮するクラス. Managerという名前は仮で、
// ServiveとかControllerとかプロジェクトごとでどんな名前にするべきか
// 相談すると良さそう.
class FavoriteMusicManager(
    val database: Database,
    val persister: Persister,
    val maxFavoriteNum: Int
) {

    fun addFavorite(favoriteList: List<String>) {
        val recordIndex = database.loadRecordIndex() + 1
        val fileUpdate = FavoriteMusic(maxFavoriteNum, 10).add(favoriteList, recordIndex)
        persister.update(fileUpdate)
    }
}

// お気に入りの更新に対しての決定を下すクラス.
// ここが単体テストの対象となる.
class FavoriteMusic(private val maxFavoriteNum: Int, private val maxRecordIndex: Int) {
    fun add(favoriteList: List<String>, recordIndex: Int): FileUpdate {
        val fileUpdate = when {
            // お気に入り登録最大件数を超えたら何もしない.
            favoriteList.size > maxFavoriteNum -> FileUpdate(FileUpdate.Command.NONE)
            // 最大レコード数を超えたら削除.
            recordIndex > maxRecordIndex -> FileUpdate(FileUpdate.Command.DELETE)
            // お気に入りを登録.
            else -> FileUpdate(FileUpdate.Command.INSERT, "file_name_$recordIndex", favoriteList)
        }
        return fileUpdate
    }
}

data class FileUpdate(
    val command: Command,
    val fileName: String? = null,
    val favoriteList: List<String> = emptyList()
) {
    enum class Command {
        INSERT,
        DELETE,
        NONE,
    }
}

// 決定に従ってファイルを更新するクラス.
class Persister(database: Database) {
    fun update(fileUpdate: FileUpdate) {
        if (fileUpdate.command == FileUpdate.Command.INSERT) {
            database.insert(fileUpdate.fileName, fileUpdate.favoriteList)
        } else if (fileUpdate.command == FileUpdate.Command.DELETE) {
            database.delete()
        }
    }
}


単体テスト

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

    @Test
    // 保存するレコードの上限に達したらファイルを削除する.
    fun delete_when_the_number_of_record_overflows(){
        val maxRecordIndex = 100
        val favoriteMusic = FavoriteMusic(100, maxRecordIndex)
        val fileUpdate = favoriteMusic.add(listOf("musicA, musicB, musicC"), maxRecordIndex + 1)
        Assert.assertEquals(FileUpdate(FileUpdate.Command.DELETE), fileUpdate)
    }

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

例えば
お気に入り登録はdatabaseに保存され、レコード数がXX件を超えると削除される

お気に入り登録はdatabaseに保存され、レコード数がXX件を超えると削除される。ただし、課金ユーザーでかつ日本の場合は保存できる
というように仕様変更があったとします。

国というのはDatabaseから取得できます。
この仕様の場合、ファイル更新の決定をする途中でDatabaseアクセスが発生します。

recordIndex > maxRecordIndex ->
    if (isPayingUser) {
        val country = database.loadCountry()
        if(country == JP){
            FileUpdate(FileUpdate.Command.INSERT)
        } else {
            FileUpdate(FileUpdate.Command.DELETE)
        }
     } else {
         FileUpdate(FileUpdate.Command.DELETE)
     }

単体テスト対象の中ではDatabaseのように隠れた入出力はしたくありません。
これを解決するとなると、database.loadCountry()という処理はFavoriteMusic#addFavoriteを呼ぶ前に取得して引数に渡すなどが考えられます。
しかし、isPayingUserがfalseの場合はDatabaseアクセスが必要ないのでパフォーマンスを犠牲にすることになります。

これを避けたいのであれば呼び元であるFavoriteMusicManagerで課金ユーザーであれば会員タイプを事前に取得して
FavoriteMusic#addの引数に会員タイプを渡してあげる方法があります。しかし、呼び元にロジックを書くことになるのでロジックが散らばります。

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

このケースになった場合は選択を迫られます。
パフォーマンスを犠牲にするのか、ロジックが散らばるのか、テストを犠牲にするのか。
ケースバイケースで対応するしかありません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0