PHPUnit3で始めるユニットテスト

第5回PHPUnitの便利な機能とPhingとの連携

今回は、PHPUnit3の便利な機能とPHP版プロジェクトビルドシステムであるPhingとの連携について見ていきます。

既存クラスからテストを作成する

さて、別の開発チームで作成していた決済用クラス(Checkoutクラス)が届きました。

<?php
require_once 'Cart.php';

class Checkout {
    private $cart;

    public function __construct(Cart $cart) {
        $this->cart = $cart;
    }

    public function getSubTotal() {
        return $this->cart->getTotal();
    }

    public function getShippingCharge() {
        if ($this->cart->getTotal() > 1500) {
            return 0;
        } else {
            return 315;
        }
    }

    public function getTotal() {
        return $this->cart->getTotal() + $this->getShippingCharge();
    }

    public function getCart() {
        return $this->cart;
    }
}

しかし、テストが用意されていません。これもありがちな話…でしょうか。このCheckoutクラスに対してもテストを用意しておきたいところですが、1から作成するのはちょっと面倒です。

PHPUnit3には既存クラスから自動的にテストケースを生成するジェネレータが用意されています。今回はこれを使ってテストケースを作ってみましょう。

既存クラスからテストケースを生成するには、phpunitコマンドに「--skeleton」オプションとクラス名を指定します。

$ phpunit --skeleton Checkout
PHPUnit 3.1.7 by Sebastian Bergmann.

Wrote test class skeleton for "Checkout" to "CheckoutTest.php".
$ 

生成されたCheckoutTest.phpは以下の通りです。

<?php
// Call CheckoutTest::main() if this source file is executed directly.
if (!defined('PHPUnit_MAIN_METHOD')) {
    define('PHPUnit_MAIN_METHOD', 'CheckoutTest::main');
}

require_once 'PHPUnit/Framework.php';

require_once 'Checkout.php';

/**
 * Test class for Checkout.
 * Generated by PHPUnit on 2007-08-23 at 15:27:59.
 */
class CheckoutTest extends PHPUnit_Framework_TestCase {
    /**
     * Runs the test methods of this class.
     *
     * @access public
     * @static
     */
    public static function main() {
        require_once 'PHPUnit/TextUI/TestRunner.php';

        $suite  = new PHPUnit_Framework_TestSuite('CheckoutTest');
        $result = PHPUnit_TextUI_TestRunner::run($suite);
    }

    /**
     * Sets up the fixture, for example, opens a network connection.
     * This method is called before a test is executed.
     *
     * @access protected
     */
    protected function setUp() {
    }

    /**
     * Tears down the fixture, for example, closes a network connection.
     * This method is called after a test is executed.
     *
     * @access protected
     */
    protected function tearDown() {
    }

    /**
     * @todo Implement testGetSubTotal().
     */
    public function testGetSubTotal() {
        // Remove the following lines when you implement this test.
        $this->markTestIncomplete(
          'This test has not been implemented yet.'
        );
    }

    /**
     * @todo Implement testGetShippingCharge().
     */
    public function testGetShippingCharge() {
        // Remove the following lines when you implement this test.
        $this->markTestIncomplete(
          'This test has not been implemented yet.'
        );
    }

    /**
     * @todo Implement testGetTotal().
     */
    public function testGetTotal() {
        // Remove the following lines when you implement this test.
        $this->markTestIncomplete(
          'This test has not been implemented yet.'
        );
    }

    /**
     * @todo Implement testGetCart().
     */
    public function testGetCart() {
        // Remove the following lines when you implement this test.
        $this->markTestIncomplete(
          'This test has not been implemented yet.'
        );
    }
}

// Call CheckoutTest::main() if this source file is executed directly.
if (PHPUnit_MAIN_METHOD == 'CheckoutTest::main') {
    CheckoutTest::main();
}
?>

生成されたCheckoutTest.phpのtestXXXXXメソッドを見てみると、markTestIncompleteメソッドが呼び出されていることが分かります。このメソッドは、名前の通り、そのテストがまだ完成していないことを表すために使われます。実際にCheckoutTestを実行してみると、テスト結果に「I」と表示されることが分かります。

$ phpunit CheckoutTest
PHPUnit 3.1.7 by Sebastian Bergmann.

IIII

Time: 0 seconds


OK, but incomplete or skipped tests!
Tests: 4, Incomplete: 4.
$ 

それでは、CartクラスとCartTest.php、CheckoutTest.phpの両テストを修正していきます。まずはCartクラスです。CartクラスにはCheckoutオブジェクトを返すcheckoutメソッドを追加しましょう。

<?php
class Cart
{
                 :

    public function checkout() {
    }
}

また、テストは次のように記述しました。

<?php
require_once 'PHPUnit/Framework.php';
require_once 'Cart.php';

class CartTest extends PHPUnit_Framework_TestCase
{
                :
    public function testCheckout() {
        $this->assertEquals('Checkout', get_class($this->cart->checkout()));
    }
}

それでは、Cart側のテストを実行してみます。

$ phpunit CartTest
 PHPUnit 3.1.7 by Sebastian Bergmann.

...........F

Time: 0 seconds

There was 1 failure:

1) testCheckout(CartTest)
Failed asserting that two strings are equal.
expected string <Checkout>
difference      <????????>
got string      <>
/home/shimooka/public_html/gihyo.jp/CartTest.php:177

FAILURES!
Tests: 12, Failures: 1.
$ 

それでは、checkoutメソッドを実装します。単純にCheckoutオブジェクトを生成して返すだけのコードです。

<?php
class Cart
{
                :
    public function checkout() {
        include_once 'Checkout.php';
        return new Checkout($this);
    }
}

それではテストを実行します。

$ phpunit CartTest
 PHPUnit 3.1.7 by Sebastian Bergmann.

............

Time: 0 seconds


OK (12 tests)
$ 

問題なさそうです。

それでは、CheckoutTestクラスの修正に入ります。ここではgetShippingChargeメソッドの境界値テストがポイントになりそうですね。また、Cartクラスはすでに存在していますので、これを使ってモックオブジェクトを生成し、テストを行うことにします。

以下が、修正後のテストケースの抜粋です。

<?php
                :

class CheckoutTest extends PHPUnit_Framework_TestCase {
                :
    public function testGetSubTotal() {
        $cart = $this->createEmptyCart();
        $checkout = new Checkout($cart);
        $this->assertEquals(0, $checkout->getSubTotal);

        $cart = $this->createCartTotal1500();
        $checkout = new Checkout($cart);
        $this->assertEquals(1500, $checkout->getSubTotal);

        $cart = $this->createCartTotal1501();
        $checkout = new Checkout($cart);
        $this->assertEquals(1501, $checkout->getSubTotal);
    }

    public function testGetShippingCharge() {
        $cart = $this->createEmptyCart();
        $checkout = new Checkout($cart);
        $this->assertEquals(315, $checkout->getShippingCharge);

        $cart = $this->createCartTotal1500();
        $checkout = new Checkout($cart);
        $this->assertEquals(315, $checkout->getShippingCharge);

        $cart = $this->createCartTotal1501();
        $checkout = new Checkout($cart);
        $this->assertEquals(0, $checkout->getShippingCharge);
    }

    public function testGetTotal() {
        $cart = $this->createEmptyCart();
        $checkout = new Checkout($cart);
        $this->assertEquals(315, $checkout->getTotal);

        $cart = $this->createCartTotal1500();
        $checkout = new Checkout($cart);
        $this->assertEquals(1815, $checkout->getTotal);

        $cart = $this->createCartTotal1501();
        $checkout = new Checkout($cart);
        $this->assertEquals(1501, $checkout->getTotal);
    }

    public function testGetCart() {
        $cart = $this->createEmptyCart();
        $checkout = new Checkout($cart);
        $this->assertTrue($cart === $checkout->getCart());

        $cart = $this->createCartTotal1500();
        $checkout = new Checkout($cart);
        $this->assertTrue($cart === $checkout->getCart());

        $cart = $this->createCartTotal1501();
        $checkout = new Checkout($cart);
        $this->assertTrue($cart === $checkout->getCart());
    }

    private function createEmptyCart() {
        include_once 'Cart.php';
        $cart = $this->getMock('Cart');
        $cart->expects($this->any())
             ->method('getTotal')
             ->will($this->returnValue(0));
        return $cart;
    }

    private function createCartTotal1500() {
        include_once 'Cart.php';
        $cart = $this->getMock('Cart');
        $cart->expects($this->any())
             ->method('getTotal')
             ->will($this->returnValue(1500));
        return $cart;
    }

    private function createCartTotal1501() {
        include_once 'Cart.php';
        $cart = $this->getMock('Cart');
        $cart->expects($this->any())
             ->method('getTotal')
             ->will($this->returnValue(1501));
        return $cart;
    }
                :

テストを実行してみます。

$ phpunit CheckoutTest
PHPUnit 3.1.7 by Sebastian Bergmann.

....

Time: 0 seconds


OK (4 tests)
$ 

こちらが想定したテストをすべてクリアしています。

複数のテストをまとめて実行する

ここまで2つのテストケースを作成し、それぞれテストを実行してきましたが、テストケースが増えてくると個別に実行していては時間がいくらあっても足りません。PHPUnitにはスイート(Suite)と呼ばれる、テストをまとめて実行する仕組みが用意されています。

最も簡単なスイートのコードは、以下のようになります。

<?php
require_once 'PHPUnit/Framework/TestSuite.php';

class AllTests
{
    public static function suite()
    {
        $suite = new PHPUnit_Framework_TestSuite();

        include_once 'CartTest.php';
        $suite->addTestSuite('CartTest');

        include_once 'CheckoutTest.php';
        $suite->addTestSuite('CheckoutTest');

        return $suite;
    }
}

ポイントは2つあります。

  • PHPUnit_Framework_TestSuiteのインスタンスを返すpublic static suiteというメソッドを用意する
  • suiteメソッドの中で実行するテストを追加する

スイートの実行はテストケースの実行と同様、phpunitコマンドにクラス名(必要であれば、ファイル名も)を指定します。

$ phpunit AllTests.php
 PHPUnit 3.1.7 by Sebastian Bergmann.

................

Time: 0 seconds


OK (16 tests)
$ 

今まで作成した16個すべてのテストが一度に実行されていることが分かるでしょうか?また、あるスイートを別のスイートにまとめて実行する事もできます。この場合、addTestSuiteメソッドに代わりにaddTestメソッドを使用します。以下は少々冗長ですが、先のスイートを2つのスイートに分けたコードです。

<?php
require_once 'PHPUnit/Framework/TestSuite.php';

class AnotherSuiteTests
{
    public static function suite()
    {
        $suite1 = new PHPUnit_Framework_TestSuite();
        include_once 'CartTest.php';
        $suite1->addTestSuite('CartTest');

        $suite2 = new PHPUnit_Framework_TestSuite();
        include_once 'CheckoutTest.php';
        $suite2->addTestSuite('CheckoutTest');

        $suite1->addTest($suite2);

        return $suite1;
    }
}
$ phpunit AllTests.php
 PHPUnit 3.1.7 by Sebastian Bergmann.

................

Time: 0 seconds


OK (16 tests)
$ 

phingとの連携

ここまでPHPUnit3の機能を色々と見てきましたが、Phingというプロジェクトビルドシステムと組み合わせることで、さらに使いやすくなります。

プロジェクトビルドシステムで有名なものとしては、Apache Antを元にして作られています。Apache AntはJavaソースファイルのコンパイルやテストの実行、APIドキュメント(javadoc)の作成などを「タスク」と呼ばれる単位で定義されており、タスクを組み合わせることで様々なバッチ処理を行うことができるようになっています。Phingは、このApache Antを元にして作られており、Apache Antと同様に、テストの実行やphpDocumentorを使ったAPIドキュメントの作成など、様々なツールと連携したバッチ処理を行うことができます。

インストール

PhingはPEARパッケージとして提供されていますので、pearコマンドでのインストールが可能です。具体的には、以下の通りとなります。今回はバージョン2.3.0beta1を使用しました。

$ sudo pear channel-discover pear.phing.info
$ sudo pear install -a phing/phing

インストール後、phingコマンドが利用可能になっていることを確認しておきます。

$ which phing
/usr/local/lib/php5/bin/phing
$ phing -version
Phing version 2.3.0beta1
$ phing -help
phing [options] [target [target2 [target3] ...]]
Options:
  -h -help               print this message
  -l -list               list available targets in this project
  -v -version            print the version information and exit
  -q -quiet              be extra quiet
  -verbose               be extra verbose
  -debug                 print debugging information
  -logfile <file>        use given file for log
  -logger <classname>    the class which is to perform logging
  -f -buildfile <file>   use given buildfile
  -D<property>=<value>   use value for given property
  -find <file>           search for buildfile towards the root of the
                         filesystem and use it

Report bugs to <dev@phing.tigris.org>

$ 

build.xml

Phingは、デフォルトではbuild.xmlというXMLファイルに実行するタスクやバッチ処理を定義することになります。

まずは、簡単な例をお見せします。

<?xml version="1.0" encoding="utf-8"?>
<project name="Shopping Cart" basedir="." default="test">

  <target name="test">
    <phpunit2 haltonfailure="true" printsummary="true">
      <batchtest>
        <fileset dir=".">

          <include name="*Test.php"/>
        </fileset>
      </batchtest>
    </phpunit2>
  </target>

</project>

このbuild.xmlは、カレントディレクトリにある*Test.phpというファイルに対してphpunitを実行するものですが、ここでそれぞれの要素について触れておきます。まず、build.xmlのルート要素はproject要素です。属性として、プロジェクト名や基準となるディレクトリ、phingコマンドに引数が渡されなかった場合に実行する処理(target要素で定義。後述)を指定しています。

<project name="Shopping Cart" basedir="." default="test">

project要素の子要素として定義しているのがtarget要素です。このtarget要素を使って、様々なタスクをまとめます。

  <target name="test">

         :
  </target>

また、上記のbuild.xmlではname属性に「test」を指定していますが、この名前をphingコマンドの引数に指定すると、特定のtargetを実行することができます。

$ phing test

target要素の子要素には「タスク」を指定します。Phingには数多くのタスクが用意されています。上記build.xmlではPHPUnitと連携するphpunit2タスクが含まれています。

  <target name="test">
    <phpunit2 haltonfailure="true" printsummary="true">

      <batchtest>
        <fileset dir=".">
        </fileset>
      </batchtest>

    </phpunit2>
  </target>

それぞれのタスクには子要素を指定できるものがあります。phpunit2タスクも、batchset要素や、さらにその子要素としてfileset要素、include要素などを使用することができます。ここでは、指定したディレクトリ以下にある、特定のパターンにマッチするファイルを対象にテストを行うように記述されています。

なお、PhingにはAPIドキュメントを作成する「phpdoc」タスクのほか、tarやzipを使ったアーカイブの作成、Subversionとの連携など様々なタスクが用意されています。それぞれのタスクの詳細については、PhingのUser Guideを参照してください。

さて、このbuild.xmlを使って、phingを実行してみましょう。

$ phing
Buildfile: /home/shimooka/public_html/gihyo.jp/build.xml

Shopping Cart > test:

  [phpunit2] Tests run: 4, Failures: 0, Errors: 0, Time elapsed: 0.17452 sec
 [phpunit2] Tests run: 12, Failures: 0, Errors: 0, Time elapsed: 0.35853 sec

BUILD FINISHED

Total time: 2.1570 seconds
$ 

いかがでしょうか?テストスイートを使わずに、今まで作成したテストが全て実行されている事が分かると思います。

確かに、PHPUnitのテストスイートを使うことで全てのテストを実行することができるのですが、テストケースを追加するたびにテストスイートを修正する必要があります。このため、テストの実行漏れが問題になる可能性があります。その点、上記のように*Test.phpを対象とするテストが可能になります。

また、テスト結果をHTML形式で出力することも可能です。これには、phpunit2タスクでサポートされているformatterタグとphpunitreportタスクを使用します。以下のbuild.xmlは、先ほどのbuild.xmlにphpunit2タスク・formatterタグ・phpunitreportタスクを適用した例となります。テストを実行し、レポートをカレントディレクトリ直下のreports/testsディレクトリに出力します。

<?xml version="1.0" encoding="utf-8"?>

<project name="Shopping Cart" basedir="." default="test">
  <target name="test">
    <mkdir dir="reports/tests" />

    <phpunit2 haltonfailure="true" printsummary="true">
      <formatter todir="reports" type="xml"/>
      <batchtest>

        <fileset dir=".">
          <include name="*Test.php"/>
        </fileset>
      </batchtest>
    </phpunit2>

    <phpunitreport infile="reports/testsuites.xml" format="frames" todir="reports/tests" styledir="etc"/>
  </target>

</project>

formatterタグ・phpunitreportタスクのその他詳細については、Phingマニュアルを参照してください。

このbuild.xmlを実行してみます。

$ phing
Buildfile: /home/shimooka/public_html/gihyo.jp/build.xml

Shopping Cart > test:

  [phpunit2] Tests run: 4, Failures: 0, Errors: 0, Time elapsed: 0.17598 sec
 [phpunit2] Tests run: 12, Failures: 0, Errors: 0, Time elapsed: 0.36254 sec

BUILD FINISHED

Total time: 2.2896 seconds

$ ls reports/tests/
allclasses-frame.html  index.html           overview-summary.html
default                overview-frame.html  stylesheet.css
$ 

実行結果は先のものと変わりませんが、mkdirタスクで作成したreports/testsディレクトリにHTMLファイルやCSSファイルが作成されていることを確認してください。確認できたら、index.htmlをブラウザで開くと次のような表示がされるはずです。

画像

さらに、カバレッジ解析の結果もHTML形式で出力することも可能です。これにはcoverage-setupタスクとcoverage- reportタスクを使用します。さらに、phpunitタスクのcodecoverageオプションをtrueに設定します。以下は、先ほどのbuild.xmlにカバレッジ解析に関連する設定を追加したものになります。カバレッジ結果は、カレントディレクトリ直下のreports/coverageディレクトリに出力されます。

<?xml version="1.0" encoding="utf-8"?>
<project name="Shopping Cart" basedir="." default="test">

  <target name="test">
    <delete dir="reports" includeemptydirs="true" verbose="true" failonerror="false" />

    <mkdir dir="reports/tests" />
    <mkdir dir="reports/coverage" />

    <coverage-setup database="reports/coverage.db">

      <fileset dir=".">
        <include name="*.php"/>
        <exclude name="*Test.php"/>
      </fileset>

    </coverage-setup>

    <phpunit2 haltonfailure="true" printsummary="true" codecoverage="true">
      <formatter todir="reports" type="xml"/>

      <batchtest>
        <fileset dir=".">
          <include name="*Test.php"/>
        </fileset>
      </batchtest>

    </phpunit2>

    <phpunitreport infile="reports/testsuites.xml" format="frames" todir="reports/tests" styledir="etc"/>

    <coverage-report>
      <report todir="reports/coverage" styledir="/usr/local/lib/php5/pear/data/phing/etc"/>
    </coverage-report>
  </target>

</project>

なお、各タスクの詳細については、Phingマニュアルを参照してください。

しかし、PHPUnit3.1.7にはカバレッジ結果の出力に不具合があり、正しいカバレッジ結果が出力されません。具体的には、PHPUnitTestRunnerクラス([PEARディレクトリ]/phing/tasks/ext/phpunit/PHPUnitTestRunner.php)のrunメソッドでカバレッジ結果をマージしているのですが、その引数が間違っているために発生します。そこで、以下のように修正し、正しくカバレッジ結果を渡すよう修正しておきます[1]⁠。

<?php
                     :
    function run()
    {
                     :
            $coverageInformation = $res->getCodeCoverageInformation();
            
            if (PHPUnitUtil::$installedVersion == 3)
            {
// 以下の行をコメントアウトする
//                 CoverageMerger::merge($this->project, array($coverageInformation[0]['files']));
// 以下のforeach文を追加する
                foreach ($coverageInformation as $info)
                {
                    CoverageMerger::merge($this->project, array($info['files']));
                }
            }
            else
                     :
    }

修正したら、早速このbuild.xmlを実行してみましょう。

$ phing
Buildfile: /home/shimooka/public_html/gihyo.jp/build.xml

Shopping Cart > test:

   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/overview-frame.html
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/index.html
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/allclasses-frame.html
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/default/package-summary.html
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/default/CheckoutTest.html
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/default/package-frame.html
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/default/CartTest.html
   [delete] Deleting directory /home/shimooka/public_html/gihyo.jp/reports/tests/default
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/overview-summary.html
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/tests/stylesheet.css
   [delete] Deleting directory /home/shimooka/public_html/gihyo.jp/reports/tests
   [delete] Deleting /home/shimooka/public_html/gihyo.jp/reports/testsuites.xml
   [delete] Deleting directory /home/shimooka/public_html/gihyo.jp/reports
    [mkdir] Created dir: /home/shimooka/public_html/gihyo.jp/reports/tests
    [mkdir] Created dir: /home/shimooka/public_html/gihyo.jp/reports/coverage
[coverage-setup] Setting up coverage database for 3 files
   [phpunit] Tests run: 4, Failures: 0, Errors: 0, Time elapsed: 2.04162 sec
  [phpunit] Tests run: 12, Failures: 0, Errors: 0, Time elapsed: 6.63770 sec
[coverage-report] Transforming coverage report

BUILD FINISHED

Total time: 11.8971 seconds

$ ls reports/coverage/
allclasses-frame.html  index.html           overview-summary.html
default                overview-frame.html  stylesheet.css
$ 

実行後、mkdirタスクで作成したreports/coverageディレクトリにHTMLファイルやCSSファイルが作成されていることを確認してください。確認できたら、index.htmlをブラウザで開くと次のような表示がされるはずです。

画像

なお、PHPUnit3とPhingでカバレッジの対象とする行のカウント方法が異なるため、結果のパーセンテージが変わってしまうことに注意してください。

Phingには紹介したタスク以外に、APIドキュメントの生成やsvnへのアクセス、PHPスクリプトの構文チェックなど様々なタスクが用意されています。また、独自タスクも作成可能です。Phingとうまく組み合わせ、効果的にプロジェクトをビルドしていきましょう。

おすすめ記事

記事・ニュース一覧