🎻

[Symfony][Doctrine] マイグレーションをテストする

2020/07/20に公開

マイグレーションスクリプトの実行結果がちゃんと期待どおりになっているかどうかは多くの場合目視確認になると思いますが、マイグレーションの内容が複雑になってくると目視ではやってられないこともあると思います。

そこで、Doctrine Migrationsによるマイグレーションを自動テストする方法をご紹介します。(僕が考えたやり方なので、あくまで一例と思ってください🙏)

[Symfony][Doctrine] 外部キーの付け替えを伴うマイグレーションスクリプトの書き方

昨日書いたこちらの記事がちょうどいい例なので、こういう外部キー制約の付け替えを伴うマイグレーションをしたときに、ちゃんと期待どおりにエンティティの関連が維持されていることを確認するテストを書いてみたいと思います。

基本方針

実際にマイグレーションを実行して、その前後のデータベースの内容をPHPから確認することでテストする

そもそものテストの方法ですが、マイグレーションスクリプトの内容はほとんどSQLなので、PHPレベルでテストをしてもほぼ無意味です。

なので、本番と同じ内容にしたデータベースを用意して、そこに対して実際にマイグレーションを実行してみて、実行前後のデータベースの内容をPHPから確認するという方法でテストすることにします。

参考:php - How to test Doctrine Migrations? - Stack Overflow

APP_ENVtest ではなく test_migrations を別途用意する

普段実行する機能テストでは、通常はSQLiteを使っていると思いますが、マイグレーションスクリプトは本番で使っているDBドライバーに依存したSQLが含まれていることが多々あります。

なので、マイグレーションのテストはSQLiteではなく本番と同じDB(例えばMySQL)を使って実行したいです。

しかし当然ながら開発環境用のDBを使ってテストするのは微妙すぎます🙄

なので、マイグレーションのテストは test 環境ではなくそれ用に別途用意した test_migrations といった環境で実行するようにし、 test_migrations 環境においては開発用とは別のDBに接続するように設定しておく、という方法をとることにします。

phpunit.xml.dist を別途用意する

マイグレーションのテストは、通常の自動テストと違ってコードを修正する度に毎回実行したいようなものではありません。

あくまで目的は その時点の本番データに対してマイグレーションが確実に成功することを確認したい というものです。

なので、普段 phpunit コマンドを実行するときには実行されてほしくありません🤔

マイグレーションのテストに @group をつけておいて、普段は phpunit --exclude-group {グループ名} でテストするようにする、という方法も考えられますが、できれば普段は phpunit とだけすればマイグレーションのテスト以外が実行されるようになっているのが理想です。

なので、 phpunit.xml.dist をマイグレーションのテスト用に別途用意するという方法をとることにします。

1. test_migrations 環境を用意する

というわけでここから具体的な手順を説明していきます。

まずは先ほど説明したとおり test_migrations 環境を用意しましょう。

まず、 .env.test をコピーして .env.test_migrations を作ります。

$ cp .env.test{,_migrations}

次に、 config/packages/test_migrations 配下に framework.yamldoctrine.yaml の2つを用意します。

# config/packages/test_migrations/framework.yaml

framework:
    test: true
# config/packages/test_migrations/doctrine.yaml

doctrine:
  dbal:
    url: '%env(resolve:DATABASE_URL_TEST_MIGRATIONS)%'

上記のとおりマイグレーションのテストに使うデータベースのURLは DATABASE_URL_TEST_MIGRATIONS という環境変数で指定できるようにしたので、 .env に以下のようにプレースホルダーを追記します。

  DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7
+ DATABASE_URL_TEST_MIGRATIONS=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7

これで、 test_migrations 環境を指定すれば DATABASE_URL_TEST_MIGRATIONS 環境変数で設定したデータベースが使われるようになりました。

あとは、 .env.localDATABASE_URL_TEST_MIGRATIONS= にマイグレーションテスト用のデータベースを設定してあげれば準備完了です👌

2. phpunit.xml.dist を用意する

続いて、マイグレーションテスト用の phpunit.xml.dist を用意します。

テストファイル群の置き場所はどこでもよいですが、 tests 配下に置いてしまうと通常のテストと混ざってしまってかえって紛らわしいので、今回はあえて src/Migrations の下に tests というディレクトリを作って、そこに phpunit.xml.dist やテストファイルを置くことにしてみます。

まずは phpunit.xml.dist をまるっとコピーしましょう。

$ mkdir src/Migrations/tests
$ cp phpunit.xml.dist src/Migrations/tests/

そして、必要な箇所だけ書き換えます。

  <?xml version="1.0" encoding="UTF-8"?>
  
  <!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
  <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-          xsi:noNamespaceSchemaLocation="bin/.phpunit/phpunit.xsd"
+          xsi:noNamespaceSchemaLocation="../../../bin/.phpunit/phpunit.xsd"
           backupGlobals="false"
           colors="true"
-          bootstrap="tests/bootstrap.php"
+          bootstrap="../../../tests/bootstrap.php"
  >
      <php>
          <ini name="error_reporting" value="-1" />
-         <server name="APP_ENV" value="test" force="true" />
+         <server name="APP_ENV" value="test_migrations" force="true" />
          <server name="SHELL_VERBOSITY" value="-1" />
          <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
          <server name="SYMFONY_PHPUNIT_VERSION" value="7.5" />
      </php>
  
      <testsuites>
          <testsuite name="Project Test Suite">
-             <directory>tests</directory>
+             <directory suffix=".php">.</directory>
          </testsuite>
      </testsuites>
  
-     <filter>
-         <whitelist processUncoveredFilesFromWhitelist="true">
-             <directory suffix=".php">src</directory>
-         </whitelist>
-     </filter>
- 
      <listeners>
          <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
      </listeners>
  </phpunit>

APP_ENVtest から test_migrations に変更したのが一番のポイントですね。

あとは testsuitedirectory ディレクティブに suffix=".php" を追記しましたが、これは、デフォルト値が suffix="Test.php" なので、それを変更して Test.php で終わらないファイル名でもテストファイルとして認識されるようにしているだけです。( Test.php で終わるファイル名を採用するなら変更の必要はありません)

3. 本番データのmysqldumpを用意する

MySQLを例に話を進めます🙏他のDBを使っている場合は適宜読み替えてください🙏

実際にマイグレーションを実行する対象のデータセットを用意します。現時点の本番データベースをmysqldumpしたものを用意しておけばよいでしょう。

それを、今回は src/Migrations/tests/mysqldump 配下に置くことにします。テスト対象のマイグレーションスクリプトのクラス名と対応づけて、以下のようなファイル名にしておくことにしましょう。

src/Migrations/tests/mysqldump/TestVersion2020xxxxxxxxxx_preUp.sql

4. テストを書く

ではいよいよテストを書いていきます。

先ほどのmysqldumpファイルと名前を合わせて、

src/Migrations/tests/TestVersion2020xxxxxxxxxx.php

というファイル名にします。

Version2020xxxxxxxxxxTest.php のように Version から始まるファイル名だと、 doctrine:migrations:migrate コマンドなどを実行したときに このファイルがマイグレーションファイルと間違われてしまう ので注意が必要です。(これを避けるために、 Test.php で終わらないファイル名を許容するように phpunit.xml.dist を修正したのでした)

というわけで、ここまでで src/Migrations/tests 配下の構成は以下のようになっています。

src/Migrations/tests
├── TestVersion2020xxxxxxxxxx.php
├── mysqldump
│   └── TestVersion2020xxxxxxxxxx_preUp.sql
└── phpunit.xml.dist

1 directory, 3 files

テストファイルの中身は、結論としては以下のような内容になります。

<?php

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

/**
 * @group 2020xxxxxxxxxx
 */
class Test2020xxxxxxxxxx extends KernelTestCase
{
    const PATH_TO_MYSQLDUMP = __DIR__.'/mysqldump/TestVersion2020xxxxxxxxxx_preUp.sql';

    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * @var Application
     */
    private $application;

    protected function setUp()
    {
        $kernel = self::bootKernel();
        $this->em = $kernel->getContainer()->get('doctrine')->getManager();
        $this->application = new Application($kernel);
    }

    protected function tearDown()
    {
        parent::tearDown();

        $this->em->close();
        $this->em = null;
    }

    public function testUp()
    {
        $this->dropSchema();
        $this->importMysqldump();

        $oldItems = $this->em->getConnection()->query('SELECT id, shop_id FROM item')->fetchAll();

        $this->migrateTo('next');

        $newItems = $this->em->getConnection()->query('SELECT i.id, s.shop_id FROM item i LEFT JOIN staff s ON i.staff_id = s.id')->fetchAll();

        $this->assertEquals(count($oldItems), count($newItems));

        for ($i = 0; $i < count($oldItems); $i++) {
            $this->assertEquals($oldItems[$i]['id'], $newItems[$i]['id']);
            $this->assertEquals($oldItems[$i]['shop_id'], $newItems[$i]['shop_id']);
        }
    }

    public function testDown()
    {
        $oldItems = $this->em->getConnection()->query('SELECT i.id, s.shop_id FROM item i LEFT JOIN staff s ON i.staff_id = s.id')->fetchAll();

        $this->migrateTo('prev');

        $newItems = $this->em->getConnection()->query('SELECT id, shop_id FROM item')->fetchAll();

        $this->assertEquals(count($oldItems), count($newItems));

        for ($i = 0; $i < count($oldItems); $i++) {
            $this->assertEquals($oldItems[$i]['id'], $newItems[$i]['id']);
            $this->assertEquals($oldItems[$i]['shop_id'], $newItems[$i]['shop_id']);
        }

        $this->dropSchema();
    }

    private function dropSchema()
    {
        $schemaDropCommand = $this->application->find('doctrine:schema:drop');
        $returnCode = $schemaDropCommand->run(new ArrayInput(['--force' => true]), new NullOutput());

        if ($returnCode !== 0) {
            throw new \RuntimeException('failed to execute doctrine:schema:drop command');
        }
    }

    private function importMysqldump()
    {
        $username = $this->em->getConnection()->getUsername();
        $password = $this->em->getConnection()->getPassword();
        $database = $this->em->getConnection()->getDatabase();
        $pathToMysqldump = realpath(self::PATH_TO_MYSQLDUMP);

        $process = Process::fromShellCommandline(sprintf('mysql -u%s -p%s %s < %s', $username, $password, $database, $pathToMysqldump));
        $process->setTimeout(300);
        $process->run();

        if (!$process->isSuccessful()) {
            throw new ProcessFailedException($process);
        }
    }

    private function migrateTo(string $version = 'next')
    {
        $migrateCommand = $this->application->find('doctrine:migrations:migrate');
        $input = new ArrayInput(['version' => $version]);
        $input->setInteractive(false);
        $returnCode = $migrateCommand->run($input, new NullOutput());

        if ($returnCode !== 0) {
            throw new \RuntimeException('failed to execute doctrine:migrations:migrate command');
        }
    }
}

全体

全体の構成としては、リポジトリクラスのテスト と同じように KernelTestCase を継承してコンテナ経由で EntityManager を取得する形になっています。 EntityManagergetConnection() でコネクションを取得し、データベースを直接触ります。

また、クラスに @group 2020xxxxxxxxxx アノテーションをつけています。こうしておくことで、特定のテストだけを選んで実行することができるようになります。

testUp()

testUp() では、先頭で

$this->dropSchema();
$this->importMysqldump();

という2つのprivateメソッドを実行してデータベースをmysqldumpの内容で初期化しています。

dropSchema()doctrine:schema:drop コマンドを実行しているだけです。 mysqldumpファイルは不要なテーブルの削除はしてくれない ので、念のため先にデータベースを空にしているというわけです。

PHPのコードからコマンドを実行する方法は How to Call Other Commands (Symfony Docs) あたりが参考になります。

importMysqldump() はmysqldumpのインポートを実行しています。こちらは Processコンポーネント を使って直接実行しています。

こちらの過去記事 でも似たようなコードを紹介しているので参考になるかもしれません。

testUp() の残りのコードは以下のとおりです。

$oldItems = $this->em->getConnection()->query('SELECT id, shop_id FROM item')->fetchAll();

$this->migrateTo('next');

$newItems = $this->em->getConnection()->query('SELECT i.id, s.shop_id FROM item i LEFT JOIN staff s ON i.staff_id = s.id')->fetchAll();

$this->assertEquals(count($oldItems), count($newItems));

for ($i = 0; $i < count($oldItems); $i++) {
    $this->assertEquals($oldItems[$i]['id'], $newItems[$i]['id']);
    $this->assertEquals($oldItems[$i]['shop_id'], $newItems[$i]['shop_id']);
}

migrateTo('next') は、マイグレーションを実行するprivateメソッドです。ここは up のテストなので対象バージョンを next (1つ次のバージョンを表すエイリアス)として実行しています。

その実行前後で item テーブル(およびJOINした staff テーブル)の中身を記憶しておいて、関連している shop のIDがズレていないことを全件チェックしています。

migrateTo() は先に見た dropSchema() と同じ要領で doctrine:migrations:migrate コマンドを実行しているだけですが、一点、インタラクションを無効にするために $input->setInteractive(false); を実行しているところだけ要注意です。

こちらの過去記事 でも触れましたが、インタラクションの無効化だけはコマンド引数として '--no-interaction' => true を渡してもダメで、 setInteractive(false) を実行する必要があるようです。

testDown()

testDown() も、 testUp の逆のことをして同じように関連がズレていないことをチェックしているだけですね。

特筆すべきことがあるとすれば、 migrateTo('prev') とバージョンに prev を指定して実行していることと、テストの最後に一応 dropSchema() を実行してマイグレーションテスト用データベースの中身を空にしていることぐらいでしょうか。

5. テストを実行してみる

では、最後に実際にテストを実行してみましょう。

$ bin/phpunit -c src/Migrations/tests --group 2020xxxxxxxxxx

PHPUnit 7.5.20 by Sebastian Bergmann and contributors.

Testing Migrations Test Suite
..                                                                  2 / 2 (100%)

Time: 6.87 seconds, Memory: 44.00 MB

OK (2 tests, 21688 assertions)

こんな感じで、無事にパスしました🙌

GitHubで編集を提案

Discussion