⏱️

Laravel の RefreshDatabase の仕組みについて理解する

2023/09/10に公開

データベースのレコード更新を伴うテストを実行するとき、テスト終了時にレコードを元の状態にリセットしたい場合がある。 Laravel ではそのような動作を簡単に実現できるいくつかのトレイトが提供されている。

https://laravel.com/docs/10.x/database-testing

例えば RefreshDatabase トレイトは次のようにして使用する (上記ドキュメントより引用)。

<?php
 
namespace Tests\Feature;
 
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
 
class ExampleTest extends TestCase
{
    use RefreshDatabase;
 
    /**
     * A basic functional test example.
     */
    public function test_basic_example(): void
    {
        $response = $this->get('/');
 
        // ...
    }
}

こうすると、データベーストランザクションの内部でテストが実行され、テスト終了時にトランザクションがロールバックされる。テストスイートに含まれるすべてのテストケースでこのトレイトが使用されるように実装すれば、テストの実行順序によらず、シーディング直後のデータベースのレコードの状態を元にテストを作成できるようになる。

本記事では、 RefreshDatabase のようなトレイトを使用すると内部的にどのような処理が実行されるのか、 Laravel のソースコードを読んでいく。

環境

  • PHP 8.2
  • PHPUnit 10.3.2
  • Laravel 10.20.0

Laravel のソースコードを読む

今回は公式ドキュメントに従って Laravel Sail の環境を構築し、そのソースコードを読んでいった。

\Illuminate\Foundation\Testing\TestCase

Laravel ではすべてのテストケースが \Illuminate\Foundation\Testing\TestCase クラスを継承することが想定されている。このクラスの setUp() メソッドを出発点にする。

<?php

namespace Illuminate\Foundation\Testing;

// ...(中略)...

abstract class TestCase extends BaseTestCase
{
    // ...(中略)...

    /**
     * Setup the test environment.
     *
     * @return void
     */
    protected function setUp(): void
    {
        static::$latestResponse = null;

        Facade::clearResolvedInstances();

        if (! $this->app) {
            $this->refreshApplication();

            ParallelTesting::callSetUpTestCaseCallbacks($this);
        }

        $this->setUpTraits();

        foreach ($this->afterApplicationCreatedCallbacks as $callback) {
            $callback();
        }

        Model::setEventDispatcher($this->app['events']);

        $this->setUpHasRun = true;
    }

    // ...(中略)...
}

ここで setUpTraits() メソッドに着目する。

/**
 * Boot the testing helper traits.
 *
 * @return array
 */
protected function setUpTraits()
{
    $uses = array_flip(class_uses_recursive(static::class));

    if (isset($uses[RefreshDatabase::class])) {
        $this->refreshDatabase();
    }

    if (isset($uses[DatabaseMigrations::class])) {
        $this->runDatabaseMigrations();
    }

    if (isset($uses[DatabaseTruncation::class])) {
        $this->truncateDatabaseTables();
    }

    if (isset($uses[DatabaseTransactions::class])) {
        $this->beginDatabaseTransaction();
    }

    if (isset($uses[WithoutMiddleware::class])) {
        $this->disableMiddlewareForAllTests();
    }

    if (isset($uses[WithoutEvents::class])) {
        $this->disableEventsForAllTests();
    }

    if (isset($uses[WithFaker::class])) {
        $this->setUpFaker();
    }

    foreach ($uses as $trait) {
        if (method_exists($this, $method = 'setUp'.class_basename($trait))) {
            $this->{$method}();
        }

        if (method_exists($this, $method = 'tearDown'.class_basename($trait))) {
            $this->beforeApplicationDestroyed(fn () => $this->{$method}());
        }
    }

    return $uses;
}

まず $uses には使用されているすべてのトレイト名を (key として) 要素に持つ array が代入される。これを作るのに class_uses_recursive() というメソッドを呼び出している。これは Laravel のヘルパーメソッドで、引数に渡されたクラスとその先祖クラスから使用されているトレイトを再帰的に取得して array で返す。この array に対して array_flip() を使って key と value を逆転させ、 $uses に代入する。トレイト名を key にすることで isset() を使ってトレイトの有無を検証できるようにしている。

次に使用されているトレイトに応じた事前処理が行われる。例えばテストクラス中で RefreshDatabase トレイトを使っているなら、 isset($uses[RefreshDatabase::class]) の結果が true になり、このトレイトが持つ refreshDatabase() メソッドが if ブロック内で呼び出される。

また foreach ($uses as $trait) の部分では、テストクラスに setUp + トレイト名 のような名前のメソッドが定義されていればそのまま実行される。例えば RefreshDatabase トレイトを使用するテストについて前処理を実行したい場合、 setUpRefreshDatabase() という名前のメソッドを定義しておけば良い。また tearDown + トレイト名 のような名前のメソッドが定義されていれば beforeApplicationDestroyed() にそのメソッドがコールバック関数として渡される。ここで渡された関数は内部のプロパティに登録され、 tearDown のフェーズでその関数が呼び出されるようになっている。

\Illuminate\Foundation\Testing\RefreshDatabase

refreshDatabase() メソッドの中身は次のようになっている。

trait RefreshDatabase
{
    // ...(中略)...

    public function refreshDatabase()
    {
        $this->beforeRefreshingDatabase();

        $this->usingInMemoryDatabase()
                        ? $this->refreshInMemoryDatabase()
                        : $this->refreshTestDatabase();

        $this->afterRefreshingDatabase();
    }

    // ...(中略)...
}

ざっくり言うと 1. 前処理、 2. トランザクション開始、 3. 後処理 の順に処理が実行される。 beforeRefreshingDatabase() および afterRefreshingDatabase() は空実装になっているので、テストクラス中でオーバーライドすることでトランザクション開始前後にカスタム処理を差し込むことができる。トランザクション開始の部分では、インメモリ DB を利用しているかどうかで呼び出されるメソッドは異なる。本記事では refreshTestDatabase() メソッドの方を読んでいく。

protected function refreshTestDatabase()
{
    if (! RefreshDatabaseState::$migrated) {
        $this->artisan('migrate:fresh', $this->migrateFreshUsing());

        $this->app[Kernel::class]->setArtisan(null);

        RefreshDatabaseState::$migrated = true;
    }

    $this->beginDatabaseTransaction();
}

RefreshDatabaseState::$migrated には初期値として false が代入されており、 if ブロック内の最後で true が代入される。したがって初回のテストケースのみ if ブロック内の処理を通る。ブロック内では migrate:fresh が実行され、テーブルのドロップとマイグレーションが実行される。 migrateFreshUsing()CanConfigureMigrationCommands トレイトに定義されており、 TestCase に定義したプロパティに応じて Artisan コマンドのオプションの配列を生成する。例えば seed というプロパティを定義しておくと --seed オプションが追加される。

そして beginDatabaseTransaction() メソッドの中身は次のようになっている。

public function beginDatabaseTransaction()
{
    $database = $this->app->make('db');

    foreach ($this->connectionsToTransact() as $name) {
        $connection = $database->connection($name);
        $dispatcher = $connection->getEventDispatcher();

        $connection->unsetEventDispatcher();
        $connection->beginTransaction();
        $connection->setEventDispatcher($dispatcher);

        if ($this->app->resolved('db.transactions')) {
            $this->app->make('db.transactions')->callbacksShouldIgnore(
                $this->app->make('db.transactions')->getTransactions()->first()
            );
        }
    }

    $this->beforeApplicationDestroyed(function () use ($database) {
        foreach ($this->connectionsToTransact() as $name) {
            $connection = $database->connection($name);
            $dispatcher = $connection->getEventDispatcher();

            $connection->unsetEventDispatcher();
            $connection->rollBack();
            $connection->setEventDispatcher($dispatcher);
            $connection->disconnect();
        }
    });
}

大まかに言うと 1. トランザクションの開始 (foreach ($this->connectionsToTransact() as $name) の部分) 、 2. ロールバック処理のコールバック関数の登録 (beforeApplicationDestroyed() の部分) の2つのセクションに分けられる。

まずトランザクションの開始部分から見ていく。 connectionsToTransact() は基本的に値が null の要素を1つだけ持つ array を返し、その結果 $database->connection($name) からはデフォルトのコネクション (Laravel Sail の場合は MySQL コネクション) が返却され、 $connection->beginTransaction() でトランザクションを開始するという流れになっている。 $connection->beginTransaction() にはイベント発火の処理が含まれるが、この直前の $connection->unsetEventDispatcher() により dispatcher を解除してイベントを発火させないようにし、その後 $connection->setEventDispatcher($dispatcher) により再設定している。プロダクトコードでトランザクション開始のイベントをトリガーにして実行される処理があるとき、それらが意図しないタイミングでトリガーされるのを避けるためと推測される。

ロールバック処理のコールバック関数の登録部分では、 \Illuminate\Foundation\Testing\TestCase で見たのと同様に beforeApplicationDestroyed() を使って、 rollBack() の実行、そして disconnect() で PDO のコネクションを解除する処理が tearDown のフェーズで実行されるようになっている。

Illuminate\Database\Concerns\ManagesTransactions

beginTransaction() および rollBack() が定義されている Illuminate\Database\Concerns\ManagesTransactions はテスト以外でも利用されるトレイトなので、あまり詳細には踏み込まず、テスト文脈で関係ありそうな部分について触れる。

beginTransaction()

beginTransaction() の中身は次のようになっている。

trait ManagesTransactions
{
    // ...(中略)...

    public function beginTransaction()
    {
        $this->createTransaction();

        $this->transactions++;

        $this->transactionsManager?->begin(
            $this->getName(), $this->transactions
        );

        $this->fireConnectionEvent('beganTransaction');
    }

    // ...(中略)...
}

createTransaction() では PDO 経由でトランザクションを開始するか、既にトランザクションが開始済みであれば savepoint [1] を作成する。通常 setUp 時点ではトランザクションは開始されていないため、ここでトランザクションが開始される。したがってテスト対象コード内で beginTransaction() が呼び出されている場合、そこでは savepoint が作成されることになる。その後 transactions++ でトランザクションの階層数をインクリメントする。

最後に fireConnectionEvent() でトランザクション開始のイベントを発火しようとするが、前述したように dispatcher を解除しているので発火されることはない。

rollBack()

rollBack() の中身は次のようになっている。

public function rollBack($toLevel = null)
{
    // We allow developers to rollback to a certain transaction level. We will verify
    // that this given transaction level is valid before attempting to rollback to
    // that level. If it's not we will just return out and not attempt anything.
    $toLevel = is_null($toLevel)
                ? $this->transactions - 1
                : $toLevel;

    if ($toLevel < 0 || $toLevel >= $this->transactions) {
        return;
    }

    // Next, we will actually perform this rollback within this database and fire the
    // rollback event. We will also set the current transaction level to the given
    // level that was passed into this method so it will be right from here out.
    try {
        $this->performRollBack($toLevel);
    } catch (Throwable $e) {
        $this->handleRollBackException($e);
    }

    $this->transactions = $toLevel;

    $this->transactionsManager?->rollback(
        $this->getName(), $this->transactions
    );

    $this->fireConnectionEvent('rollingBack');
}

引数は指定されずに呼び出されるので、 $toLevel には null が代入される。この時点で $this->transactions は 1 なので、ガード節を通り抜けて performRollBack() が実行される。 performRollBack() では PDO 経由でロールバックされてトランザクションが終了するか、 savepoint が存在する場合はトランザクションを終了させずに savepoint の時点までロールバックされる。通常 tearDown の時点では savepoint は存在しないため、前者の処理が実行される。

最後に fireConnectionEvent() でロールバックのイベントを発火しようとするが、前述したように dispatcher を解除しているので発火されることはない。

まとめ

一連の処理をサマリーすると次のようになる。

  1. setUp フェーズで \Illuminate\Foundation\Testing\TestCase::setUp が実行される。
  2. \Illuminate\Foundation\Testing\RefreshDatabase::refreshTestDatabase() が実行され、いくつかのメソッドを経由して beginDatabaseTransaction() が実行される。
  3. トランザクションが開始される。
  4. tearDown フェーズでロールバック処理が実行されるようにコールバック関数が仕込まれる。
  5. テストが実行される。テスト中の beginTransaction() では savepoint が作成され、 rollback() では savepoint の状態までロールバックされる。
  6. tearDown フェーズでトランザクションがロールバックされる。
脚注
  1. https://dev.mysql.com/doc/refman/8.0/en/savepoint.html ↩︎

Discussion