「テストケース間でデータを通して依存関係が発生する問題」を解消した話

こんにちは。エンジニアの濱田 (@hamakou108) です。

エンジニアの皆さん、テストコード書いてますか?

M&Aクラウドのエンジニアは呼吸をするようにテストを書くことが推奨されます。しかし時にはテストが書きにくい、テストが脆いといった状況に直面し、呼吸困難になることもしばしばです。今回はM&Aクラウドで度々そのような症状を引き起こしていた、「テストケース間でデータを通して依存関係が発生する問題」を解消した話をしたいと思います。

TL;DR

  • テストケース間でデータを通して依存関係が発生すると、テストの有効性が低下する
  • Laravel では DatabaseTransactions トレイトを使うことで、テストケースごとにトランザクションが張られ、テスト終了時にロールバックが実行されるようになる

背景

M&Aクラウドでは各ルーティングに対して Feature Test を作成し、そのパスへのリクエストが成功することを必ずテストするようにしています。このテストではテスト用の RDB に接続しており、テストケースによってはデータの追加や更新などを伴います。従来の Feature Test では、このように変更された RDB の状態が後続のテストケースへそのまま引き継がれるような仕組みになっていました。

テストケース間のデータの依存関係が引き起こす問題

変更後の RDB の状態が後続のテストケースへ引き継がれるということは、テストスイート中のテストケースは、それまでに実行されたすべてのテストケースが引き起こす変更の影響を受けた RDB の状態に依存することになります。これがテストケース間に暗黙の依存関係を生じさせ、幾つかの問題を引き起こしていました。

例えば、次のようなテストケースがあったとします。

<?php

class IndexControllerTest extends FeatureTest
{
    public function testDeleteUser()
    {
        $this->post('/users/1/delete')
            ->assertStatus(302)
            ->assertRedirect('/users');
    }
}

このテストケース testDeleteUser ではユーザー削除の POST リクエストが成功することを検証しています。このテストケースの終了時には ID が 1 のユーザーのレコードは削除されており、その状態は後続のテストケースに引き継がれます。このとき、次のような問題が発生する可能性があります。

  • このテストケースを単体で実行して、テストが成功したとします。しかし続けてもう一度テストを実行すると、テストは失敗します。2回目のテスト実行時には ID が 1 のユーザーのレコードは存在しないためです。
  • ID が 1 のユーザーのレコードに依存する別のテストケースがあったとします。個別にテストケースを実行してテストが成功したとしても、テストスイートの中でそのテストケースが成功するとは限りません。このテストケースよりも先に testDeleteUser が実行された場合、依存先のレコードが存在しない状態でテストが実行されるためです。
  • リファクタリングによってテストクラスの名前空間が変更された場合にもテストケースが失敗する可能性があります。 PHPUnit の場合は名前空間を含むテストクラス名のアルファベット順でテストケースが実行されるため、名前空間やクラス名が変更されると、テストケースの実行順序も変更されます。例えば IndexControllerTestDeleteControllerTest のようなクラス名に変更した場合、テストの実行順が前の方に移動するため、元の実行順序によって担保されていたデータの整合性が崩れてしまう可能性があります。
  • 上記とは逆に、コードの内容に問題があるのにたまたまテストが成功してしまう可能性もあります。例えば、他のテストケースで ID が 2 のユーザーが存在しないことを検証しようとしたものの、誤って ID が 1 のユーザーが存在しないことを検証するようなコードを書いてしまったとします。本来はテストが失敗してほしい場面ですが、テストスイートの中で testDeleteUser が先に実行されていた場合、そのテストケースは成功します。

テストの有効性との関係

このような問題はテストの有効性を低下させます。「単体テストの考え方/使い方」によると、良い単体テストは次の4つの性質を備えています。

  • 退行 (regression) に対する保護
  • リファクタリングへの耐性
  • 迅速なフィードバック
  • 保守のしやすさ

コードに問題がないのにテストが失敗する (偽陽性) と、リファクタリングへの耐性が低下します。逆にコードに問題があるのにテストが成功する (偽陰性) と、退行に対する保護が欠落します。テストケース間のデータの依存関係はこういった偽陽性偽陰性の問題を引き起こします。

また各テストケースが他のテストケースに依存することから、テストの実行時間が長くなってもテストスイートを分割することは困難になり、迅速なフィードバックに悪影響を与えます。さらにテストケース実行時の RDB の状態が不明瞭であるため、テスト失敗時にどこを修正すれば良いかも分かりにくくなり、テストの保守のしやすさも低下します。テストスイートが大きくなるほど、この問題は重くのしかかります。

テストケースごとにトランザクションロールバックする

根っこにある問題はテストケースで変更された RDB の状態がそのまま次のテストケースに引き継がれることです。そのため、テストケースごとにトランザクションを張り、テスト終了時にロールバックされるようにテストの仕組みを変更しました。

DatabaseTransactions トレイトの使用

テストクラスで Illuminate\Foundation\Testing\DatabaseTransactions トレイトを使用することで、クラス内の各テストケース開始前にトランザクションが張られ、テスト終了時にロールバックされるようになります。

Feature Test の共通の親クラスを作成してトレイトを使用することですべてのテストに適用できますが、そのまま適用すると今までテストスイートを維持していた暗黙の依存関係が崩れ、テストが大量に失敗してしまう可能性があります。そのため今回はテストスイートの単位で改修を行い、一度に修正するテストの量を小さくすることにしました。様々な方法が考えられると思いますが、今回は機能フラグを利用することにしました。M&Aクラウドの機能フラグの仕組みについては次の記事をご覧ください。

tech.macloud.jp

DatabaseTransactions トレイトを使用すると、テストクラスの大元の setup() メソッド内で beginDatabaseTransaction() メソッドが呼ばれるようになっており、これがトランザクションの開始およびテスト終了時のロールバック実行の仕込みを行います。このメソッドを共通親クラス (BaseFeatureTest) でオーバーライドし、機能フラグによって親メソッドを呼び出すかどうか条件分岐させます。

<?php

class BaseFeatureTest extends TestCase
{
    use DatabaseTransactions
    {
        beginDatabaseTransaction as protected parentBeginDatabaseTransaction;
    }

    public function beginDatabaseTransaction()
    {
        if (\FeatureFlagManager::isEliminateDependenciesInFeatureTest()) {
            $this->parentBeginDatabaseTransaction();
        }
    }
}

そして各テストスイートの一番最初に実行されるダミーのテストケースを作成し、その中で機能フラグを ON にします。

<?php

namespace Tests\Feature\Service\A;

class ATest extends BaseFeatureTest
{
    public function testA(): void
    {
        // 機能フラグを ON にする
        \DB::table('feature_flags')->insert([
            'name' => '2023_07_12_eliminate_dependencies_in_feature_test',
            'is_enabled' => true,
            'created_at' => Carbon::now(),
            'updated_at' => Carbon::now(),
        ]);

        $this->assertTrue(true);
    }
}

M&Aクラウドでは機能フラグを RDB で管理しているため、 testA では機能フラグを ON にするためのレコードを作成しています。 testA の開始時点では機能フラグが OFF になっているため、 parentBeginDatabaseTransaction は呼ばれず、トランザクションロールバックされません。後続のテストケースでは機能フラグが ON になっているため、テスト終了時にロールバックが実行されます。

トレードオフ

この方針を採用した場合のトレードオフについても触れておきます。

トランザクションを閉じる操作は原則行うことができない

テストの途中でトランザクションを閉じてしまうと、せっかく DatabaseTransactions を使っているのに RDB への変更がそのまま残ってしまう可能性があります。このため、トランザクションを無効にするようなテストは原則書くことはできません。

特に注意が必要なのが truncate() メソッドです。 MySQL ではトランザクション中に truncate() メソッドを実行すると暗黙的にコミットが行われるため、後続のテストケースではそのテーブルのレコードが削除された状態になってしまいます。

qiita.com

RDB の Auto Increment のカウントはリセットされない

DatabaseTransactions によってロールバックは実行されますが、 RDB の Auto Increment のカウントはリセットされません。そのため Auto Increment の ID を持つレコードの追加を伴う処理のテストを実行する場合、追加されるレコードの ID の値はテスト実行のたびに変わります。したがって Auto Increment の ID の値をハードコーディングするようなテストを書かないように注意する必要があります。

最後に

M&Aクラウドでは一緒にプロダクト開発してくれるメンバーを募集しています!カジュアル面談はもちろん、社内メンバーと軽くご飯に行くだけでも構いませんので、気になる方はぜひ Twitter DM や Wantedly 等でご連絡ください!

sg.wantedly.com