TL;DR: PHPUnit の遅いテストを ergebnis/phpunit-slow-test-detector で可視化して、テストの並列化やジョブ分割の指標にしよう。

ergebnis/phpunit-slow-test-detector: 設定した閾値を超えた遅いテストを検出して報告する PHPUnit 拡張


どのテストが CI を遅くしているのか分からない問題

CI のテストが遅い。だんだん遅くなってきて、プッシュするたびに待たされるのってだるいですよね。

でも、いざ速くしようと思っても「全体で5分かかる」までは分かるのに、「じゃあそのうちどのテストが足を引っ張ってるの?」がパッと出てこない。なんとなく「DBを触ってるテストが怪しいかな」くらいの当たりはつくものの、ちゃんと測ったわけじゃない。

闇雲に並列実行(paratest など)を入れても、遅いテストがどこに偏っているか分からなければ、ジョブの分け方も決まりません。まずは遅いテストを数字で可視化するのが先決です。

そこで使えるのが ergebnis/phpunit-slow-test-detector です。

phpunit-slow-test-detector とは

PHPUnit の実行時に、設定した閾値(しきい値)を超えた「遅いテスト」を検出して、実行後に一覧で報告してくれる拡張機能です。johnkary/phpunit-speedtrap にインスパイアされた実装で、ライセンスは MIT です。

特徴をざっくり挙げると、

  • 閾値ベースの検出 - 「○○ミリ秒を超えたら遅い」という基準でテストを拾う
  • 遅いテストのランキング表示 - 遅い順に件数を絞って一覧表示してくれる
  • テストごとの閾値上書き - 「このテストは重いのが当たり前」というケースを個別に許容できる
  • 幅広い対応バージョン - PHP 7.0〜8.5、PHPUnit 6.5〜13.0 と守備範囲が広い

CI に常設しておくと、テストが遅くなってきたタイミングで気づけるのが嬉しいところです。

インストールと設定

開発依存として入れます。

composer require --dev ergebnis/phpunit-slow-test-detector

PHPUnit 10〜13 系では、phpunit.xml の <extensions> に bootstrap として登録します。

<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
>
    <extensions>
        <bootstrap class="Ergebnis\PHPUnit\SlowTestDetector\Extension">
            <parameter name="maximum-count" value="3"/>
            <parameter name="maximum-duration" value="250"/>
        </bootstrap>
    </extensions>
    <testsuites>
        <testsuite name="unit">
            <directory>test/Unit/</directory>
        </testsuite>
    </testsuites>
</phpunit>

設定できるパラメータはシンプルです。

  • maximum-duration - テストが「遅い」と判定される閾値(ミリ秒)。デフォルトは 500
  • maximum-count - 報告する遅いテストの最大件数。デフォルトは 10

最初は maximum-duration をデフォルトの 500(0.5秒)あたりから始めて、自分のプロジェクトの実態に合わせて下げていくのがおすすめです。

※ PHPUnit 9 以前は <extension> 要素+ <arguments> での登録になります。バージョンごとの書き方は公式 README を参照してください。

実行結果を読む

テストを実行すると、終了時に遅いテストの一覧が出力されます。

Detected 11 tests where the duration exceeded the global maximum duration (0.500).

 # Duration Test
------------------------------------------------------------------------------------
 1    1.604 Ergebnis\PHPUnit\SlowTestDetector\Test\EndToEnd\Default\SleeperTest
       ::testSleeperSleepsLongerThanDefaultMaximumDurationWithDataProvider#9
 2    1.505 Ergebnis\PHPUnit\SlowTestDetector\Test\EndToEnd\Default\SleeperTest
       ::testSleeperSleepsLongerThanDefaultMaximumDurationWithDataProvider#8
       ...
------------------------------------------------------------------------------------

見方はシンプルで、順位・実行時間(秒)・テストのクラス名::メソッド名が並びます。冒頭の Detected 11 tests ... で「閾値(この例では 0.500 秒)を超えたテストが11件あった」ことも分かります。

これで「遅いテストの正体」が一目で分かります。あとはこのランキングを上から潰していくなり、CI のジョブ分割に活かすなりできます。

個別のテストで閾値を上書きする

とはいえ、「このテストは外部 API を叩くから遅くて当然」というケースもありますよね。そういうテストまで毎回ランキングに載ってしまうとノイズになります。

PHPUnit 10〜13 系なら、アトリビュートでテストごとに閾値を上書きできます。

use Ergebnis\PHPUnit\SlowTestDetector;

#[SlowTestDetector\Attribute\MaximumDuration(5000)]
public function testExtraExtraSlow(): void
{
    // このテストは 5000 ミリ秒(5秒)まで許容する
    // ...
}

こうしておけば、5秒を超えない限りこのテストはランキングに載りません。「許容する遅さ」を意図的に宣言しておくことで、本当に問題のある遅さだけが浮き上がります。

CI 効率化の指標として使う

ここが本題です。遅いテストが可視化できると、CI を速くするための打ち手が具体的に決まります。

1. 遅いテストを別スイートに分離する

ランキング上位の遅いテスト(DB アクセスや外部通信を伴うものが多いはず)を Integration や EndToEnd のスイートに切り分け、速い Unit スイートと分けます。

<testsuites>
    <testsuite name="unit">
        <directory>test/Unit/</directory>
    </testsuite>
    <testsuite name="integration">
        <directory>test/Integration/</directory>
    </testsuite>
</testsuites>

CI 上では速い unit を先に回して即フィードバックを得て、遅い integration は別ジョブで並行して回す、といった構成が組めます。どのテストをどちらに振り分けるかの判断材料として、検出された遅いテスト一覧がそのまま使えます。

2. 並列実行のジョブ分割の指標にする

paratest は空いたプロセスにテストクラス単位でテストを割り振っていくので、どのプロセスで何を動かすかは自動で決めてくれます。ただ、遅いテストが1つのクラスに固まっていると、そのクラスを受け持ったプロセスだけがいつまでも終わらず、全体の実行時間はその「一番遅いプロセス」で決まってしまいます。遅いテスト一覧でそういうクラスを見つけたら、クラスを分割するか、テストメソッド単位で割り振る --functional オプションを試すと効いてきます。

CI のジョブ自体を複数に分ける場合も同じで、遅いテストがどこにあるか分かっていれば、ジョブごとの実行時間が均等になるように振り分けられます。

3. 閾値を「許容ライン」として CI に常設し、退行を検知する

maximum-duration をプロジェクトとして許容できるラインに設定し、CI に置きっぱなしにしておきます。新しく追加されたテストや、いつの間にか重くなったテストが閾値を超えると一覧に現れるので、テストの遅さの退行(リグレッション)に気づくセンサーとして機能します。

「速くする」だけでなく「遅くなったことに気づく」仕組みとして CI に組み込んでおくと、テストスイートの健全性を保ちやすくなります。

依存を増やしたくないなら JUnit ログでもできる(けど面倒)

ちなみに「Composer の依存をこれ以上増やしたくない」という場合、専用の拡張を入れなくても遅いテストは調べられます。PHPUnit には JUnit 形式でログを出力するオプションがあるので、その time 属性をパースすれば済む話ではあります。

# JUnit 形式でテスト結果を出力
vendor/bin/phpunit --log-junit junit.xml

出力された junit.xml の各 <testcase> には実行時間(秒)が time 属性で入っているので、降順に並べれば遅い順のランキングが作れます。

// junit.xml をパースして遅いテスト上位10件を出す
$xml = simplexml_load_file('junit.xml');
$durations = [];
foreach ($xml->xpath('//testcase') as $case) {
    $key = (string) $case['classname'] . '::' . (string) $case['name'];
    $durations[$key] = (float) $case['time'];
}
arsort($durations);
foreach (array_slice($durations, 0, 10, true) as $name => $time) {
    printf("%6.3f  %s\n", $time, $name);
}

…と、やればできるんですが、閾値での絞り込み・出力の整形・「このテストは重くて当然」の個別許容みたいなものを全部自前で抱え始めると、地味に面倒です。結局 phpunit-slow-test-detector はこのあたりを最初から面倒みてくれるので、サッと済ませたいなら拡張を入れたほうが早い、という温度感ですね。

参考リンク