テストコードの見やすさと品質について考える

:::note info
この記事は、[LITALICO Advent Calendar 2024](https://qiita.com/advent-calendar/2024/litalico) シリーズ4の14日目の記事です。
:::

# はじめに
この記事は、筆者がテストコードの見やすさと品質について考えてみた内容について書いています。

:::note warn
「見やすさ」は人それぞれ異なります。本記事では筆者個人の主観が多く含まれています。
:::

本記事作成に使用した言語およびフレームワークは以下の通りです。これらは筆者が普段使用しているものであり、特定のライブラリに依存する内容ではありません。
- PHP: 8.3
- Laravel: 11.34
- PHPUnit: 11.5

# テスト対象のコード
### 仕様
以下の仕様を満たすユーザー一覧を返却するコードを対象とします。

1. IDが奇数かつ名字の先頭がア行のユーザーのみを返却する。
2. 並び順はメールアドレスのドメイン部を昇順で、ドメイン部が同じ場合はIDを降順で並べる。

### 実装例
以下はPHP（Laravel）で実装した例です。
namespaceやuseは省略しています。
Userクラスは一般的なフィールド（ID、氏名、メールアドレスなど）を持つモデルクラスを想定してください。

```php
class GetUsersUseCase
{
    public function __construct(
        private readonly UserRepository $userRepository,
    ) {
    }

    /**
     * @return Collection<User>
     */
    public function execute(): Collection
    {
        $allUsers = $this->userRepository->findAll();
        return $allUsers->filter(static function (User $user) {
            // IDが偶数のユーザーは除外する
            if ($user->id % 2 === 0) {
                return false;
            }
            // 先頭の文字がア行のユーザーのみを抽出
            $first = mb_substr($user->lastNameKana, 0, 1);
            return in_array($first, ['ア', 'イ', 'ウ', 'エ', 'オ'], true);
        })->sort(static function (User $a, User $b) {
            // メールアドレスのドメイン部分で比較
            $i = $a->getMailAddressDomain() <=> $b->getMailAddressDomain();
            if ($i !== 0) {
                return $i;
            }
            // ドメイン部が同じならIDの降順とする
            return $b->id <=> $a->id;
        })->values();
    }
}
```

# テストコードを書く
以下のテストコードでは、準備（Arrange）、実行（Act）、確認（Assert）の3つのフェーズで構成するAAA（Arrange-Act-Assert）パターンを採用しています。
本題ではないので詳細は割愛しますが、各フェーズの文字通り、準備・実行・確認のフェーズに分けて記載する、テストコードを読みやすくするためのパターンの1つです。

## ①戻り値の全体を検証する
以下の例は、1つのテストケースに全ての確認ポイントをまとめたものです。
```php
class GetUsersTest extends TestCase
{
    #[Test]
    public function ユーザーの一覧が取得できる()
    {
        // Arrange
        $user1 = new User(
            id: 1,
            lastNameKana: 'イノウエ',
            mailAddress: 'inoue@example.org',
        );
        $user2 = new User(
            id: 2, // IDが偶数なので除外される
            lastNameKana: 'アオキ',
            mailAddress: 'aoki@example.net',
        );
        $user3 = new User(
            id: 3,
            lastNameKana: 'サトウ', // 先頭がア行じゃないで除外される
            mailAddress: 'sato@example.com',
        );
        $user4 = new User(
            id: 5,
            lastNameKana: 'エンドウ',
            mailAddress: 'endo@example.net',
        );
        $user5 = new User(
            id: 7,
            lastNameKana: 'ウエノ',
            mailAddress: 'ueno@example.org',
        );

        $expected = new Collection([$user4, $user5, $user1]);

        $userRepository = $this->mock(UserRepository::class);
        $userRepository->shouldReceive('findAll')
            ->andReturn(new Collection([$user1, $user2, $user3, $user4, $user5]));
        $useCase = app(GetUsersUseCase::class);

        // Act
        $actual = $useCase->execute();

        // Assert
        self::assertEquals($expected, $actual);
    }
}
```
確かに仕様通りに動作することが検証できている、、、気がします。
しかし、具体的にどんなポイントを検証しているかはわかりにくいと感じます。
また、上記ではUserクラスのプロパティが3個なのでまだ把握できる範囲内ですが、たとえばプロパティが10個あったらどうでしょうか。
テストデータの準備部分だけでも見通しが悪くなってしまいそうです。

## ②テストケース名で確認ポイント（≒仕様）がわかるようにする
以下のようにテストケース名に確認ポイントを含めることで、何を検証しているのかを明確にします。

```php
class GetUsersTest extends TestCase
{
    #[Test]
    public function ユーザーの一覧が取得できる｜IDが偶数のユーザーは除外される()
    {
    }

    #[Test]
    public function ユーザーの一覧が取得できる｜名字がア行でないユーザーは除外される()
    {
    }

    #[Test]
    public function ユーザーの一覧が取得できる｜並び順はメールアドレスのドメイン部の昇順→IDの降順となる()
    {
    }
}
```

### 実装例
各テストケースを実装すると、たとえば以下のようにできます。

```php
class GetUsersTest extends TestCase
{

    private function setupMock(Collection $users): void
    {
        $userRepository = $this->mock(UserRepository::class);
        $userRepository->shouldReceive('findAll')
            ->andReturn($users);
    }

    #[Test]
    public function ユーザーの一覧が取得できる｜IDが偶数のユーザーは除外される()
    {
        // Arrange
        $userCreator = static fn (int $id) => new User(
            id: $id,
            lastNameKana: 'アオキ',
            mailAddress: 'aoki@example.com',
        );

        $user1 = $userCreator(1);
        $user2 = $userCreator(2); // 除外対象
        $user3 = $userCreator(3);

        $this->setupMock(new Collection([$user1, $user2, $user3]));
        $useCase = app(GetUsersUseCase::class);

        // Act
        $actual = $useCase->execute();

        // Assert
        self::assertNotContains(2, $actual->pluck('id'));
    }

    #[Test]
    public function ユーザーの一覧が取得できる｜名字がア行でないユーザーは除外される()
    {
        // Arrange
        $faker = fake();
        $userCreator = static fn (string $lastNameKana) => new User(
            id: $faker->unique()->numberBetween(1, 100) * 2 + 1, // 奇数にする
            lastNameKana: $lastNameKana,
            mailAddress: $faker->safeEmail(),
        );

        $user1 = $userCreator('イノウエ');
        $user2 = $userCreator('アオキ');
        $user3 = $userCreator('サトウ'); // 除外対象

        $this->setupMock(new Collection([$user1, $user2, $user3]));
        $useCase = app(GetUsersUseCase::class);

        // Act
        $actual = $useCase->execute();

        // Assert
        self::assertEqualsCanonicalizing(
            expected: ['イノウエ', 'アオキ'], // 並び順は問わずに確認
            actual: $actual->pluck('lastNameKana')->toArray(),
        );
    }

    #[Test]
    public function ユーザーの一覧が取得できる｜並び順はメールアドレスのドメイン部の昇順→IDの降順となる()
    {
        // Arrange
        $userCreator = static fn (int $id, string $domain) => new User(
            id: $id,
            lastNameKana: 'アオキ',
            mailAddress: 'hello@' . $domain,
        );

        $user1 = $userCreator(1, 'example.org');
        $user2 = $userCreator(3, 'example.net');
        $user3 = $userCreator(5, 'example.org');

        $this->setupMock(new Collection([$user1, $user2, $user3]));
        $useCase = app(GetUsersUseCase::class);

        // Act
        $actual = $useCase->execute();

        // Assert
        self::assertSame(
            expected: [3, 5, 1],
            actual: $actual->pluck('id')->toArray(),
        );
    }
}
```

テストケースを分割したことで、各テストの意図が明確になりました。
また、テストクラス全体が長くなったとしても、各ケースが簡潔であるため、確認ポイントは理解しやすいと考えます。

## おまけ：③データプロバイダーを使う
最初にライブラリの制限はないと言いつつ、PHPUnitにはデータプロバイダーという仕組みがあり、これを使うと便利な場合もあります。
以下は除外対象のテストにデータプロバイダーを利用した例です。

```php
class GetUsersTest extends TestCase
{
    #[Test]
    #[DataProvider('ユーザー一覧の除外パターン')]
    public function ユーザーの一覧が取得できる｜除外パターン(Collection $users, array $expectedIds): void
    {
        // Arrange
        $userRepository = $this->mock(UserRepository::class);
        $userRepository->shouldReceive('findAll')
            ->andReturn($users);
        $useCase = app(GetUsersUseCase::class);

        // Act
        $actual = $useCase->execute();

        // Assert
        self::assertEqualsCanonicalizing(
            expected: $expectedIds,
            actual: $actual->pluck('id')->toArray(),
        );
    }

    public static function ユーザー一覧の除外パターン(): array
    {
        return [
            'IDが偶数のユーザーは除外される' => [
                'users' => new Collection([
                    new User(1, 'アオキ', 'aoki@example.com'),
                    new User(2, 'アオキ', 'aoki@example.com'), // 除外対象
                    new User(3, 'アオキ', 'aoki@example.com'),
                ]),
                'expectedIds' => [1, 3],
            ],
            '名字がア行でないユーザーは除外される' => [
                'users' => new Collection([
                    new User(1, 'アオキ', 'aoki@example.com'),
                    new User(3, 'サトウ', 'sato@example.com'), // 除外対象
                    new User(5, 'イノウエ', 'inoue@example.com'),
                ]),
                'expectedIds' => [1, 5],
            ],
        ];
    }
}
```
いい感じに見えます。

注意点を挙げるとするならば、①と同様にプロパティの数が多い場合に見通しが悪くなる可能性があります。

# ところで
これまでの内容に基づくと、①よりも②（あるいは③）の方が優れているように感じられるかもしれませんが、一概にそうとは言い切れません。

## ②の落とし穴
気付いたでしょうか。
テスト対象のコード（`GetUsersUseCase`）の処理において、一番最後の `->values()` [^1] を書いていなかったとします。
この場合、①のテストは失敗しますが、②のテストは成功してしまいます。

確かに、`GetUsersUseCase`の仕様には、「返却値のキーは0からの連番であること」などという記載はありません。
しかし、実際には連番でない場合はJSONに変換した際に配列にならずオブジェクトになってしまうなど、問題が出てしまうのも事実です。[^2]

①の場合、確認ポイントを細かく分けずに戻り値全体を比較することにより、本人が気づいているか否かに関わらず、結果的にキーが期待通りになっていないことに気づけるタイミングが生まれていたのです。

# さいごに
テストコードは、確認ポイントを細かく分割することで見やすさが上がる一方、全体の挙動を見落としやすいというリスクもあります。

何を重視するかはプロジェクトやチームの状況、機能の特性などによって変わってきます。
上記で紹介した書き方にはそれぞれにメリットとデメリットがあり、状況に応じて適切なアプローチを選択することが重要です。

本記事が少しでもその参考になれば幸いです。

[^1]: キーを0からの連番に振り直してくれるCollectionのメソッド。今回は `filter` によって歯抜けになったキーの歯抜けをなくすために書く。
[^2]: たとえば、 `[0 => 'a', 1 => 'b']` を `json_encode()` すると `["a","b"]` だが、 `[0 => 'a', 2 => 'b']` は `{"0":"a","2":"b"}` となる。