CakePHP 4.x に ParaTest を導入

CakePHP 4.x に ParaTest を導入

テストの数が増えてきて全件テストを実行するのに時間がかかるようになった為、ParaTestを導入し、テストを並列実行するようにしてみました。

version

ドキュメント

ParaTest

  • PHPUnit でのテストを並列で実行できるツール
    • 並列数(プロセス数)はオプションで指定できる
    • テストクラス単位で並行実行される模様
  • 環境変数 ParaTest に並列実行されるプロセスごとに1から始まる連番が設定される
    • DB を利用したテストを並列実行する際は、TEST_TOKEN を利用して、各プロセス毎に利用するDBを分けるが必要がある
  • Laravel 10.x の並列テストでは内部的に ParaTest を使用

導入効果

元々 06:27 かかっていたところが 01:48 で実行できるようになりました

  • ParaTest (8 プロセス) : 01:48
  • PHPUnit (1 プロセス) : 06:27

CakePHP 4.x への導入方法のメモ

DB が絡むテストを並列実行する際は、TEST_TOKEN を利用して、各プロセス毎に利用するDBを分ける等の工夫が必要になる

  • 今回はCakePHPのコアのコードを読み、以下のようにする事で、各プロセス毎に利用するDBの出し分けを実現しました
    • Fixtureの調整あたりで少しハマりました

ParaTest をインストール

composer require --dev brianium/paratest --with-all-dependencies

並列数分のテスト用DBを作成する

  • 今回は 8 プロセスで並列実行する想定なので、並列数分のテスト用DBを作成
  • 作成方法は割愛

app_local.phpDatasources に ParaTest 用の connection の設定を追加

// app/config/app_local.php

$paraTestConnectionBase = [
    'className' => Connection::class,
    'driver' => //...,
    //...

    'host' => env('DB_HOST'),
    'port' => env('DB_PORT'),
    'username' => env('DB_USER'),
    'password' => env('DB_PASSWORD'),
    'encoding' => env('DB_ENCODING', 'UTF8'),
    'timezone' => env('DB_TIMEZONE', 'UTC'),
]

// ...

return [
    //...
    'Datasources' => [
        // ...

        // ParaTest用のconnection
        // note: connection名を `test_` で始まるものにする必要がある
        'test_1' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test1'],
        'test_2' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test2'],
        'test_3' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test3'],
        'test_4' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test4'],
        'test_5' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test5'],
        'test_6' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test6'],
        'test_7' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test7'],
        'test_8' => $paraTestConnectionBase + ['database' => env('DB_NAME').'_test8'],

TEST_TOKEN を元に、各プロセス毎の Connection の設定を調整

  • \Cake\TestSuite\Fixture\PHPUnitExtension::executeBeforeFirstTest の処理をカスタマイズする必要があった為、PHPUnitBeforeFirstTestHook を利用し、以下のようにカスタマイズ
// app/phpunit.xml.dist

//...
  <extensions>
    <extension class="Path\To\AppExtension"/>
    //...
// Path/To/AppExtension.php

//...
use PHPUnit\Runner\BeforeFirstTestHook;

//...
class AppExtension implements BeforeFirstTestHook
{
    /**
     * what:
     *  \Cake\TestSuite\Fixture\PHPUnitExtension::executeBeforeFirstTest の処理に、
     *  ParaTest 使用時の connection を調整する為の処理を追加したもの
     */
    public function executeBeforeFirstTest(): void
    {
        $helper = new ConnectionHelper();
        $helper->addTestAliases();

        // for ParaTest
        $paraTestToken = getenv('TEST_TOKEN');
        if ($paraTestToken !== false) {
            // ParaTestで各プロセス用のconnectionを設定する為に、
            // \Cake\TestSuite\ConnectionHelper::addTestAliases で設定した ConnectionManager::alias('test', 'default'); を上書き
            ConnectionManager::alias("test_{$paraTestToken}", 'default');
        }

        $enableLogging = in_array('--debug', $_SERVER['argv'] ?? [], true);
        if ($enableLogging) {
            $helper->enableQueryLogging();
            Log::drop('queries');
            Log::setConfig('queries', [
                'className' => 'Console',
                'stream' => 'php://stderr',
                'scopes' => ['queriesLog'],
            ]);
        }
    }

Fixture で利用する connection を調整

  • TEST_TOKEN を元に、当該プロセス用のconnectionを設定
// app/tests/Fixture/AppTestFixture.php

//...
use Cake\TestSuite\Fixture\TestFixture;

//...
class AppTestFixture extends TestFixture
{
    public function __construct()
    {
        // ParaTestの場合は、当該プロセス用のconnectionを設定
        $paraTestToken = getenv('TEST_TOKEN');
        if ($paraTestToken !== false) {
            $this->connection = "test_{$paraTestToken}";
        }

        parent::__construct();
    }
}
class XxxxFixture extends AppTestFixture

Test用DBのmigration方法を調整

  • こちらも TEST_TOKEN を元に当該プロセスで利用するDBをmigrateするように調整
  • 各DB毎に1回だけmigrateされるように調整
// app/tests/bootstrap.php

// Run migrations for multiple plugins
$migrator = new Migrator();

$paraTestToken = getenv('TEST_TOKEN');

// for ParaTest
if ($paraTestToken !== false) {
    $dirName = TMP . 'tests' . DS . 'paratest' . DS;
    $fileName = "migrated-test_{$paraTestToken}";
    $path = $dirName . $fileName;

    // 1DBにつき1回のみmigrate
    if (!file_exists($path)) {
        $DBName = "test_{$paraTestToken}";
        $connectionName = $DBName;

        $migrator->runMany([
            //...
            ['connection' => $connectionName, 'plugin' => '//...'],
        ]);

        if (!file_exists($dirName)) {
            mkdir($dirName);
        }
        touch($path);
    }
} else {
    $migrator->runMany([
        //...
        ['connection' => 'test', 'plugin' => '//...'],
    ]);
}

上記で作成した一時ファイルをParaTest実行前に削除するように設定

// composer.json

{
    //..
    "scripts": {
        //..
        "paratest": "rm -rf tmp/tests/paratest && ./vendor/bin/paratest -p 8",

ParaTest実行

composer paratest