[Symfony][Doctrine] マイグレーションをテストする
マイグレーションスクリプトの実行結果がちゃんと期待どおりになっているかどうかは多くの場合目視確認になると思いますが、マイグレーションの内容が複雑になってくると目視ではやってられないこともあると思います。
そこで、Doctrine Migrationsによるマイグレーションを自動テストする方法をご紹介します。(僕が考えたやり方なので、あくまで一例と思ってください🙏)
昨日書いたこちらの記事がちょうどいい例なので、こういう外部キー制約の付け替えを伴うマイグレーションをしたときに、ちゃんと期待どおりにエンティティの関連が維持されていることを確認するテストを書いてみたいと思います。
基本方針
実際にマイグレーションを実行して、その前後のデータベースの内容をPHPから確認することでテストする
そもそものテストの方法ですが、マイグレーションスクリプトの内容はほとんどSQLなので、PHPレベルでテストをしてもほぼ無意味です。
なので、本番と同じ内容にしたデータベースを用意して、そこに対して実際にマイグレーションを実行してみて、実行前後のデータベースの内容をPHPから確認するという方法でテストすることにします。
APP_ENV
は test
ではなく 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
をマイグレーションのテスト用に別途用意するという方法をとることにします。
test_migrations
環境を用意する
1. というわけでここから具体的な手順を説明していきます。
まずは先ほど説明したとおり test_migrations
環境を用意しましょう。
まず、 .env.test
をコピーして .env.test_migrations
を作ります。
$ cp .env.test{,_migrations}
次に、 config/packages/test_migrations
配下に framework.yaml
と doctrine.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.local
の DATABASE_URL_TEST_MIGRATIONS=
にマイグレーションテスト用のデータベースを設定してあげれば準備完了です👌
phpunit.xml.dist
を用意する
2. 続いて、マイグレーションテスト用の 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_ENV
を test
から test_migrations
に変更したのが一番のポイントですね。
あとは testsuite
の directory
ディレクティブに 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
を取得する形になっています。 EntityManager
の getConnection()
でコネクションを取得し、データベースを直接触ります。
また、クラスに @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)
こんな感じで、無事にパスしました🙌
Discussion