DBUnit拡張を使ったFuelPHPのテストを考える

  • 25
    Like
  • 0
    Comment
More than 1 year has passed since last update.

FuelPHP Advent Calendar 2015の6日目を担当する@wataです。昨日は@sharkppさんのNestedSets Model を使って FuelPHP 用コメントボックスパッケージを作った話でした。
本記事ではFuelPHPを用いた開発におけるユニットテスト、特にデータベースまわりに関するテストケースの作成について、保守性の面から色々考えたあれこれを書かせていただければと思います。

FuelPHPのテスト事情

突然ですがみなさん、テスト書いてますか?
PHPはとっても柔軟(?)な言語なので、品質を担保するためにはいつでも実行可能で、軽量なテストによって、動作が常に正しいことを検証できることが望ましいとされています。

PHPの場合、テスティングフレームワークとしてはPHPUnitが有名であり、多くのテスト支援のための機能が実装されています。FuelPHPも例外なくPHPUnitを用いてテストされることを想定しています。
FuelPHPのフレームワークとしてのコアの機能についてはPHPUnitで書かれたテストが付属しており、PHPUnitを導入すればすぐにコアの機能について動作を検証することができます。

ただ、PHPに限った話ではありませんが、この手のユニットテストを行う上で問題になるのはデータベースの状態に依存するテストです。幸いなことに、FuelPHPではテスト実行時のみ接続先データベースを変更することができるので、接続先をテスト時のみ切り替えるような面倒な実装は必要ないのですが、それでも初期データのセットアップ、データの破棄など、本来のテストケースには無関係の「準備のためのプログラム」が大きくなりすぎてテストの可読性が損なわれる心配があります。

この点に関してはPHPUnitは公式にDBUnit拡張を提供することで解決を図っています。これはPHPUnitと一緒に導入することで上述した「準備のためのプログラム」を簡略化します。DBUnit拡張もFuelPHPに問題なく適用できますので、まずはその部分の導入について書きます。

使用する環境

FuelPHP 1.7.3
PHP 5.5.14
PHPUnit 4.8.19
DBUnit 2.0.2

FuelPHPでのPHPUnitとDBUnitの導入

FuelPHPではComposerを使ってパッケージを管理をしていますので、composer.jsonに以下の項目を追記して導入します。

composer.json
{
    "require-dev": {
        "phpunit/phpunit": "4.8.*",
        "phpunit/dbunit": ">=1.2"
    }
}

なお、本記事執筆時点(2015年12月)ではPHPUnitの安定版は5.0なのでこっちを使ったほうがいいかもしれないです。確認した環境はちょっとPHP5.6を用意できなかったので4.8を使ってます。

Composerのアップデートが無事完了したら、FuelPHPにおけるPHPUnitとDBUnit拡張のインストールは終わりです。後は実際のテストケースで使用するだけです。
DBUnit拡張時には継承元となるテストケースの名前がPHPUnit_Framework_TestCaseからPHPUnit_Extensions_Database_TestCaseになります。ただし、FuelPHPで継承元として使用しているTestCaseクラスはPHPUnit_Framework_TestCaseを継承しただけのもの(=同一のクラス)でありますので、DBUnit拡張時のテストケースを使用したい場合には別途DbTestCaseのようなクラスを自分で定義する必要があります。

FuelPHPで自作のクラスをオートロードさせる方法は複数ありますが、TestCaseクラスがコアで定義されているimport関数を使用しているので、それを利用しましょう。fuel/app/classes配下にtestcase.phpを作成します。

fuel/app/classes/testcase.php
<?php

class DbTestCase extends \PHPUnit_Extensions_Database_TestCase { } 

import関数では、fuel/app/classes配下に同名のクラスが存在すれば、それもロードするので、これでテスト時のみ、DbTestCaseクラスが利用できるようになります。

DBUnitを使ってテストを書いてみる

作成したDbTestCaseを継承してテストを作成する場合、最低限の要求事項として、テスト時に接続するデータベースの情報を与えるためのgetConnection()とセットアップするべきデータベースの初期データを指定するgetDataSet()が定義されていなくてはいけません。

初期データはテストケースごとに異なって当たり前なので、getDataSet()は各テストケースに定義するとして、getConncetion()についてはどのテストでも同様のはずなので、DbTestCaseに定義してしまいましょう。

fuel/app/classes/testcase.php
<?php

namespace Fuel\Core;

class DbTestCase extends \PHPUnit_Extensions_Database_TestCase
{
    protected function getConnection()
    {
        $db = \Database_Connection::instance();
        return $this->createDefaultDBConnection($db->connection(), 'データベース名');
    }

    protected function getDataSet() {}
} 

createDefaultDBConnection()の第一引数にはPDOオブジェクトが必要なので、データベースの接続インスタンスを生成して、connection()メソッドでPDOオブジェクトを取得します。

これで、後は通常のDBUnit拡張を使用する場合と同じようにテストケースを作成することができます。例えば、PHPUnitの公式に記載されているようなテストをちょっと拡張したものはこんな感じで記載できます。

fuel/app/tests/model/guestbook.php
<?php

class Test_Guestbook extends DbTestCase
{
    protected function getDataSet()
    {
        return $this->createFlatXmlDataset(APPPATH."tests/fixture/defaultBook.xml");
    }

    public function test_addEntry()
    {
        $guestbook = new Model_Guestbook();
        $guestbook->addEntry("suzy", "Hello world!");

        $queryTable = $this->getConnection()->createQueryTable(
            'guestbook', 'SELECT id, content, user, type FROM guestbook'
        );
        $expectedTable = $this->createFlatXmlDataset(APPPATH."tests/fixture/expectedBook.xml")
                              ->getTable("guestbook");
        $this->assertTablesEqual($expectedTable, $queryTable);
    }
}

使用されるデータセットは以下のようになります。

fuel/app/tests/fixture/defaultBook.xml
<?xml version="1.0" ?>
<dataset>
    <guestbook id="1" content="Hello buddy!" user="joe" type="1" created="2010-04-24 17:15:23" />
    <guestbook id="2" content="I like it!" user="nancy" type="2" created="2010-04-26 12:14:20" />
</dataset>
fuel/app/tests/fixture/expectedBook.xml
<?xml version="1.0" ?>
<dataset>
    <guestbook id="1" content="Hello buddy!" user="joe" type="1" />
    <guestbook id="2" content="I like it!" user="nancy" type="2" />
    <guestbook id="3" content="Hello world!" user="suzy" type="1" />
</dataset>

createdはテスト実行時に決まる不確定な要素であり、そこまで重要な数値でもないので、QueryTableを生成する際のSQLで無視するようにしています。

DBUnitの問題点

さて、これでFuelPHPでも他のPHPプログラム同様にテストができるようになりましたが、個人的にはテストデータの管理の方法にいくつか問題点があると考えています。

テーブルのデータがテストケース別のファイルにそれぞれ定義されている

DataSetをセットアップする際に、外部のXMLファイルを読み込みますが、これらのデータはテスト内容に応じて分かれており、テーブル別には分離していません。
例えば、guestbookテーブルとuserテーブルの両方をセットアップする場合には、ひとつのXMLファイルにそれぞれのテーブルのデータをすべて定義する必要があります。このようなファイルが複数あると、特定のテーブルのデータ構造を変更した場合に、修正範囲がわかりにくく、管理がしにくくなります。

テスト用の初期データと想定データが一部重複している

サンプルを見てもらえればわかるかと思いますが、defaultBook.xmlexpectedBook.xmlのレコードのうち、2つがまったく同じレコードを示しています。
無駄に同じデータを保持するのは修正時のコストが増えますので、好ましくありません。

スキーマ変更時の修正範囲が広い

仮にguestbookにユーザの所属するグループを示すgroupカラムを追加するとしましょう。すると、まずテストデータのXMLファイルにgroup属性を追加して、QueryTableのSQLを変更して・・・
この小さなテストケースではわかりにくいですが、テストケースの数が増えれば増えるほど修正コストが高くなることは想像に難くないと思います。

マジックナンバー

XMLファイルにテストデータを定義すると、当然のことながらPHPの定数などの仕組みは利用できなくなります。マジックナンバーの苦しみは今更説明するまでもないと思いますが、同様の問題がテストデータにも発生します。
例えば、今回のテストケースではtypeというカラムはユーザの種類(友人なのか?同僚なのか?)を示しているつもりなのですが、これを意味する数値が変わったら、すべてをいちいち直すのは面倒臭いですよね。

ではどうするべきなのか

答えは複数あると思うのですが、個人的には以下のような対応を考えました。

arrayDataSetを使用する

arrayDataSetとは、PHPの配列を使ってデータセットを作成するものです。
当然PHPなので、定数が利用できます。

fuel/app/tests/model/guestbook.php
protected function getDataSet()
{
    return $this->createArrayDataset([
        'guestbook' => [
            [
                'id' => 1,
                'content' => 'Hello buddy!',
                'user' => 'joe',
                'type' => FRIEND_GUEST,
                'created' => '2010-04-24 17:15:23',
            ],
            [
                'id' => 2,
                'content' => 'I like it!',
                'user' => 'nancy',
                'type' => COLLEAGUE_GUEST,
                'created' => '2010-04-26 12:14:20',
            ],
        ]
    ]);
}

これで、マジックナンバーが排除され、typeについて明確な意味が生まれました。

テーブル別にフィクスチャをクラス化してまとめる

arrayDataSetを使うのであれば、テーブル別にフィクスチャクラスを作成して、メソッド別に供給するデータを変えます。

fuel/app/tests/fixture/guestbook.php
<?php

class Fixture_Guestbook
{
    public static function defaultDataset()
    {
        return [
            'guestbook' => [
                [
                    'id' => 1,
                    'content' => 'Hello buddy!',
                    'user' => 'joe',
                    'type' => FRIEND_GUEST,
                    'created' => '2010-04-24 17:15:23',
                ],
                [
                    'id' => 2,
                    'content' => 'I like it!',
                    'user' => 'nancy',
                    'type' => COLLEAGUE_GUEST,
                    'created' => '2010-04-26 12:14:20',
                ],
            ]
        ];
    }

    public static function expectedDataset()
    {
        return [
            'guestbook' => [
                [
                    'id' => 1,
                    'content' => 'Hello buddy!',
                    'user' => 'joe',
                    'type' => FRIEND_GUEST,
                    'created' => '2010-04-24 17:15:23',
                ],
                [
                    'id' => 2,
                    'content' => 'I like it!',
                    'user' => 'nancy',
                    'type' => COLLEAGUE_GUEST,
                    'created' => '2010-04-26 12:14:20',
                ],
                [
                    'id' => 3,
                    'content' => 'Hello world!',
                    'user' => 'suzy',
                    'type' => FRIEND_GUEST,
                    'created' => '2010-05-01 09:56:12',
                ],
            ]
        ];
    }
}

使用側はこんな感じで記述します。

fuel/app/tests/model/guestbook.php
<?php

class Test_Guestbook extends DbTestCase
{
    protected function getDataSet()
    {
        return $this->createArrayDataset(Fixture_Guestbook::defaultDataset());
    }

    public function test_addEntry()
    {
        $guestbook = new Model_Guestbook();
        $guestbook->addEntry("suzy", "Hello world!");

        $queryTable = $this->getConnection()->createQueryTable(
            'guestbook', 'SELECT id, content, user, type FROM guestbook'
        );
        $expectedTable = $this->createArrayDataset(Fixture_Guestbook::expectedDataset())
                              ->getTable("guestbook");
        $this->assertTablesEqual($expectedTable, $queryTable);
    }
}

このようにPHPファイルのメソッドを呼び出す形でデータを供給するようにすれば、供給時にデータを柔軟に変形させることができるようになります。また、これを用いることでテストデータをそれぞれテーブルごとにまとめることができるようになります。
例えば、userテーブルのデータが必要であれば、それぞれFixture_Guestbook::defaultDataset()Fixture_User::defaultDataset()の返り値をArr::merge()などでマージして供給するようにすれば、guestbookcontentの生成ルールが変わったような場合にも、fuel/app/tests/fixture/guestbook.phpだけを修正すればよくなります。

追加データは追加分だけ定義してArr::merge()して渡す

上記の前提があれば、初期データと想定データに重複が発生する問題もクリアできます。
Fixture_Guestbookクラスを以下のように修正します。

fuel/app/tests/fixture/guestbook.php
<?php

class Fixture_Guestbook
{
    public static function defaultDataset()
    {
        return [
            'guestbook' => [
                [
                    'id' => 1,
                    'content' => 'Hello buddy!',
                    'user' => 'joe',
                    'type' => FRIEND_GUEST,
                    'created' => '2010-04-24 17:15:23',
                ],
                [
                    'id' => 2,
                    'content' => 'I like it!',
                    'user' => 'nancy',
                    'type' => COLLEAGUE_GUEST,
                    'created' => '2010-04-26 12:14:20',
                ],
            ]
        ];
    }

    public static function expectedAddDataset()
    {
        return [
            'guestbook' => [
                [
                    'id' => 3,
                    'content' => 'Hello world!',
                    'user' => 'suzy',
                    'type' => FRIEND_GUEST,
                    'created' => '2010-05-01 09:56:12',
                ],
            ]
        ];
    }
}

使用側はこのようになります。

fuel/app/tests/model/guestbook.php
<?php

class Test_Guestbook extends DbTestCase
{
    protected function getDataSet()
    {
        return $this->createArrayDataset(Fixture_Guestbook::defaultDataset());
    }

    public function test_addEntry()
    {
        $guestbook = new Model_Guestbook();
        $guestbook->addEntry("suzy", "Hello world!");

        $queryTable = $this->getConnection()->createQueryTable(
            'guestbook', 'SELECT id, content, user, type FROM guestbook'
        );
        $expectedDataset = Arr::merge(Fixture_Guestbook::defaultDataset(), Fixture_Guestbook::expectedAddDataset());
        $expectedTable = $this->createArrayDataset($expectedDataset)
                              ->getTable("guestbook");
        $this->assertTablesEqual($expectedTable, $queryTable);
    }
}

Arr::filter_keys()を使ってテスト対象のカラムを動的に決定する

Fixture_Guestbookクラスにテストしたいカラムを定義した$_testable_propertiesを宣言します。これを元にArr::filter_keys()でフィルタしてからデータを供給するようにすると、今後このカラムを変更することでテスト対象のカラムを簡単に変更できるようになります。

fuel/app/tests/fixture/guestbook.php
<?php

class Fixture_Guestbook
{
    protected static $_testable_properties = [
        'id',
        'content',
        'user',
        'type',
    ];

    public static function provideTestableProperties()
    {
        return self::$_testable_properties;
    }

    ...
}
fuel/app/tests/model/guestbook.php
<?php

class Test_Guestbook extends DbTestCase
{
    protected function getDataSet()
    {
        return $this->createArrayDataset(Fixture_Guestbook::defaultDataset());
    }

    public function test_addEntry()
    {
        $guestbook = new Model_Guestbook();
        $guestbook->addEntry("suzy", "Hello world!");

        $testableQuery = 'SELECT '.implode(',', Fixture_Guestbook::provideTestableProperties()).' FROM guestbook';
        $queryTable = $this->getConnection()->createQueryTable(
            'guestbook', $testableQuery
        );
        $expectedDataset = Arr::merge(Fixture_Guestbook::defaultDataset(), Fixture_Guestbook::expectedAddDataset());
        $properties = Fixture_Guestbook::provideTestableProperties();
        $testableDataset = ['guestbook' => []];
        foreach ($expectedDataSet['guestbook'] as $record) {
            $record = Arr::filter_keys($record, $properties);
            array_push($testableDataset['guestbook'], $record);
        }
        $expectedTable = $this->createArrayDataset($testableDataset)
                              ->getTable("guestbook");
        $this->assertTablesEqual($expectedTable, $queryTable);
    }
}

これで仮にguestbookgroupカラムを追加したい場合にも、データセットを変更し、$_testable_propertiesgroupを追加するだけで他の修正が不要になります。データベースのスキーマ変更に対して、データを定義するFixture_Guestbookを変更するだけで完結するわけです。しっかり役割が分離されて、とてもすっきりします。

これだけ色々やると、テストメソッドの中身がごちゃごちゃしすぎてしまうので、共通化できそうな部分はスーパークラスにまとめてFixture_Guestbookで継承するようにするとなおよいかもしれません。

まとめ

arrayDataSetがデータを管理する上では一番良い気がします。今回はFuelPHPの記事なので、その前提で書きましたが、他のPHPのテストでも同様の考え方が使えるはずです。
FuelPHPはこういった拡張が簡単にできるのでよいですね。

テストの記事を真面目に書くにあたって、全然調べても出てこないのでもっとみんなPHPのテストについて色々書けばいいと思います。。

参考

https://phpunit.de/manual/4.8/ja/database.html
http://d.hatena.ne.jp/Kenji_s/20111110/1320922825
http://tech.aainc.co.jp/archives/1314

This post is the No.6 article of FuelPHP Advent Calendar 2015