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

第4回モックオブジェクトを使ったテスト

今回はダミーのオブジェクト(モックオブジェクト)を使ったテストについて見ていきます。

モックオブジェクトを使ったテスト

さて、折角完成したCartクラスですが、商品コードの代わりに商品クラス(Itemインターフェースを実装したクラス)を導入し、さらに商品の合計代金も取得できるようにすることになりました。ありがちな話ですね。具体的には、次のようなItemインターフェースが提供されています。商品コードのほかに、商品名や価格が取得可能なようです。

<?php
interface Item {
    public function getName();
    public function getCode();
    public function getPrice();
}

しかし、肝心の実装クラス(ItemImplクラス)自身はまだ作成されていません。こういった場合、どうすればいいでしょうか?ItemImplクラスが作成されるまで待ちますか?確かに、簡単なクラスであればそれもあり得るかも知れません。しかし、対象となっているクラスが複雑な場合や時間的な制限がある場合、そうも言っていられませんね。可能であれば、Cartクラスの修正やテストを進めておきたいところです。

幸い、PHPUnit3には「モックオブジェクト」を生成する機能があります。⁠モック(Mock⁠⁠」には「模造品」⁠偽の」といった意味がありますが、これを使うことで「まだ存在しない」クラスのインスタンスを使ったテストが可能になります。

次に、PHPUnitで提供されているモックオブジェクトの生成機能について説明します。

PHPUnit3のモックオブジェクト生成機能

PHPUnitはPHPのリフレクション機能を使ってモックオブジェクトを生成します。また、生成されるオブジェクトにメソッドを定義したり、そのメソッドの振る舞いを指定することができます。さらに、⁠制約」と呼ばれるクラス(PHPUnit_Framework_Constraintのサブクラス)を使って、期待する回数だけメソッドがコールされたかどうかや、期待した振る舞いをしているかどうかのテストも可能です。

それでは、具体的な生成手順を見ていくことにします。

モックオブジェクトの生成は、PHPUnit_Framework_TestCaseクラスのgetMockメソッドを呼び出すだけです。getMockメソッドのAPIとパラメータについては、以下のようになっています。

object getMock($className, [array $methods, [array $arguments, [string $mockClassName]]])
パラメータ必須意味
$classNameこのクラスのモックオブジェクトを生成する
array $methods 生成するメソッド
array $arguments コンストラクタの引数
string $mockClassName モックオブジェクト自身のクラス名。デフォルトは、Mock_[$className]_[生成時刻(ミリ秒)のハッシュ値]

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

$mock = $this->getMock('Item');

このコードを実行すると内部では以下のようなコードが生成され、そのインスタンスが返されます。以下は、生成されるモックオブジェクトのソース例です。

class Mock_Item_fe459e06 extends Item implements PHPUnit_Framework_MockObject_MockObject {
    private $invocationMocker;

    public function __construct() {
        $this->invocationMocker = new PHPUnit_Framework_MockObject_InvocationMocker($this);
    }

    public function __clone() {
        $this->invocationMocker = clone $this->invocationMocker;
    }

    public function getInvocationMocker() {
        return $this->invocationMocker;
    }

    public function expects(PHPUnit_Framework_MockObject_Matcher_Invocation $matcher) {
        return $this->invocationMocker->expects($matcher);
    }

    public function verify() {
        $this->invocationMocker->verify();
    }
}

お気づきの通り、実際に生成されるオブジェクトは、$classNameで指定したクラスのサブクラスとなります。

メソッドを指定してモックオブジェクトを生成する場合、getMockメソッドの第2引数にメソッド名を配列で指定します。

$mock = $this->getMock('Item', array('getName', 'getCode', 'getPrice'));

内部で生成されるコードは以下のようになり、第2引数で指定したメソッドが生成されていることが分かると思います。以下は、生成されるモックオブジェクトのソース例です。


class Mock_Item_23571fea extends Item implements PHPUnit_Framework_MockObject_MockObject {
    private $invocationMocker;

    public function __construct() {
        $this->invocationMocker = new PHPUnit_Framework_MockObject_InvocationMocker($this);
    }

    public function __clone() {
        $this->invocationMocker = clone $this->invocationMocker;
    }

    public function getInvocationMocker() {
        return $this->invocationMocker;
    }

    public function expects(PHPUnit_Framework_MockObject_Matcher_Invocation $matcher) {
        return $this->invocationMocker->expects($matcher);
    }

    public function verify() {
        $this->invocationMocker->verify();
    }

    public function getName() {
        $args = func_get_args();
        return $this->invocationMocker->invoke(
          new PHPUnit_Framework_MockObject_Invocation($this, "Item", "getName", $args)
        );
    }

    public function getCode() {
        $args = func_get_args();
        return $this->invocationMocker->invoke(
          new PHPUnit_Framework_MockObject_Invocation($this, "Item", "getCode", $args)
        );
    }

    public function getPrice() {
        $args = func_get_args();
        return $this->invocationMocker->invoke(
          new PHPUnit_Framework_MockObject_Invocation($this, "Item", "getPrice", $args)
        );
    }
}

また、既存のクラスやインターフェースを継承・実装したモックオブジェクトを生成することもできます。この場合のPHPコードは

/**
 * Item.phpにはItemクラスもしくはItemインターフェースが定義してある
 */
include_once 'Item.php';
$mock = $this->getMock('Item', array(), array(), 'AnotherItem');

この場合、第4引数にモックオブジェクトの名前を指定していますので、AnotherItemオブジェクトが生成されることになります。なお、前述の通り、モックオブジェクトは指定したクラスのサブクラスになりますので、finalクラスを使ってモックオブジェクトを生成することはできません。また、ItemクラスもしくはItemインターフェースで定義されているメソッドは、モックオブジェクト側でオーバーライドされます。

PHPUnit3のモックオブジェクト機能は、オブジェクトを生成するだけではなく、その振る舞いも任意に変更することが可能です。モックオブジェクトのメソッドの振る舞いを変更するには、

  • 実行回数の制約を設ける
  • メソッド名を指定する
  • 具体的な振る舞いを記述する

を指定する必要があります。

1つ目の「実行回数の制約」には、たとえば「1回のみ呼び出される」「0回以上呼び出される」といったものを指定します。具体的には、PHPUnit_Framework_MockObject_Matcher_Invocationインターフェースを実装したクラスのインスタンスを指定します。具体的なクラスについては、PHPUnitポケットガイドを参照してください。

なお、指定した制約を満たさない場合、テストに失敗したと見なされます。

また、メソッドの振る舞いには、

  • メソッドの戻り値
  • メソッドが投げる例外

のいずれかが指定可能で、PHPUnit_Framework_MockObject_Stubインターフェースを継承したクラスです。具体的なクラスについては、PHPUnitポケットガイドを参照してください。

以下、サンプルコードになります。

<?php
class MockTest extends PHPUnit_Framework_TestCase
{
    public function testMock() {
                     :
        $mock = $this->getMock('Item', array('getName', 'getCode', 'getPrice'));

        /**
         * getNameメソッドは一度だけ呼び出され、文字列「item001」を返す
         */
        $mock->expects($this->once())
             ->method('getName')
             ->will($this->returnValue('item001'));

        /**
         * getCodeメソッドはゼロ回以上呼び出され、1回目の呼び出し時は「001」、
         * 2回目の呼び出し時は「002」、3回目の呼び出し時は「100」を返す。
         * なお、4回目以降はNULLを返す。
         */
        $mock->expects($this->any())
             ->method('getCode')
             ->will($this->onConsecutiveCalls('001', '002', '100'));

        /**
         * getNameメソッドは少なくとも一度は呼び出され、呼び出されると
         * RuntimeExceptionを投げる
         */
        $mock->expects($this->atLeastOnce())
             ->method('getPrice')
             ->will($this->throwException(new RuntimeException()));

                     :
    }
}

また、実行回数以外に、期待した振る舞いをするかをテストするための制約も用意されています。これらの制約は、willメソッドの代わりにwithメソッドを使用し、その引数として利用可能です。

以下、サンプルコードになります。

<?php
class MockTest extends PHPUnit_Framework_TestCase
{
    public function testMock() {
                     :
        $mock = $this->getMock('Item', array('setCode', 'setData'));

        /**
         * setCodeメソッドは第1引数にオブジェクト型、第2引数に42より
         * 大きな数を受け取る
         */
        $mock->expects($this->any())
             ->method('setCode')
             ->with('item001');  // ->with($this->equalsTo('item001')) と等価

        /**
         * setDataメソッドは第1引数に「001」、第2引数にオブジェクト型、
         * 第3引数に42より大きな数を受け取る
         */
        $mock->expects($this->any())
             ->method('setData')
             ->with('001', $this->isType('object'), $this->greaterThan(42));

                     :
    }
}

具体的な制約クラスについては、PHPUnitポケットガイドを参照してください。

CartTestクラスにモックオブジェクトを導入する

それでは、CartTestクラスにモックオブジェクトを導入してみましょう。まず、前述の通り、次のようなItemインターフェースが提供されています。

<?php
interface Item {
    public function getName();
    public function getCode();
    public function getPrice();
}

また、Itemインターフェースを導入することで、Cartクラスのaddメソッドの引数が変更になることとします。

旧)public function add($item_cd, $amount)
新)public function add(Item $item, $amount)

さらに、商品の合計代金を取得するgetTotalメソッドを追加します。

public function getTotal()

まずCartTestクラスから修正していきます。CartTestクラス内でCartクラスのaddメソッドを使用している箇所を見てみると、3種類の商品を扱っている事が分かりますので、3種類のモックオブジェクトを生成するようにしましょう。今回は、商品コード毎にモックオブジェクトを生成するprivateメソッドを追加しました。以下、CartTest.phpの抜粋です。

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

class CartTest extends PHPUnit_Framework_TestCase
{
                 :

    private function createItem001Stub() {
        include_once 'Item.php';
        $mock = $this->getMock('Item');
        $mock->expects($this->any())
             ->method('getName')
             ->will($this->returnValue('item001'));
        $mock->expects($this->any())
             ->method('getCode')
             ->will($this->returnValue('001'));
        $mock->expects($this->any())
             ->method('getPrice')
             ->will($this->returnValue(1200));
        return $mock;
    }

    private function createItem002Stub() {
        include_once 'Item.php';
        $mock = $this->getMock('Item');
        $mock->expects($this->any())
             ->method('getName')
             ->will($this->returnValue('item002'));
        $mock->expects($this->any())
             ->method('getCode')
             ->will($this->returnValue('002'));
        $mock->expects($this->any())
             ->method('getPrice')
             ->will($this->returnValue(2000));
        return $mock;
    }

    private function createItem003Stub() {
        include_once 'Item.php';
        $mock = $this->getMock('Item');
        $mock->expects($this->any())
             ->method('getName')
             ->will($this->returnValue('item003'));
        $mock->expects($this->any())
             ->method('getCode')
             ->will($this->returnValue('003'));
        $mock->expects($this->any())
             ->method('getPrice')
             ->will($this->returnValue(1500));
        return $mock;
    }
}

そして、addメソッドの引数を、先のメソッドを呼び出すコードに変更します。

$this->assertTrue($this->cart->add($this->createItem001Stub(), 1));

また、Itemクラスを導入することで、Cartクラス内部に保持している商品情報の持たせ方も変更する必要がありそうです。これまでは、キーとして商品コード、値として数量を持つハッシュでしたが、これを

  • 第1キーとして商品コード
  • 第2キーとして文字列「amount」もしくは「object」
  • 第2キーが「amount」の場合、値として数量
  • 第2キーが「object」の場合、値としてItemオブジェクト

に変更します。具体的には、以下のようなハッシュとなります。

 $items['item_code_001']['amount']
 $items['item_code_001']['object']

これに伴い、テスト側も修正します。

最終的なCartTestクラスは以下のようになりました。

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

class CartTest extends PHPUnit_Framework_TestCase
{
    protected $cart;

    protected function setUp() {
        $this->cart = new Cart();
    }

    public function testInitCart() {
        $this->assertTrue(is_array($this->cart->getItems()));
        $this->assertEquals(0, count($this->cart->getItems()));
    }

    public function testAdd() {
        $this->assertTrue($this->cart->add($this->createItem001Stub(), 1));
        $this->assertTrue($this->cart->add($this->createItem001Stub(), 0));
        $this->assertTrue($this->cart->add($this->createItem001Stub(), -1));
    }

    public function testAddNotNumeric() {
        try {
            $this->cart->add($this->createItem001Stub(), 'string');
        } catch (UnexpectedValueException $e) {
            return;
        }
        $this->fail();
    }

    public function testAddFloat() {
        try {
            $this->cart->add($this->createItem001Stub(), 1.5);
        } catch (UnexpectedValueException $e) {
            return;
        }
        $this->fail();
    }

    public function testGetAmount() {
        $this->assertEquals(0, $this->cart->getAmount('001'));
        $this->cart->add($this->createItem001Stub(), 1);
        $this->assertEquals(1, $this->cart->getAmount('001'));

        $this->assertEquals(0, $this->cart->getAmount('999'));

        $this->cart->add($this->createItem002Stub(), 1);
        $this->assertEquals(1, $this->cart->getAmount('001'));
        $this->assertEquals(1, $this->cart->getAmount('002'));

        $this->cart->add($this->createItem001Stub(), -1);
        $this->assertEquals(0, $this->cart->getAmount('001'));
        $this->assertEquals(1, $this->cart->getAmount('002'));
    }

    public function testGetItems() {
        $this->cart->add($this->createItem001Stub(), 3);
        $this->assertEquals(1, count($this->cart->getItems()));
        $this->cart->add($this->createItem002Stub(), 2);
        $this->assertEquals(2, count($this->cart->getItems()));

        $items = $this->cart->getItems();
        $this->assertEquals(3, $items['001']['amount']);
        $this->assertEquals(2, $items['002']['amount']);
    }

    public function testClearCart() {
        $this->cart->add($this->createItem001Stub(), 1);
        $this->cart->add($this->createItem002Stub(), 2);
        $this->cart->add($this->createItem003Stub(), 3);
        $this->cart->clear();
        $this->assertTrue(is_array($this->cart->getItems()));
        $this->assertEquals(0, count($this->cart->getItems()));
    }

    public function testAddUpperLimit() {
        $this->cart->add($this->createItem001Stub(), PHP_INT_MAX);
        try {
            $this->cart->add($this->createItem001Stub(), 1);
        } catch (OutOfRangeException $e) {
            return;
        }
        $this->fail();
    }

    public function testAddUnderLimit() {
        $this->cart->add($this->createItem001Stub(), 1);
        $this->assertEquals(1, count($this->cart->getItems()));
        $this->cart->add($this->createItem001Stub(), -1);
        $this->assertEquals(0, count($this->cart->getItems()));

        $this->cart->clear();
        $this->cart->add($this->createItem001Stub(), 1);
        $this->assertEquals(1, count($this->cart->getItems()));
        $this->cart->add($this->createItem001Stub(), -2);
        $this->assertEquals(0, count($this->cart->getItems()));
    }

    public function testAddRemove() {
        $this->cart->add($this->createItem001Stub(), 2);
        $this->cart->add($this->createItem002Stub(), 3);
        $this->assertEquals(2, count($this->cart->getItems()));

        $this->cart->add($this->createItem001Stub(), -2);
        $items = $this->cart->getItems();
        $this->assertEquals(1, count($items));
        $this->assertFalse(isset($items['001']));
        $this->assertEquals(3, $items['002']['amount']);

        $this->cart->add($this->createItem002Stub(), -3);
        $items = $this->cart->getItems();
        $this->assertEquals(0, count($items));
        $this->assertFalse(isset($items['001']));
        $this->assertFalse(isset($items['002']));
    }

    private function createItem001Stub() {
        include_once 'Item.php';
        $mock = $this->getMock('Item');
        $mock->expects($this->any())
             ->method('getName')
             ->will($this->returnValue('item001'));
        $mock->expects($this->any())
             ->method('getCode')
             ->will($this->returnValue('001'));
        $mock->expects($this->any())
             ->method('getPrice')
             ->will($this->returnValue(1200));
        return $mock;
    }

    private function createItem002Stub() {
        include_once 'Item.php';
        $mock = $this->getMock('Item');
        $mock->expects($this->any())
             ->method('getName')
             ->will($this->returnValue('item002'));
        $mock->expects($this->any())
             ->method('getCode')
             ->will($this->returnValue('002'));
        $mock->expects($this->any())
             ->method('getPrice')
             ->will($this->returnValue(2000));
        return $mock;
    }

    private function createItem003Stub() {
        include_once 'Item.php';
        $mock = $this->getMock('Item');
        $mock->expects($this->any())
             ->method('getName')
             ->will($this->returnValue('item003'));
        $mock->expects($this->any())
             ->method('getCode')
             ->will($this->returnValue('003'));
        $mock->expects($this->any())
             ->method('getPrice')
             ->will($this->returnValue(1500));
        return $mock;
    }

    public function testTotal() {
        $this->assertEquals(0, $this->cart->getTotal());
        $this->cart->add($this->createItem001Stub(), 1);
        $this->cart->add($this->createItem002Stub(), 2);
        $this->cart->add($this->createItem003Stub(), 3);

        /**
         * 9700 = 1200 * 1 + 2000 * 2 + 1500 * 3
         */
        $this->assertEquals(9700, $this->cart->getTotal());
    }
}

一方のCartクラス側ですが、getTotalメソッドを用意します。

<?php
class Cart
{
              :
    public function getTotal() {
    }
}

それでは、テストを実行してみましょう。

$ phpunit CartTest
  PHPUnit 3.1.7 by Sebastian Bergmann.

.PHP Warning:  Illegal offset type in isset or empty in /home/shimooka/public_html/gihyo.jp/Cart.php on line 19

              :

Warning: Illegal offset type in unset in /home/shimooka/public_html/gihyo.jp/Cart.php on line 28
F

Time: 0 seconds

There were 6 failures:

1) testGetAmount(CartTest)
Failed asserting that <integer:0> matches expected value <integer:1>.
/home/shimooka/public_html/gihyo.jp/CartTest.php:45

2) testGetItems(CartTest)
Failed asserting that <integer:0> matches expected value <integer:1>.
/home/shimooka/public_html/gihyo.jp/CartTest.php:60

3) testAddUpperLimit(CartTest)
/home/shimooka/public_html/gihyo.jp/CartTest.php:85

4) testAddUnderLimit(CartTest)
Failed asserting that <integer:0> matches expected value <integer:1>.
/home/shimooka/public_html/gihyo.jp/CartTest.php:90

5) testAddRemove(CartTest)
Failed asserting that <integer:0> matches expected value <integer:2>.
/home/shimooka/public_html/gihyo.jp/CartTest.php:104

6) testTotal(CartTest)
Failed asserting that <null> matches expected value <integer:0>.
/home/shimooka/public_html/gihyo.jp/CartTest.php:165

FAILURES!
Tests: 11, Failures: 6.
$

当然、テストに失敗します。それでは、Cartクラスを修正していきましょう。変更点は、以下のとおりです。

  • addメソッドのシグネチャ(引数)の変更
  • addメソッドで、商品コードをItemオブジェクトのgetCodeメソッドを使って取得する
  • getTotalメソッドを実装する
  • 内部ハッシュの変更とそれに伴う修正

修正後のCartクラスは、次のようになりました。

<?php
class Cart
{
    private $items;

    public function __construct() {
        $this->clear();
    }

    public function getItems() {
        return $this->items;
    }

    public function add(Item $item, $amount) {
        $item_cd = $item->getCode();

        if (!preg_match('/^-?\d+$/', $amount)) {
            throw new UnexpectedValueException('Invalid amount');
        }

        if (!isset($this->items[$item_cd])) {
            $this->items[$item_cd]['amount'] = 0;
            $this->items[$item_cd]['object'] = null;
        }
        $this->items[$item_cd]['amount'] += (int)$amount;
        $this->items[$item_cd]['object'] = $item;

        if ($this->items[$item_cd]['amount'] > PHP_INT_MAX) {
            throw new OutOfRangeException('the amount exceeded PHP_INT_MAX');
        }
        if ($this->items[$item_cd]['amount'] <= 0) {
            unset($this->items[$item_cd]);
        }

        return true;
    }

    public function getAmount($item_cd) {
        if (isset($this->items[$item_cd]['amount'])) {
            return $this->items[$item_cd]['amount'];
        } else {
            return 0;
        }
    }

    public function clear() {
        $this->items = array();
    }

    public function getTotal() {
        $total = 0;
        foreach ($this->getItems() as $item => $arr) {
            $total += $arr['object']->getPrice() * $arr['amount'];
        }
        return $total;
    }
}

再度テストを実行します。

$ phpunit CartTest
 PHPUnit 3.1.7 by Sebastian Bergmann.

...........

Time: 0 seconds


OK (11 tests)
$ 

少し大きめの修正でしたが、すべてのテストに成功し、問題ないことが確認できました。

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

おすすめ記事

記事・ニュース一覧