旅するえんじにあ - Engineers to Travel -

旅するエンジニアの気まま備忘録

【PHP】 苦悩の末導入したPHPUnitとDBUnit

やっと忙しい時期がある程度落ち着いたので 久々の更新です。

もちろんコードも書いていますが やはり運用をしていくうえで必要なのがテストです。

これは初めすごく面倒なのですが、一度やってしまうと テストなしではコードが書けなくなります。 自分も最初これを言われた時には何を言ってんだ。。。 と思ったのですが、いざやってみるとまさに・・・

特に今回は有名フレームワークではなく、 MonobitエンジンにおけるMWFフレームワークという 独自フレームワークに近いものにインストールしていきます。

さて導入から躓いた場所、解決策を書いていきます。

インストールはComposerで行います。

{
    "require-dev": {
        "phpunit/phpunit": "3.7.*",
    "phpunit/dbunit": ">=1.3,<1.4",
        "phpunit/phpunit-skeleton-generator": "*",
    "piece/stagehand-testrunner": "~4.1@dev",
    "davedevelopment/phpmig": "*"
    }
}

今回はvendor以下にインストールを行いました。

次に設定周りです。

PHPUnitではphpunit.xmlを指定することによって 色々な設定ができます。

今回目的としては

「独自フレームワークでDBにテストデータを入れつつ、正常系、異常系のテストを行う」

です。

今更ですがテストの大まかな流れとしては

・必要データをDBに投入 ・必要引数の設定と返り値における期待値を設定 ・methodに問い合わせ期待値と一致するか確認

上記を自動化してくれます。

さて、続きです。

ということで目的からするに必要な情報としては 既に作ってあるmethodを見に行くのでdefineされているPathの読み込みや DBのユーザ、ホスト情報等が必要になってくると思います。 それを設定するのがphpunit.xmlです。

ということで作っていきます。

<phpunit colors="true" verbose="true" bootstrap="[APP_PATH]/mwf/vendor/phpunit/UnitTestEntry.php">
    <php>
        <var name="DB_DSN" value="mysql:dbname=[DBName];host=[HOST]" />
        <var name="DB_USER" value="[DB_USER]" />
        <var name="DB_PASSWD" value="[DB_PASSWD]" />
        <var name="DB_NAME" value="[DB_NAME]" />
        <env name="ENV" value="[ENV]" />
        <ini name="display_errors" value="on" />
    </php>
    <testsuites>
        <testsuite name="Unit Test">
            <directory>./tests</directory>
        </testsuite>
    </testsuites>
</phpunit>

こんな感じで設定しました。 上から見て行きましょう。

[bootstrap] ここには初期に読み込ませるフレームワークのbootstrapやentryのファイルpathを指定します。

[DB_DSN] mysql:dbname=[DBName];host=[HOST] 上記の形でDB名とホストを指定します。

[DB_USER][DB_PASSWD][DB_NAME] DBのユーザ名パスワード、DBの名前を指定します。

[ENV] Unitテストを走らせる上で設定上色々と変更する場合があるので 環境変数にはPHPUnit等の環境変数を指定します。 これによって後で記述しますが、ConfigやDBManager等に追加が必要です。

さて早速動かそうと思ったところで問題が。 skelgenでテストを作ろうと思ったのだが、mwfフレームワークで引き継ぎを行ったソースの entry.phpに書かれているソースが以下のようなものだった。

define( 'APP_ROOT',          \realpath( \dirname( $_SERVER[ 'SCRIPT_FILENAME' ] ) . '/..' ) );

そうAPP_ROOTが実行した場所のpathを返すようになっている。 このままだと実行phpunit自体のpathが取得され、動かない。 だからといって変更するとプログラム全体のPathが変わり、動かなくなる可能性がある。

それは困るので、entry.phpをこちらで自作してphpunitを動かす時にそちらを読み込むように変更する。 そこで使うのがbootstrapだ。 phpunit.xmlに自作したphpunit用のentry.phpを読み込ませる。

そして無事テストケースが出来上がった。 テストを行う前に、まずDBUnitの設定が必要になる。

要はテスト用に使うテーブルを通常使っているDBと分けてテスト専用DBを使うというところ。 こちらは以下のようなファイルを作って対応した。

また、DBに入れるデータについてはyamlを使うようにした。 もちろんPHPUnitではXMLjsonでのデータも作成できる。

見やすいからymlがいいのかねって話でそうなったんだけどね。

CommonDatabaseTest.php

<?php

require_once "PHPUnit/Extensions/Database/TestCase.php";
require_once "PHPUnit/Extensions/Database/DataSet/YamlDataSet.php";

class CommonDatabaseTest extends PHPUnit_Extensions_Database_TestCase
{

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

    /**
     * @var PHPUnit_Extensions_Database_DB_IDatabaseConnection のインスタンス生成は、テストごとに一度だけ
     */
    public $conn = null;

    /**
      * データベースに接続
      */
    public function getConnection()
    {
        if ( $this->conn === null ) {
            if ( self::$pdo == null ) {
                self::$pdo = new PDO($GLOBALS['DB_DSN'], $GLOBALS['DB_USER'], $GLOBALS['DB_PASSWD']);
                self::$pdo->query('SET NAMES UTF8');
            }
            $this->conn = $this->createDefaultDBConnection(self::$pdo, $GLOBALS['DB_NAME']);
        }

        return $this->conn;
    }

    /**
      * フィクスチャを読み込んで、データベースを初期化する
      *
      * @return PHPUnit_Extensions_Database_DataSet_CompositeDataSet
      */
    public function getDataSet($fixtureFile)
    {
        $fixturePath = APP_ROOT . '/tests/fixtures/' . $fixtureFile;
        return new PHPUnit_Extensions_Database_DataSet_YamlDataSet($fixturePath);
    }
}

PHPUnit_Extensions_Database_TestCaseを継承し、CommonDatabaseTestを作りました。 自分の実装はこちらのファイルに記述するようにします。 今後作るテストファイルもこのCommonDatabaseTestを継承して作るようにします。

getConnectionにはphpunit.xmlで指定した内容が入り getDataSetではfixtureFileのpathを引数に入れてmethod毎にfixtureのファイルを用意するようになる。 要はそのmethod毎にテーブルの出し入れをしつつ、柔軟なテストができるということ。

ここで簡単にディレクトリ構造だけを書いておく。

APP_ROOTとなるディレクトリに対して testsというディレクトリを作った。 この中にあるファイルを読み込んでテストを行うようにする。 また、その他fixtureについてもここに入れるルールとした。

tests以下は以下の通りである。

[root@localhost tests]# tree
.
├── fixtures
│   └── TestDaoModelTest             // ディレクトリ名はテストするファイル名
│       ├── testGetAll.yml           // method毎にymlファイルを作成
│       ├── testGetTestById.yml
│       └── testTestTest.yml
├── model
│   └── dao
│       └── TestDaoModelTest.php     // テストが書かれているファイル
└── sql                              //テストで必要となるだろうテーブルはここに置いておく
    ├── create_table.sql
    └── test.sql

説明はコメントに書いた通りなんだけど、用意するのは大まかに2つ テストに必要なfixtureファイルと実際にテストを行うファイル SQLについては便利かもしれないと思って構築用に用意しているものである。

PHPUnitを動かすとDB内の該当テーブルをTRUNCATEし、対象のfixtureファイルをINSERTしてくれる。 なのでテーブルだけ作っておけばOKなのである。

さてこれで終わりかと思いきや、先ほど作ったCommonDatabaseTest.phpにあるように fixtureファイルを渡してあげる必要がある。 そこについてはテストファイルのsetUpでファイル名とmethod名を取得してあげ、渡すようにした。

TestDaoModelTest.php

<?php

require_once VENDOR_ROOT . '/phpunit/dbunit/CommonDatabaseTest.php';
require_once VENDOR_ROOT . '/phpunit/dbunit/PHPUnit/Extensions/Database/DataSet/YamlDataSet.php';
require_once SCRIPTS_ROOT.'/models/dao/TestDaoModel.class.php';

class TestDaoModelTest extends CommonDatabaseTest
{

    /**
     * @var TestDaoModel
     */
    protected $testDaoModel;
    protected $fixtureFile;


    /**
     * Sets up the fixture, for example, opens a network connection.
     * This method is called before a test is executed.
     */
    protected function setUp()
    {
        $fileInfo = pathinfo(__FILE__);
        $this->fixtureFile = $fileInfo['filename'] . '/' . $this->getName() . '.yml';
        parent::setUp($this->fixtureFile);
        $this->testDaoModel = new TestDaoModel();
    }

    /**
     * Tears down the fixture, for example, closes a network connection.
     * This method is called after a test is executed.
     */
    protected function tearDown()
    {
        parent::tearDown($this->fixtureFile);
    }

    /**
     * @covers TestDaoModel::getAll
     */
    public function testGetTestById()
    {
        $data = [
            'Id' => 200,
            'name' => "Bad Request"
        ];

        $result = $this->testDaoModel->getTestById();
        $this->assertEquals($data, $result, 'error');

    }

    /**
     * @covers TestDaoModel::getAll
     */
    public function testGetAll()
    {
        $testDaoModel = new TestDaoModel();
        // Remove the following lines when you implement this test.
        $data = [
            0 => [
                "id" => 1,
                "name" => "test1",
                "created" => "2015-03-10 11:00:00",
                "modified" => "2015-03-11 11:00:00",
                "deleted" => 0
            ],
            1 => [
                "id"=> 2,
                "name"=> "test2",
                "created"=> "2015-03-10 11:00:00",
                "modified"=> "2015-03-11 11:00:00",
                "deleted"=> 0
            ],
            2 => [
                "id"=> 3,
                "name"=> "test3",
                "created"=> "2015-03-10 11:00:00",
                "modified"=> "2015-03-11 11:00:00",
                "deleted"=> 0
            ]
        ];
        $result = $testDaoModel->getAll();
        // $result = false;
        $this->assertEquals($data, $result, 'error');
        // $this->assertEquals(false, $result, 'error');
    }
}

上記のような形のテストファイルを用意した。

ここでまずは先ほどのfixtureファイルの読み込み部分を見ていく。

    /**
     * Sets up the fixture, for example, opens a network connection.
     * This method is called before a test is executed.
     */
    protected function setUp()
    {
        $fileInfo = pathinfo(__FILE__);
        $this->fixtureFile = $fileInfo['filename'] . '/' . $this->getName() . '.yml';
        parent::setUp($this->fixtureFile);
        $this->testDaoModel = new TestDaoModel();
    }

まずテストが走る前にsetUpが走る。 ここでpathinfo関数を使ってファイル名を取得し、$this->getName()を使ってmethod名を取得している。 これで$this->fixtureFileに対してpathを格納する。 そしてparent::setUpに引数をとして渡してある。 また、毎回クラスのインスタンスを作るのは面倒なので最後にインスタンスを作成させる。

parent::setUpは以下のファイルへ続く

vendor\phpunit\dbunit\PHPUnit\Extensions\Database\TestCase.php

    /**
     * Returns the test dataset.
     *
     * @return PHPUnit_Extensions_Database_DataSet_IDataSet
     */
    protected abstract function getDataSet($fixturePath);


    /**
     * Performs operation returned by getSetUpOperation().
     */
    protected function setUp($fixturePath)
    {
        parent::setUp();
        $this->databaseTester = NULL;
        $this->getDatabaseTester()->setSetUpOperation($this->getSetUpOperation());
        $this->getDatabaseTester()->setDataSet($this->getDataSet($fixturePath));
        $this->getDatabaseTester()->onSetUp();
    }

ここのsetUpが呼ばれるのでsetDataSetを行っている$this->getDataSetへpathを渡す 更に同じファイル内にあるgetDataSetへも引数として渡してあげる すると、先ほど作ったCommonDatabaseTestのgetDataSetへと渡す事ができる

さてこれでPHPUnit等の設定はある程度完了です。

次にphpunit.xml環境変数を設定しました。 実際に接続にいくときはテストファイルではなく、テスト対象のファイルにアクセスし、そちらのmethodを使うので そちらの接続先を変更する必要があります。

フレームワーク側のDatabaseManager等に各環境ごとの設定が書かれていると思うので そちらにDBの接続情報を追加し、テスト用のDBへ向けてあげるようにします。

これで大体の設定は完了

実際に以下で実行をします。

$ phpunit tests/

これで走らせたのですが、うまく走らなかったです。 ※正直install後にこのブログを書いているのでちょっと他にも設定必要だったかもしれないのですが・・・

さて、何故走らないかというと。 走ってはいるんです。 ただ、テストの途中で止まるのです。

今回作成したテストファイルについては

testGetTestById このmethodではjsonファイルから情報を引っ張りだしているだけです。

testGetAll 問題だったのはこれ。 DBに入っているtestテーブルの内容を全て引っ張り出すという処理です。

処理的には動くのですが、テストの途中でwaitがかかったようにうんともすんとも言わないのです。

PHPUnitに問題があるのか? 独自で実装したyml読み込みが悪いのか? 何か設定が悪いのか?

悩みに悩んでPHPUnitのソースを追っていく事に。

実行した所から処理が流れるところを確認してどうやらPHPUnitは正常な処理をしているはずが TruncateしようとしてるところでMySQLのテーブルがロックされているような挙動が見られました。

まずはMySQLに対してPHPUnitはどういったQueryを投げているか調べました。

MySQLのプロセスリストをリアルタイムで見たかったので以下のコマンドで覗いてみました。

$ watch -n1 'mysql -uroot -e "show full processlist"'

すると2つのプロセスが立ち上がり、一つはstatus sleepとなっており、やはりロックされている状態でした。 にしても何故2つもプロセスが立あがるのだろうと次はQueryを調べることに。 MySQLの設定でQueryログを出すように設定をしました。

/etc/my.cnf

[mysqld]
general-log=TRUE
general-log-file=/var/log/mysql/query.log

上記を追加して/var/log以下にmysqlディレクトリを作成してQueryログを出力することに。

そしてMySQL再起動後、PHPUnitを実行してみると以下ログが出てきました。

27 Connect   root@localhost on
27 Query     select @@version_comment limit 1
27 Query     show full processlist
27 Quit
28 Connect   test@localhost on test
28 Query     SET NAMES UTF8
28 Query     TRUNCATE `test`
28 Query     SHOW COLUMNS FROM `test`
28 Query     SHOW INDEX FROM `test`
28 Query     INSERT INTO `test` (`id`, `name`, `created`, `modified`, `deleted`) VALUES (NULL, 'test1', '2015-03-10 11:00:00', '2015-03-11 11:00:00', '0')
28 Query     INSERT INTO `test` (`id`, `name`, `created`, `modified`, `deleted`) VALUES (NULL, 'test2', '2015-03-10 11:00:00', '2015-03-11 11:00:00', '0')
28 Query     INSERT INTO `test` (`id`, `name`, `created`, `modified`, `deleted`) VALUES (NULL, 'test3', '2015-03-10 13:00:00', '2015-03-11 13:00:00', '0')
28 Query     TRUNCATE `test`
28 Query     SHOW COLUMNS FROM `test`
28 Query     SHOW INDEX FROM `test`
28 Query     INSERT INTO `test` (`id`, `name`, `created`, `modified`, `deleted`) VALUES (NULL, 'test1', '2015-03-10 11:00:00', '2015-03-11 11:00:00', '0')
28 Query     INSERT INTO `test` (`id`, `name`, `created`, `modified`, `deleted`) VALUES (NULL, 'test2', '2015-03-10 11:00:00', '2015-03-11 11:00:00', '0')
28 Query     INSERT INTO `test` (`id`, `name`, `created`, `modified`, `deleted`) VALUES (NULL, 'test3', '2015-03-10 11:00:00', '2015-03-11 11:00:00', '0')
29 Connect   test@localhost on test
29 Query     set autocommit=0
29 Query     SELECT * FROM test
28 Query     TRUNCATE `test`

ID27 についてはshow full processlistを実行しているだけなので置いといて ID28 がPHPUnitでfixtureを入れているログになります。 そしてID29 が実際テストファイルからテスト対象methodを呼び出して問い合わせしているところです。

注目すべきはset autocommit=0というところです。

このコマンドをフレームワーク側で投げていて、autocommitを無効にしていました。

これによって一番最後のTRUNCATE testが実行できず止まっていたようでした。

実際フレームワークにてautocommit無効処理を見つけ、phpunit.xmlに記載している ENVだった場合はその処理を行わないように書き換えました。

これにより無事UnitTestが可能に!

いやーハマった。

これから正常、異常系のテストをどう書くか memcacheやrandを使った時のテストをどうするか等 まだまだ問題は山積みですが、少しずつ解決していこうと思います。

テストないコードは書いていて気持ち悪いし、debugもしずらいので これで晴れてテストし、品質を保持した開発ができるっ!

MWFフレームワークを使った開発をしている人は少ないとは思いますが 独自フレームワークやテストの機構を持っていないフレームワークは数多あると思います。 そんな人の役に立てれば。

さーて次のタスクいくかー