- 2010年8月29日 9:00 PM
- CakePHP

前回挙げたチュートリアルはやってみましたか?
快適なテストライフを送ってますか?
テストケースをたくさん書いていると気づくのは、フィクスチャがメンテナンスの邪魔をするということ。
フィクスチャに初期データを定義すると、それを気にしながらテストケースを作ることになります。
これがとても面倒くさいんです。
これを解消すべく、今日はモックを使ったテストケースの書き方を紹介します。
モックとは
SimpleTestのモックで参考になるのは、以下の書籍です。
![]() |
Webアプリケーションテスト手法
|
この書籍のp154にモックについて以下のように書いてあります。
モックを使うとデータファイルからではなく擬似的に値を返せるので、OrderReaderがどのような動作をするのか可視的に図ることができます。もしCSVから違う入力形式をサポートするように仕様が変わった場合にも、このテストコードを見れば、修正が用意となります。
さてモックというと何だか難しく聞こえますが、テストコードの記述手順を箇条書きにしてみれば、それほどでもないと思うでしょう。モックを使わない場合のテストは以下の手順です。
1. テストするメソッドを呼び出す
2. テスト結果を評価する一方でモックを使う場合は、以下の手順です。
1. モックを生成する
2. モックで戻り値を設定する
3. テストするメソッドを呼び出す
4. テスト結果を評価する
5. モックが使われたか確認する
“Webアプリケーションテスト手法”より
またモックを英英辞書で引くと以下のようにあります。
You use mock to describe something which is not real or genuine, but which is intended to be very similar to the real thing.
“Collins Cobuild English Dictionary”より
“very similar to real thing”がポイントですね。
つまり、一言でモックを言うならば”見せかけのクラス”です。
ランキングモデルのテストケースの修正
今回の記事は前回の”cakephpを使ったテスト駆動開発“の続きです。
使用したコードやチュートリアルの流れなどは全て前回の記事を参考にして下さい。
以下が前回、作成したランキングモデルのテストケースです。
/* Ranking Test cases generated on: 2010-07-13 18:07:16 : 1279014616*/
App::import('Model', 'Ranking');
// テストケース用のクラスはTestCaseで終わる名前にし、CakeTestCaseを継承する。TestCaseより前はファイル名と一致させる。必ずしもテストするクラス名と一致させる必要はない。
class RankingTestCase extends CakeTestCase {
var $fixtures = array('app.ranking');
function startTest() {
$this->Ranking =& ClassRegistry::init('Ranking');
}
function endTest() {
unset($this->Ranking);
ClassRegistry::flush();
}
/**
* addGoodメソッドのテスト
*/
// testで始まるメソッドがテストとして実行される。テストメソッドは必ずtestでメソッド名を始めること。
function testAddGood() {
debug('addGoodメソッドのテスト');
// 正常: id=1のデータに対してaddGoodメソッドを実行する。
// 確認: 返り値がtrueであること、goodがプラス1されていること。
$ret = $this->Ranking->addGood(1);
$this->assertTrue($ret);
$params = array(
'conditions' => array('id = ' => 1),
'fields' => array('good'),
'recursive' => -1
);
$data = $this->Ranking->find('list', $params);
$expected = array(1 => 2);
// この部分がテスト: 期待する結果とメソッドの戻り値を比較して等しいならテストが通る。等しくないならテストが失敗する。
$this->assertEqual($expected, $data);
// 異常: id=2のデータに対してaddGoodメソッドを実行する。
// 確認: 返り値がfalseであること。
$ret = $this->Ranking->addGood(1);
// この部分がテスト: メソッドの戻り値がfalseならテストが通る。false以外ならテストが失敗する。
$this->assertFalse($ret);
}
/**
* getGoodTitlesメソッドのテスト
*/
// testで始まるメソッドがテストとして実行される。テストメソッドは必ずtestでメソッド名を始めること。
function testGetGoodTitles() {
// 正常: 取得件数を5件に設定し、getGoogTitlesメソッドを実行する。
// 確認: 上位から5件のデータが取得出来ること。
$ret = $this->Ranking->getGoogTitles(5);
$expected = array(
array(
'Ranking' => array(
'id' => 1,
'title' => 'title1',
'good' => 10,
),
),
array(
'Ranking' => array(
'id' => 2,
'title' => 'title1',
'good' => 9,
),
),
array(
'Ranking' => array(
'id' => 3,
'title' => 'title1',
'good' => 8,
),
),
array(
'Ranking' => array(
'id' => 4,
'title' => 'title1',
'good' => 7,
),
),
array(
'Ranking' => array(
'id' => 5,
'title' => 'title1',
'good' => 6,
),
),
);
// この部分がテスト: 期待する結果とメソッドの戻り値を比較して等しいならテストが通る。等しくないならテストが失敗する。
$this->assertEqual($expected, $ret);
}
}
?>
このうち、テスト対象のRankingモデルをモック化してしまいます。
こうすることで、特定のメソッドを実行したときに呼び出されるfindメソッドやdeleteメソッドのコール回数や引数を確認出来るのです。
テスト用のクラスRankingTestCaseを書く前に以下の記述を追加してください。
/**
* テーブルを使用させないためにテスト対象のRankingモデルをオーバライド
*/
class TestRanking extends Ranking {
var $useTable = false;
}
Mock::generatePartial(
'TestRanking', 'MockRanking',
array('exists', 'updateAll', 'find')
);
これでRankingモデルがモック化されたMockRankingモデルが生成されます。
モックの考え方は初め、意味がわからないと思います。
そのため、今回は説明の前にテストケースを書いてしまいます。
<?php
/* Ranking Test cases generated on: 2010-07-13 18:07:16 : 1279014616*/
App::import('Model', 'Ranking');
/**
* テーブルを使用させないためにテスト対象のRankingモデルをオーバライド
*/
class TestRanking extends Ranking {
var $useTable = false;
}
Mock::generatePartial(
'TestRanking', 'MockRanking',
array('exists', 'updateAll', 'find')
);
class RankingTestCase extends CakeTestCase {
// TestRankingクラスを作ったこと、モッククラスを作ったことでテーブルが不要に。フィクスチャをコメントアウトする。
// var $fixtures = array('app.ranking');
function startTest() {
$this->Ranking =& ClassRegistry::init('MockRanking');
}
function endTest() {
unset($this->Ranking);
ClassRegistry::flush();
}
/**
* addGoodメソッドのテスト
*/
function testAddGood() {
debug('addGoodメソッドのテスト');
// 正常: id=1のデータに対してaddGoodメソッドを実行する。
// 確認: exists, updateAllメソッドが呼ばれていること。返り値がtrueであること。
// existsメソッドが引数なしで呼ばれていること。
$this->Ranking->expectOnce('exists', array());
// existsメソッドの返り値をセット
$this->Ranking->setReturnValue('exists', true);
// updateAllメソッドがgoodをインクリメントする内容の引数で呼ばれていること。
$fields = array('good' => 'good + 1');
$conditions = array('id = ' => 1);
$this->Ranking->expectOnce('updateAll', array($fields, $conditions));
// updateAllメソッドの返り値をセット
$this->Ranking->setReturnValue('updateAll', true);
// addGoodメソッドの呼び出しと結果の確認
$ret = $this->Ranking->addGood(1);
$this->assertTrue($ret);
}
function testAddGoodNothing() {
// 異常: 存在しないデータ(existsメソッドがfalseを返すデータ)に対してaddGoodメソッドを実行する。
// 確認: existsメソッドが呼ばれていること。updateAllメソッドが呼ばれていないこと。
// existsメソッドが引数なしで呼ばれていること。
$this->Ranking->expectOnce('exists', array());
// existsメソッドの返り値をセット
$this->Ranking->setReturnValue('exists', false);
// updateAllメソッドが呼ばれていないこと。
$this->Ranking->expectNever('updateAll');
$ret = $this->Ranking->addGood(10);
$this->assertFalse($ret);
}
/**
* getGoodTitlesメソッドのテスト
*/
function testGetGoodTitles() {
debug('getGoodTitlesメソッドのテスト');
// 正常: 取得件数を5件に設定し、getGoodTitlesメソッドを実行する。
// 確認: findメソッドが呼ばれていること。
// findメソッドが上位5件を取得する条件で呼ばれていること。
$params = array(
'limit' => 5,
'page' => 1,
'order' => 'good DESC',
'recursive' => -1,
);
$this->Ranking->expectOnce('find', array('all', $params));
$return = array(
array(
'Ranking' => array(
'id' => 6,
'title' => 'title6',
'good' => 10,
),
),
array(
'Ranking' => array(
'id' => 5,
'title' => 'title5',
'good' => 9,
),
),
array(
'Ranking' => array(
'id' => 4,
'title' => 'title4',
'good' => 8,
),
),
array(
'Ranking' => array(
'id' => 3,
'title' => 'title3',
'good' => 7,
),
),
array(
'Ranking' => array(
'id' => 2,
'title' => 'title2',
'good' => 6,
),
),
);
$this->Ranking->setReturnValue('find', $return);
$ret = $this->Ranking->getGoodTitles(5);
$expected = array(
array(
'Ranking' => array(
'id' => 6,
'title' => 'title6',
'good' => 10,
),
),
array(
'Ranking' => array(
'id' => 5,
'title' => 'title5',
'good' => 9,
),
),
array(
'Ranking' => array(
'id' => 4,
'title' => 'title4',
'good' => 8,
),
),
array(
'Ranking' => array(
'id' => 3,
'title' => 'title3',
'good' => 7,
),
),
array(
'Ranking' => array(
'id' => 2,
'title' => 'title2',
'good' => 6,
),
),
);
$this->assertEqual($expected, $ret);
}
}
?>
コメントに入れた観点でテストを実施しています。
前回のテストと今回のテストの観点の比較を以下に書いておきます。
| 前回のテスト | 今回のテスト |
|---|---|
| 1. テスト対象のメソッドを呼び、テーブルに実際にデータを挿入する。 | 1. テスト対象のメソッドを呼び、モック化されたクラスのメソッドを呼ぶ。 |
| 2. このデータをテストケースで読み込み、想定したデータと一致するか、確認する。 | 2. こうして呼ばれたメソッドの呼び出し回数や引数が一致するか、確認する。 |
まずはモックを使うと、”このようなテストが出来る“ということが理解出来ましたか?
これがわかれば後は書き方を理解するだけです。
モックの作り方
先ほどのコーディングで何をしているのか、モックはどう書くのかを順を追って説明して行きます。
まずはモックのイメージです。
始めに書いた以下のコードは、次のようなクラスを作成することを意味しています。
/**
* テーブルを使用させないためにテスト対象のRankingモデルをオーバライド
*/
class TestRanking extends Ranking {
var $useTable = false;
}
Mock::generatePartial(
'TestRanking', 'MockRanking',
array('exists', 'updateAll', 'find')
);

モックのイメージ
このことは、Mock::generatePartialをprintすると簡単にわかります。
<?php
class MockRanking extends TestRanking {
var $_mock;
var $_mocked_methods = array('exists', 'updateall', 'find');
function MockRanking() { $this->_mock = &new SimpleMock();
$this->_mock->disableExpectationNameChecks();
}
function setReturnValue($method, $value, $args = false) {
if (! in_array(strtolower($method), $this->_mocked_methods)) {
trigger_error("Method [$method] is not mocked");
$null = null;
return $null;
}
$this->_mock->setReturnValue($method, $value, $args);
}
/** ----- 省略 ----- **/
function exists() {
$args = func_get_args();
$result = &$this->_mock->_invoke("exists", $args);
return $result;
}
function updateAll() {
$args = func_get_args();
$result = &$this->_mock->_invoke("updateAll", $args);
return $result;
}
function find() {
$args = func_get_args();
$result = &$this->_mock->_invoke("find", $args);
return $result;
}
}
?>
わかりますか?つまり、Mockクラスがgenerateメソッドの呼び出しを受けて、自動的にコードを吐き出しているんです。
PHPはスクリプト言語なので、こうして吐き出されたコードも簡単に自身のコードの一部として扱うことが出来ます。
こういった面白いハックを見ると、自分でも作ってみたくなりますね。
話が少し逸れました。モックのクラスの生成について理解出来ましたか?
これが理解出来れば後は簡単、テストに使用する基本的なメソッドの動きがわかってくると思います。
後はもう自由にテストを書けますよね?
モックでよく使用するメソッド
最後にモックでよく使用するメソッドをまとめておきます。
もちろん、モック化したクラスに対してメソッドを実行してください。
このメソッド一覧があれば、どんなテストも簡単に書けるようになるでしょう。
| メソッド名 | 動作 |
|---|---|
| expectAt ($n, $method, $arguments) |
n回目の特定のメソッドの呼び出しが指定した引数であることを確認する。 |
| expectCallCount ($method, $n) |
特定のメソッドがn回呼び出されることを確認する。 |
| expectNever ($method) |
特定のメソッドが呼び出されないことを確認する。 |
| expectOnce ($method, $arguments) |
呼び出し回数が1回で、かつ指定した引数であることを確認する。引数指定を省略した場合は、呼び出し回数のみ確認する。 |
| setReturnValue ($method, $value) |
特定のメソッドの返り値をセットする。 |
| setReturnValueAt ($n, $method, $value) |
特定のメソッドのn回目の返り値をセットする。 |
モック、なかなか便利でしょう。
私はよく、フィクスチャを書くのが面倒なときやデータベースの仕様が固まっていないときに、モデルのモックを作りテストします。
特にデータベースの仕様が固まっていないときに便利です。
テストケースを書きながら、テーブルのフィールドや型を考えることが出来ます。
これは以下の4ステップが、
- データベース仕様を決める。
- メソッドを作成する。
- データベース仕様を変更する。
- メソッドを変更する。
次の2ステップに変わることを意味します。
- メソッドを作成する。
- データベース仕様を決める。
なかなか、便利なので皆さんもやってみてください。
モックを使ったテストケースの書き方がわかりましたか?
もし、わからない点や間違った点があれば気軽にコメントをください。
[2010/09/08 追記]
Pythonでのテストの記事ですが、テストやメソッドを設計する上で参考になる点が多いと思います。
ご一緒にどうぞ。
こちらもあわせてどうぞ
ちょっと一言
今日はブログをアップ出来ないかと思いました。 しかし、一回途切れると続けられないと思い、頑張って書きました。 そのため、内容がわかりにくいかもしれない...。わからなければTwitterで質問ください。


