LoginSignup
3
2

More than 5 years have passed since last update.

PHPUnitのsetUpでPostgreSQLの外部キー制約がついたテーブルをクリーンアップしフィクスチャに初期化する

Posted at

結論

  • PHPUnit_Extensions_Database_TestCaseクラスの初期化処理では、外部キー制約があるとうまく初期化できない場合がある
  • オプションでTRUNCATE処理にCASCADEを追加できるが、外部からこのオプション引数は制御できない
  • 引数なしで実行しているメソッドをオーバーライドすることで、CASCADEするようにしてやる

class HogeTest extends \PHPUnit_Extensions_Database_TestCase
{
    protected function getSetUpOperation()
    {
        // 引数にtrueを渡すように上書き
        return \PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT(true);
    }

    public function setUp()
    {
        parent::setUp();
    }
}

概要

  1. 本番と同一のテーブル構造でテスト用のDBをPostgresで作る
  2. 外部キーが付いているので、目的レコード以外の依存するダミーレコードも作る処理を含めたテストケースのフィクスチャ(データセット)を用意する
    PHPUnit マニュアル – 第8章 データベースのテスト
  3. 外部キーが付いているので、データセットの記述順も順番に書かなければならない
  4. テスト終了後のクリーンナップ時に、削除エラーが出る
  5. 解消したい!
  6. CASCADEというオプションを使えば依存関係を自動的に辿って削除してくれる

検証環境

  • PHP5.4
  • PHPUnit 4.8.31
  • phpunit/dbunit 2.0.3
  • Postgres 9.2.15

テストコード部分サンプル

(これだけではうごきません)

初心者なので基本的なマニュアル通りです。
PHPUnit マニュアル – 第8章 データベースのテスト全体


class HogeTest extends \PHPUnit_Extensions_Database_TestCase // ←DBUnitのクラスを継承する
{

    /** @var \PDO PDO のインスタンス生成は、クリーンアップおよびフィクスチャ読み込みのときに一度だけ */
    static private $pdo = null;
    /** @var PHPUnit_Extensions_Database_DB_IDatabaseConnection のインスタンス生成は、テストごとに一度だけ */
    private $conn = null;

    /* @var \Hoge */
    private $instance;

    /**
     * Returns the test database connection.
     *
     * @return PHPUnit_Extensions_Database_DB_IDatabaseConnection
     */
    protected function getConnection()
    {
        if ($this->conn !== null) {
            return $this->conn;
        }
        if (self::$pdo == null) {
            // $GLOBALSはphpunit.xmlで定義している
            self::$pdo = new \PDO($GLOBALS['DB_DSN'], $GLOBALS['DB_USER'], $GLOBALS['DB_PASSWD']);
        }
        $this->conn = $this->createDefaultDBConnection(self::$pdo, $GLOBALS['DB_DBNAME']);
        return $this->conn;
    }

    /**
     * Returns the test dataset.
     *
     * @return PHPUnit_Extensions_Database_DataSet_IDataSet
     */
    protected function getDataSet()
    {
        $dataset = [
            'テーブル' => [
                [
                    'カラム1'     => '値',
                    'カラム2'     => '値',
                ],
            ],
        ];
        // データセットを配列で定義できるようになったのでyamlとか覚えなくて良くなった
        return $this->createArrayDataSet($dataset);
    }


    public function setUp()
    {
     // ここでgetDataSetが使われ、テーブルが一旦空になり、データセットがINSERTされる
        parent::setUp();
        $this->instance = new Hoge();
    }

    public function testHoge()
    {
        $this->assertTrue(true);
    }
}


動かすとこんなエラーになる。

1) HogeTest::testHoge
PHPUnit_Extensions_Database_Operation_Exception: COMPOSITE[TRUNCATE] operation failed on query:
                TRUNCATE "テーブル"
             using args: Array
(
)
 [SQLSTATE[0A000]: Feature not supported: 7 ERROR:  cannot truncate a table referenced in a foreign key constraint
DETAIL:  Table "テーブルA" references "テーブルB".
HINT:  Truncate table "テーブル" at the same time, or use TRUNCATE ... CASCADE.]

(at the same timeなどとあるので)データセットの並び順を変えたりしても変わらなかったり別のエラーが出るので、方向性を変える

ネットで調べる

Truncateとか知らないし、とりあえずforeign keyが関係しそうなのでそこのエラーメッセージ + phpunitでググる。

php - PHPUnit and MySQL truncation error - Stack Overflow

ナルホド!一時的にforeign keyチェックを無効にして、あとでもとに戻せばいいのか!

mysqlみたいなクエリはpsqlではどうするんだと調べる。

foreign key 無効 postgres
とかで調べる

PostgreSQL - PostgreSQLで既存の外部キー制約にCASCADE等の振る舞いを追加したい(17854)|teratail

PostgreSQLで外部キー(FK)をすべて削除する方法 - Qiita

外部キー制約は重荷になるか - iakioの日記

mysqlではクエリ一発で全体をチェックから外したりつけたりできるが、postgresではひとつひとつ消してつけなおしてをするか、DB側にトリガーの設定をつけなければ同様のことができないらしい。

どっちも嫌だな…

攻め方を変える

foreign keyチェックでは難しそうなので、同時に出ていたヒントから考えてみる。

HINT: Truncate table "テーブル" at the same time, or use TRUNCATE ... CASCADE.]

TRUNCATECASCADEも知らないので調べる。(どうなんだそれは)

TRUNCATEとDELETEの違い | アライドアーキテクツ エンジニアブログ

・TRUNCATEではDROP TABLEを行った上で再度同じテーブルを作成する
・AUTO_INCREMENTが設定されている場合はAUTO_INCREMENTの値は初期化される
・ROLLBACKできない

なるほど 全削除→データセットの反映 の全削除部分の担当。
AUTO_INCREMENTズレないの?という謎も解決。

PostgreSQL 9.1.5文書 TRUNCATE

CASCADE

指定されたテーブル、または、CASCADEにより削除対象テーブルとされたテーブルを参照する外部キーを持つテーブルすべてを自動的に空にします。

DB側で自動解決してくれる便利オプションでした。

そのテーブルが他のテーブルから外部キーで参照されている場合、1つのTRUNCATEでそれらのテーブルをすべて空にするように指定していない限り、TRUNCATEを使用することはできません。 このような場合は、有効性を検査するためにテーブルスキャンが必要になりますが、テーブルスキャンを行うのであれば、このコマンドの利点がなくなるからです。 CASCADEを使用して、自動的にすべての依存テーブルを含めることができます。 しかし、意図しないデータ損失の可能性がありますので、このオプションを使用する時には十分に注意してください。

目的は全テーブルを空にすることなので大丈夫!

PHPUnitの初期化の実装を見る

ヒントを見るに、CASCADEを追加してやればいいのだから、まずはどこでTRUNCATEしているのかを調べる。
といってもテストクラスのsetUp()であることはわかっているので、継承元を追っていくだけ。PhpStormなりIDEを使えば簡単。
…なのだが、dbunitはtrait駆動になっているので、extendsだけ追えば見落としてしまうようだ。
追うならuseだが、テストケースにsetUp()を書いてIDEでオーバーライド元を検知してもらってジャンプするなども小技になりそうだ。

composer\vendor\phpunit\dbunit\src\Extensions\Database\TestCaseTrait.php

trait PHPUnit_Extensions_Database_TestCase_Trait
{
    /**
     * Performs operation returned by getSetUpOperation().
     */
    protected function setUp()
    {
        parent::setUp();

        $this->databaseTester = NULL;

        $this->getDatabaseTester()->setSetUpOperation($this->getSetUpOperation());
        $this->getDatabaseTester()->setDataSet($this->getDataSet());
        $this->getDatabaseTester()->onSetUp();
    }
}

モチのロン何が書いてあるかはわかりませんが、
getDataSet()がユーザー定義のテーブル状態を返すので、その前が空にする処理だと考えてみる。


    protected function getSetUpOperation()
    {
        return PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT();
    }

何が書いているのかは分からないが、CLEAN_INSERTという字面は初期化に見える。

composer\vendor\phpunit\dbunit\src\Extensions\Database\Operation\Factory.php
class PHPUnit_Extensions_Database_Operation_Factory
{

    /**
     * Returns a clean insert database operation. It will remove all contents
     * from the table prior to re-inserting rows.
     *
     * @param  bool                                                     $cascadeTruncates Set to true to force truncates to cascade on databases that support this.
     * @return PHPUnit_Extensions_Database_Operation_IDatabaseOperation
     */
    public static function CLEAN_INSERT($cascadeTruncates = FALSE)
    {
        return new PHPUnit_Extensions_Database_Operation_Composite([
            self::TRUNCATE($cascadeTruncates),
            self::INSERT()
        ]);
    }

}


$cascadeTruncates = FALSEという引数にtrueを渡してやれば良さそうだ。
しかし、setUpでは引数なしで実行してしまっているから、コピペして手動でtrueを渡してやろう。

parent::setUp()が呼び出せなくなる都合、Traitでしているparent::setUp()も実行されなくなるが、今のバージョンではより先祖のsetUp()には実装が無いので今は問題が無い。

仮結論

  • PHPUnit_Extensions_Database_TestCaseクラスの初期化処理では、外部キー制約があるとうまく初期化できない場合がある
  • オプションでTRUNCATE処理にCASCADEを追加できるが、外部からこのオプションは制御できない
  • よって、parent::setUp()ではなく、テストケースに処理をコピペし、カスケードトランケートを有効化してやる

class HogeTest extends \PHPUnit_Extensions_Database_TestCase
{
    public function setUp()
    {
        // cascade削除のために手動で実行している
        $this->databaseTester = NULL;
        $this->getDatabaseTester()->setSetUpOperation(\PHPUnit_Extensions_Database_Operation_Factory::CLEAN_INSERT(true));
        $this->getDatabaseTester()->setDataSet($this->getDataSet());
        $this->getDatabaseTester()->onSetUp();
    }
}

これで上手く行った。

記事を書く

この記事を書く。
見た資料を書くためにもう一度調べなおす。
前は目に入らなかった記事が目に入ってくる。

TRUNCATE fails on tables which have foreign key constraints · Issue #37 · sebastianbergmann/dbunit

スレ本文と最初のレスからまたMySQLかと思い、それ以後はよくわからないやり取りですが、今見ると、2レス目の意味がわかりますよね。

コピペなんてつらいなーと思ったら、ただのオーバーライドで引数をtrueにすればいいもよう。

こんな簡単なことにも気づかないなんて。

当時はオーバーライドの副作用を懸念したのかな?

最後の

なんてまさにPostgresの話題。MySQLの話題だと思って気を抜くことなかれ。

1レス目の
PHPUnit_Extensions_Database_Operation_Truncate
を参照すると、まさにTruncateのためのクラスになっているのですが、複雑なので手を出しません。CLEAN_INSERTで間接的に使っているようですし。

よって、結論のコードは冒頭にあるように、記事を書いている途中に、より簡潔になりましたとさ。


実はネットで調べるの項の
php - PHPUnit and MySQL truncation error - Stack Overflow
でも2番めのコード群以降に同じようなコードが書かれているんですけど、この時点であれらのコードはやっぱり目にした瞬間理解を放棄するんですよね。


3
2
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
3
2