13
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Lancers(ランサーズ)Advent Calendar 2024

Day 19

PHPUnitの実行パフォーマンスを7倍にした

Last updated at Posted at 2024-12-19

本記事は、Lancers(ランサーズ) Advent Calendar 2024 の19日目の記事です。

PHPUnitがいくらなんでも遅すぎる

弊社ではGitHub ActionsでCI実行をしています。
これが毎回42分も掛かっており、開発体験の悪さがエンジニアの間でも悩みの種でした。
なんとか早くしようと、有志達でリファクタリングしまくって改善したわけです。
速さは正義。

最終結果

CI実行時間が42分から6分への改善に成功しています。
当初は10分を目指してましたが、それを上回る結果が出せて本当に気持ち良かった。
スクリーンショット 2024-12-19 午後01.10.11 午前.png

遅くなっていた原因は何か?

読み込むfixtureの量が膨大だった

10年以上サービスを継続しており、様々な機能をユーザに提供しています。
その結果、読み込むfixtureが膨大になってしまいました。
各テスト間でこれだけの数を読み込むわけですから、そりゃ遅くなるわけです。

/**
 * @var array
 */
public $fixtures = [
    'app.a',
    'app.b',
    'app.c',
    'app.d',
    'app.e',
    'app.f',
    'app.g',
    'app.h',
    'app.i',
    'app.j',
    'app.k',
    'app.l',
    'app.m',
    'app.n',
];

テスト間の依存関係が影響して分割できない

TestA、TestB、TestC、TestD...と順次テスト実行させていました。
これをTestA、TestDと実行させるようにすると、なんと失敗するようになります。

なぜ、このような事態が起きるのか調べていくと、キャッシュが後続のテストに影響を与えていることに気付きました。PHPUnitで順次テストを実行しているため、意図しないキャッシュが残存し、想定と異なる結果を返してテストが失敗していたのです。

class XxxRepository
{
    private $cache = [];

    public function get($id)
    {
        // キャッシュに存在すれば打ち返す
        if (isset($this->cache[$id])) {
            return $this->cache[$id];
        }
    
        $data = $this->loadModel('Table')
            ->findById($id);
    
        // キャッシュ
        $this->cache[$id] = $data;
    
        return $data;
    }
}

以上を踏まえ、まずはテスト単体を速くし、テスト間の依存関係を解決した上で、テストを並列実行することにしました。

モック

モックを使うことで読み込むfixtureを削減しました。
Mockeryを使用しているので、その例が以下になります。

/**
 * @test
 */
public function xxx()
{
    $mock = \Mockery::mock(Xxx::class);
    $mock->id = 1;
    $mock->name = 'test';
    $mock->shouldReceive('isXxx')->andReturnFalse();

    /** @var Container $container */
    $container = \ClassRegistry::getObject("container");
    $container->add(Xxx::class, $mock);

    // 以下、テスト対象のメソッド実行
}

オリジナルの実装をそのまま使いたい場合は、このような書き方になります。

/**
 * @test
 */
public function xxx()
{
    $mock = \Mockery::mock(Xxx::class);
    $mock->id = 1;
    $mock->name = 'test';
    $mock->shouldReceive('isXxx')->andReturnFalse();
    $mock->shouldReceive('getYyy')->passthru();
    $mock->shouldReceive('getZzz')->passthru();

    /** @var Container $container */
    $container = \ClassRegistry::getObject("container");
    $container->add(Xxx::class, $mock);

    // 以下、テスト対象のメソッド実行
}

ただ、オリジナルの実装をそのまま使いたいメソッドが多いと正直面倒です。その時はパーシャルモックを使うと、モックしたいメソッドだけ定義すればいいので便利でした。

/**
 * @test
 */
public function xxx()
{
    $mock = \Mockery::mock(Xxx::class)->makePartial();
    $mock->id = 1;
    $mock->name = 'test';
    $mock->shouldReceive('isXxx')->andReturnFalse();
    // $mock->shouldReceive('getYyy')->passthru();
    // $mock->shouldReceive('getZzz')->passthru();

    /** @var Container $container */
    $container = \ClassRegistry::getObject("container");
    $container->add(Xxx::class, $mock);

    // 以下、テスト対象のメソッド実行
}

修行僧のように全テストを見直した結果、実行時間17分まで改善できました。
この経験でだいぶメンタルが鍛えられました。

キャッシュクリア

setUpメソッドは、テストケースが実行される前に、各テストケースごとに実行されます。
ReflectionPropertyを使ってキャッシュを空にすることで、他のテスト実行による影響を無くしました。

class XxxTest
{
    public function setUp()
    {
        $property = new \ReflectionProperty(\XxxRepository::getInstance(), 'cache');
        $property->setAccessible(true);
        $property->setValue(\XxxRepository::getInstance(), []);
    }
}
class XxxRepository
{
    private $cache = [];

    public function get($id)
    {
        // キャッシュに存在すれば打ち返す
        if (isset($this->cache[$id])) {
            return $this->cache[$id];
        }
    
        $data = $this->loadModel('Table')
            ->findById($id);
    
        // キャッシュ
        $this->cache[$id] = $data;
    
        return $data;
    }
}

テスト実行の並列化

phpunit.xmlを分割する

全テストを見直すと、どのテストがどれくらい掛かるか全て覚えるようになります。
ちょうど実行時間が均等になるよう、ここは気合いと根性で分割してください。

元のphpunit.xml
<testsuites>
    <testsuite name="test">
        <directory>Case/</directory>
    </testsuite>
</testsuites>
分割後のphpunit_1.xml
<testsuites>
    <testsuite name="test">
        <directory>Case/Aaa/</directory>
        <directory>Case/Bbb/</directory>
        <directory>Case/Lib/Ccc</directory>
        <directory>Case/Ddd/</directory>
        <directory>Case/Eee/</directory>
    </testsuite>
</testsuites>

composer.jsonにスクリプトを書く

分割した分のスクリプトを用意します。

"scripts": {
    "test:Xxx:config1": "Xxx/Vendor/phpunit/phpunit/phpunit --config=Xxx/Test/phpunit_1.xml --exclude-group skip",
    "test:Xxx:config2": "Xxx/Vendor/phpunit/phpunit/phpunit --config=Xxx/Test/phpunit_2.xml --exclude-group skip",
    "test:Xxx:config3": "Xxx/Vendor/phpunit/phpunit/phpunit --config=Xxx/Test/phpunit_3.xml --exclude-group skip",
    "test:Xxx:config4": "Xxx/Vendor/phpunit/phpunit/phpunit --config=Xxx/Test/phpunit_4.xml --exclude-group skip",
}

.github/workflows/Xxx.ymlでジョブを並列化する

jobs.<job_id>.strategy.matrixを使用してマトリックスを定義しました。
変数test_scriptに実行したいスクリプトを格納しています。

このワークフローでは、変数の値ごとに1つずつ、4つのジョブが実行されます。
各ジョブは、matrix.test_scriptコンテキストを通して test_script値にアクセスし、スクリプトを実行しているというわけです。

jobs:
  Xxx_test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        test_script: [ 'composer test:Xxx:config1',
                       'composer test:Xxx:config2',
                       'composer test:Xxx:config3',
                       'composer test:Xxx:config4'
        ]

    ~~ 割愛 ~~
        
    steps:
    - name: Run tests
      run: ${{ matrix.test_script }}

さいごに

ここまで読んでいただいてありがとうございました。
開発体験を上げる活動は地道なものが多く、決して楽ではありません。
ですが、必ずその先に楽園は存在します。

楽園を目指して、共にエンジニアライフを楽しみましょう。

13
4
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
13
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?