ホーム > タグ > 技術

技術

CakePHPプラグインのまとめ – 最適化/デバッグ編

CakePHPプラグインのまとめも大詰めです。
今回は”Optimization“と”Debugging“を訳します。

最適化

  • Frank de Graaf’s Lazy Model: これは君のモデルのチェインローディングを最適化し、君の作った巨大なアプリをスピードアップさせてくれるよ。君がCakePHP1.2または1.3を使っているのなら確実に見ておくべきものだ。CakePHP 2.0ではこれはコアに組み込まれる予定だけど、まだ入っちゃいない。Lazy Modelは次のお楽しみだね。
  • このプラグインをディレクトリに配置し、通常、AppModelを継承するところをLazyModelを継承することで動作速度の改善を図れるようです。
    ただ、その分だけメモリを食うとも書いてあるので、そこは注意ですね。CakePHPはただでさえ、大食いですからね…。

  • Rafael Bandeiras’ Linkable: 思うに、こいつはContainableBehaviorの良い相棒だ。そうだ、いつかこの世界の1つのルールになるだろうね。けど彼はベースとなるSQLを書くのでもうしばらく忙しいみたいだ。Terrがそのアップデートされたバージョンを持っているようなんで、これはそこへのリンクだ。要チェックだね。
  • hasManyやhasAndBelongsToManyのアソシエーションの場合に効果を発揮するプラグインですね。
    これを使用すると、hasManyやHABTMのテーブルに対して条件を指定し、それに関連のあるレコードを取得出来るようです。
    READMEにある例ではTagを条件に指定して、関連あるEntryを取得しています。

  • Mark Story’s Asset Compress: 君のCSSとJSを圧縮してくれるプラグインだ。このプラグインの使い方とCakePHPで最も大切でこれ以上ないリードデベロッパーが誰だか、彼のgithubのプロフィールを見ればわかるはずだ!
  • コメントの通りCSSとJSを自動で圧縮してくれるプラグインですね。
    プラグインをインストールし、Routesを設定すると、そのURLで圧縮したJSやCSSを取得出来るようです。シェルで圧縮したJS/CSSを吐き出したり、それを削除することも可能だそうです。便利ですね!

  • Matt Curry’s HTML Cache: 君のページをHTMLでキャッシュする。ものすごくスピードアップするよ。静的なページには最高だ。君がもしCroogo CMSを使うんならこいつはCroogoのフックも持ってるよ。
  • ヘルパーを読み込むだけでHTMLキャッシュをwebroot以下に生成してくれるようです。
    こうすると、.htaccessの書き換えが必要ですが、それも載っています。cronでのキャッシュ削除方法も載せてあるのが良いですね。

  • Matt Curry’s URL Cache: この不機嫌な野郎はgithubのプロフィール中に良いもんを持っているようだね。君が使ってるどんなキャッシュシステムに対してもURLの生成過程をキャッシュしてくれる。ページリクエストに対して発生する重たいURLの生成処理をとてもスピードアップしてくれるよ。
  • AppHelperの継承元を変えるだけで、Routesの処理をキャッシュしてくれるようです。これもすごく簡単ですね。

  • Matt Curry’s Custom Find Types: 君のアプリケーションに対してfindのカスタムタイプを作り出すのに確実で簡単な方法だな。キャッシュやフィルタリングのカスタマイズも簡単だ。

これもすごく便利なプラグインです。
プラグインをインストールして、モデルの継承元を変えてやるだけで使えます。
あとは”__findHogeHoge”というメソッドを定義するとtypeに”hoge_hoge”が使えるようになり、そのメソッドでfindの処理がされるようになります。すごいよ、Curryさん。

デバッグ

  • Mark Story’s DebugKit: いや、マジでこの男はモンスターだ。彼はJS忍者でさらにCakePHPのバッシュ魔人であるだけじゃなく、君のCakePHPのアプリケーションのためにデバッグのための素晴らしいツールを書き続けている。これは今すぐインストールすべきだね。使い始めたら虜になること間違いなし。
  • 言わずとしれたDebugKitですね。これは皆さん知っていると思うので省略。
    詳しく知りたい方はcakephperさんが書かれた以下のページを参照してください。

  • Joe Beeson’s Referee: “エラーと例外をキャッチし、それをロギング出来るCakePHP 1.3+ のプラグイン” 思うにこいつはすこいイカしてる。君もそう思うだろうね。
  • コンポーネントで設定出来るので、コントローラ毎にエラーをロギング出来そうです。
    CakePHP 1.3からはログエンジンを変えることが出来るようになったので、ほとんど不要かもしれませんが、きめ細かなロギングの設定をしたいときに。

  • Matt Curry’s Interactive: DebugKitをページ更新なしにインタラクティブに表示してくれるパネルだ。特定のクエリがどんな風に動いているか見るのに良いね。
  • これもすごく便利ですね。
    DebugKitのコントロールパネルからクエリを発行したり、PHPのコードをインタラクティブに実行出来るようになるみたいです。
    ここまで来るとDebugKitがちょっとしたIDEみたいですね。

最適化のプラグインは気になるのがたくさんありますね。
モデル周りの最適化を図ってくれる”Lazy Model”やCSSとJSを圧縮してくれる”Aseset Compress”、そしてHTMLキャッシュを作ってくれる”HTML Cache”…。
CSSとJSの圧縮がタスクにあがっているので、今度は”Asset Compress“の使い方をまとめましょうか。

次回が最後です。”Useful Helpers“と”Random Awesome-sauce“を訳しますね。

[連載記事一覧]

  • 認証編
    • AuthComponentの代替えになるプラグインやfacebookと連携するプラグインが紹介されています。それに加え、使いにくいACLを使いやすくするツールも。個人的には一番、興味のある記事でした。
  • 検索とページネーション/ファイルアップロード編
    • CakePHPの使いにくいページネーションを使いやすくするプラグイン、Yahoo! BOSSのプラグインが紹介されています。ファイルアップロードはTransloaditというサービスに対応させるプラグインから、メジャーなメディアプラグインまで。
  • ヘルパー編
    • ブログを作るなら使いたいGravatarやGoogle APIをCakePHPのために取りまとめたプラグインが紹介されています。Google APIにCakePHPを対応させるプラグインは必見です。使った人がいたら、ぜひコメントください。

CakePHPでフィクスチャに惑わされずにテストを書く方法 – モック編

cakephp_testing.png

前回挙げたチュートリアルはやってみましたか?
快適なテストライフを送ってますか?

テストケースをたくさん書いていると気づくのは、フィクスチャがメンテナンスの邪魔をするということ。
フィクスチャに初期データを定義すると、それを気にしながらテストケースを作ることになります。
これがとても面倒くさいんです。

これを解消すべく、今日はモックを使ったテストケースの書き方を紹介します。

モックとは

SimpleTestのモックで参考になるのは、以下の書籍です。

Webアプリケーションテスト手法 Webアプリケーションテスト手法

  • 著者: 水野 貴明 (著), 石井 勇一 (著), 新藤 愛大 (著), 岸田 健一郎 (著), 荻野 淳也 (著), 安井 力 (著), 田中 慎司 (著)
  • 出版社: 毎日コミュニケーションズ
  • 発売日: 2008/7/25

この書籍の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_object.png
モックのイメージ

このことは、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ステップが、

  1. データベース仕様を決める。
  2. メソッドを作成する。
  3. データベース仕様を変更する。
  4. メソッドを変更する。

次の2ステップに変わることを意味します。

  1. メソッドを作成する。
  2. データベース仕様を決める。

なかなか、便利なので皆さんもやってみてください。

モックを使ったテストケースの書き方がわかりましたか?
もし、わからない点や間違った点があれば気軽にコメントをください。

[2010/09/08 追記]

Pythonでのテストの記事ですが、テストやメソッドを設計する上で参考になる点が多いと思います。
ご一緒にどうぞ。

CakePHPを使ったテスト駆動開発

ブログ初ポストはCakePHPを使ったテスト駆動開発です。

CakePHPはユニットテストとしてSimpleTestに対応しています。
SimpleTestをインストールするだけで、モデルやコントローラ、シェル、ルーティングクラスなどのユニットテストが出来るようになります。

今日はこのCakePHPとSimpleTestを使ってテスト駆動開発の流れを説明します。
ただ、僕自身テスト駆動開発を学んだのは去年のCake祭りなので、至らない点が多々あります。
もし何かあれば、コメントでご指摘ください。

今更感もありますが、この場を借りてCake祭りでテスト駆動の指導をしてくださった、@sizuhikoさんに感謝します。

開発手順

まずは開発手順を示します。少し細かいですが、テスト駆動では以下のような順で開発していきます。

  1. 設計する。
  2. テストケースを書く。
  3. テストケースをデバッグする。
  4. コードを書く。
  5. テストケースを実行する。
  6. コードをデバッグする。
  7. テストケースを全て通す
  8. コードが完成する。

コードとテストケースが分かれていること、それに始めにある程度設計してしまうのがポイントです。
設計といっても、私がやるのは白紙のA4用紙にクラスとメソッド名を書き出すぐらいです。
こうして置くと、テストケースもコードも書きやすくなります。

これだけ見ても、実感がないと思うので実例で説明していきます。

ランキングモデルの開発

良くある例としてCakePHPを使ったランキングを作ります。
といっても全てを作る時間はないので、ランキングデータを取り扱うランキングモデルをテスト駆動開発で作ります。

設計

モデル名やスキーマを次のようにします。

  • モデル名 : Ranking
    • addGood : goodを追加するメソッド
    • getGoodTitles : goodの順にデータを取得するメソッド
  • テーブル名 : rankings
    • id : 主キー
    • good : 良いと思った人の数
    • title : ブログのタイトル

ブログにgoodボタンをつけて、それを押すとスキーマのgoodが1増えるイメージです。
例としてはありがちですが、facebookの「良いね!」機能から考えました。

今回は書きませんが、コントローラからaddGoodメソッドやgetGoodTitlesメソッドを呼びます。

テストケース・コーディング

設計が決まったらテストケースを書いていきます。さっそく書き始めたいですが、その前に色々とやることがあります。
空の状態(CakePHPのappディレクトリ)からの開発を想定してますので、モデルやテーブルを作る必要があります。

まず、データベース設定をして、この画面が出るようにしましょう(設定の仕方は省略します)。

startup.jpg

次はschema.phpを作りましょう。schema.phpはCakePHPでデータベースのスキーマを管理出来るファイルです。
ここにスキーマ情報を書き込んでおけば、データベースのマイグレーションが簡単になります。

cake schema generate

Welcome to CakePHP v1.3.2 Console
---------------------------------------------------------------
App : ranking
Path: /home/tfmagician/ranking
---------------------------------------------------------------
Cake Schema Shell
---------------------------------------------------------------
Generating Schema...
Schema file: schema.php generated

schema.php

<?php
/* SVN FILE: $Id$ */
/* Ranking schema generated on: 2010-07-13 17:07:41 : 1279010981*/
class RankingSchema extends CakeSchema {
  var $name = 'Ranking';

  function before($event = array()) {
    return true;
  }

  function after($event = array()) {
  }

}
?>

まだ、データベーステーブルを作っていないので空の状態でテンプレートが出来上がります。
ここに設計したスキーマ情報をプロパティとして加えます。

  var $rankings = array(
    'id'    => array('type' => 'integer',  'null' => false, 'default' => NULL, 'key' => 'primary'),
    'title' => array('type' => 'string',   'null' => false, 'default' => NULL, 'key' => 'index'),
    'good'  => array('type' => 'integer',  'null' => false, 'default' => 0),
    'indexes' => array(
      'PRIMARY' => array('column' => 'id',    'unique' => 1),
      'title'   => array('column' => 'title', 'unique' => 0),
    ),
    'tableParameters' => array(
      'charset' => 'utf8',
      'collate' => 'utf8_general_ci',
      'engine'  => 'MyISAM',
    ),
  );

これを元にCakePHPにテーブルを作らせます。

cake schema create

Welcome to CakePHP v1.3.2 Console
---------------------------------------------------------------
App : ranking
Path: /home/tfmagician/ranking
---------------------------------------------------------------
Cake Schema Shell
---------------------------------------------------------------

The following table(s) will be dropped.
rankings
Are you sure you want to drop the table(s)? (y/n)
[n] > y
Dropping table(s).
rankings updated.

The following table(s) will be created.
rankings
Are you sure you want to create the table(s)? (y/n)
[y] >
Creating table(s).
rankings updated.
End create.

これで指定したスキーマのテーブルが出来ているはずです。phpmyadminなどで確認しておきましょう。

次にテストで使うコードとモデルのコードをbakeで作成します。

cake bake model

Welcome to CakePHP v1.3.2 Console
---------------------------------------------------------------
App : ranking
Path: /home/tfmagician/ranking
---------------------------------------------------------------
---------------------------------------------------------------
Bake Model
Path: /home/tfmagician/ranking/models/
---------------------------------------------------------------
Possible Models based on your current database:
1. Ranking
Enter a number from the list above,
type in the name of another model, or 'q' to exit
[q] > 1
Would you like to supply validation criteria
for the fields in your model? (y/n)
[y] > y

Field: id
Type: integer
---------------------------------------------------------------
Please select one of the following validation options:
---------------------------------------------------------------
1 - alphanumeric
2 - between
3 - blank
4 - boolean
5 - cc
6 - comparison
7 - custom
8 - date
9 - decimal
10 - email
11 - equalto
12 - extension
13 - inlist
14 - ip
15 - maxlength
16 - minlength
17 - money
18 - multiple
19 - notempty
20 - numeric
21 - phone
22 - postal
23 - range
24 - ssn
25 - time
26 - url
27 - userdefined
28 - Do not do any validation on this field.
... or enter in a valid regex validation string.

[28] >

Field: title
Type: string
---------------------------------------------------------------
Please select one of the following validation options:
---------------------------------------------------------------
1 - alphanumeric
2 - between
3 - blank
4 - boolean
5 - cc
6 - comparison
7 - custom
8 - date
9 - decimal
10 - email
11 - equalto
12 - extension
13 - inlist
14 - ip
15 - maxlength
16 - minlength
17 - money
18 - multiple
19 - notempty
20 - numeric
21 - phone
22 - postal
23 - range
24 - ssn
25 - time
26 - url
27 - userdefined
28 - Do not do any validation on this field.
... or enter in a valid regex validation string.

[19] >
Would you like to add another validation rule? (y/n)
[n] >

Field: good
Type: integer
---------------------------------------------------------------
Please select one of the following validation options:
---------------------------------------------------------------
1 - alphanumeric
2 - between
3 - blank
4 - boolean
5 - cc
6 - comparison
7 - custom
8 - date
9 - decimal
10 - email
11 - equalto
12 - extension
13 - inlist
14 - ip
15 - maxlength
16 - minlength
17 - money
18 - multiple
19 - notempty
20 - numeric
21 - phone
22 - postal
23 - range
24 - ssn
25 - time
26 - url
27 - userdefined
28 - Do not do any validation on this field.
... or enter in a valid regex validation string.

[20] >
Would you like to add another validation rule? (y/n)
[n] >
Would you like to define model associations
(hasMany, hasOne, belongsTo, etc.)? (y/n)
[y] >
One moment while the associations are detected.
---------------------------------------------------------------
Please confirm the following associations:
---------------------------------------------------------------
Would you like to define some additional model associations? (y/n)
[n] >

---------------------------------------------------------------
The following Model will be created:
---------------------------------------------------------------
Name:       Ranking
DB Table:   `rankings`
Validation: Array
(
    [title] => Array
        (
            [notempty] => notempty
        )

    [good] => Array
        (
            [numeric] => numeric
        )

)

Associations:
---------------------------------------------------------------
Look okay? (y/n)
[y] >

Baking model class for Ranking...

Creating file /home/tfmagician/ranking/models/ranking.php
Wrote `/home/tfmagician/ranking/models/ranking.php`
SimpleTest is not installed. Do you want to bake unit test files anyway? (y/n)
[y] > y

You can download SimpleTest from http://simpletest.org

Baking test fixture for Ranking...

Creating file /home/tfmagician/ranking/tests/fixtures/ranking_fixture.php
Wrote `/home/tfmagician/ranking/tests/fixtures/ranking_fixture.php`
Bake is detecting possible fixtures..

Creating file /home/tfmagician/ranking/tests/cases/models/ranking.test.php
Wrote `/home/tfmagician/ranking/tests/cases/models/ranking.test.php`

(あ、忘れていましたが、cakeコマンドはcakeコンソール(cake/console/cake)のことです。適宜置き換えてくださいね。)

これでテストケースとフィクスチャ(後で説明します)、モデルのテンプレートが完成しました。
あとはコーディングしていくだけです。

と、一つ忘れていたことがありました。SimpleTestのインストールです。
SimpleTestはアプリケーションのvendorsか、CakePHP本体と同じ階層のvendorsに配置します。

cd vendors/
wget http://downloads.sourceforge.net/simpletest/simpletest_1.0.1.tar.gz
tar zxvf simpletest_1.0.1.tar.gz
rm simpletest_1.0.1.tar.gz

配置したら、ブラウザからtest.phpにアクセスします。

test.php
test_php.jpg

左メニューからApp > Test Casesをクリック
test_cases.jpg

コンテンツからmodels / Rankingをクリック
ranking_test.jpg

bakeで作ったテンプレートのテストが実行されているのがわかります。
まだ何も書いていないですが、テンプレートのテストが実行され、1件のテストケースが通っています。

これでテストが実行出来るようになりました。テストケースをコーディングします。
テストケースはさきほどのbakeの最後に書かれている通り、tests/cases/models.ranking.test.phpにあります。
これを元に、オリジナルのテストケースをコーディングします。

tests/cases/ranking.test.php

/* 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);

  }

}
?>

コメント中にも書いてありますが、assertメソッドがテストです。
例えばassertEqualメソッドは、「2つの変数が等しいことを期待する」という意味になります。
この期待に応えない(変数が等しくない)場合、テストは失敗します。後でスクリーンショットで示します。

またテストメソッドが実行される順は、次のようになります。

  1. startTest
  2. テストメソッド1(testで始まるメソッド)
  3. endTest
  4. startTest
  5. テストメソッド2(testで始まるメソッド)
  6. endTest

なのでこのテストケースでは、以下の順でテストが実行されます。

  1. startTest
  2. testAddGood
  3. endTest
  4. startTest
  5. testGetGoodTitles
  6. endTest

必ずstartTestとendTestメソッドが呼ばれる点がポイントです。これらのメソッドでクラスの初期化などの処理を実施し、テストを書きやすくします。

このテストではデータの挿入、更新を行っています。しかし、さきほどテーブルを作ったばかりなのでデータはまだ入っていないはずです。
逐一、テストの度に手作業でデータを入れてもいいのですが、それはとても面倒です。そこでフィクスチャという機能を使います。

CakePHPのフィクスチャ機能はテーブルのデータをPHPのコードとして書いておくと、勝手にテスト前にデータを挿入してくれます。
挿入のタイミングはテストメソッド(テストケースの中のtestで始まるメソッド)毎です。つまり、特定のテストメソッドでデータを更新したり削除しても、次のテストメソッドでは新しいデータが入っています。
これを知っておくと、テストケースを書くのが楽になります。

tests/fixtures/ranking_fixture.php

/* Ranking Fixture generated on: 2010-07-13 18:07:16 : 1279014616 */
class RankingFixture extends CakeTestFixture {
  var $name = 'Ranking';

  // スキーマ情報をfieldsプロパティとして書いておきます。indixesはインデックスの指定、tableParametersはテーブルの設定値です。schema.phpを同じ形式で指定します。
  var $fields = array(
    'id' => array('type' => 'integer', 'null' => false, 'default' => NULL, 'key' => 'primary'),
    'title' => array('type' => 'string', 'null' => false, 'default' => NULL, 'key' => 'index'),
    'good' => array('type' => 'integer', 'null' => false, 'default' => '0'),
    'indexes' => array('PRIMARY' => array('column' => 'id', 'unique' => 1), 'title' => array('column' => 'title', 'unique' => 0)),
    'tableParameters' => array('charset' => 'utf8', 'collate' => 'utf8_general_ci', 'engine' => 'MyISAM')
  );

  // データをrecordsプロパティとして書いておきます。
  var $records = array(
    array(
      'id' => 1,
      'title' => 'title1',
      'good' => 5,
    ),
    array(
      'id' => 2,
      'title' => 'title2',
      'good' => 6,
    ),
    array(
      'id' => 3,
      'title' => 'title3',
      'good' => 7,
    ),
    array(
      'id' => 4,
      'title' => 'title4',
      'good' => 8,
    ),
    array(
      'id' => 5,
      'title' => 'title5',
      'good' => 9,
    ),
    array(
      'id' => 6,
      'title' => 'title6',
      'good' => 10,
    ),
  );
}
?>

フィクスチャの書き方は省略します。ただ、連想配列で指定するので少し考えれば意味はわかるかと思います。
書き方がわからない場合はschema.phpを参考にしてください。

テストケースのデバッグ

作成したテストケースをデバッグします。まだモデル側のコードは書いていませんが、まずはtest.phpを開いてみましょう。

test.php
ranking.jpg

メソッドをまだ用意していないため、モデルにクエリを発行されてしまっています(CakePHPでは存在しないメソッドは全てクエリとして扱われます)。
これではデバッグにならないので、モデルにメソッドだけ用意しましょう。

models/ranking.php

<?php
class Ranking extends AppModel {
  var $name = 'Ranking';
  var $displayField = 'title';
  var $validate = array(
    'title' => array(
      'notempty' => array(
        'rule' => array('notempty'),
        //'message' => 'Your custom message here',
        //'allowEmpty' => false,
        //'required' => false,
        //'last' => false, // Stop validation after this rule
        //'on' => 'create', // Limit validation to 'create' or 'update' operations
      ),
    ),
    'good' => array(
      'numeric' => array(
        'rule' => array('numeric'),
        //'message' => 'Your custom message here',
        //'allowEmpty' => false,
        //'required' => false,
        //'last' => false, // Stop validation after this rule
        //'on' => 'create', // Limit validation to 'create' or 'update' operations
      ),
    ),
  );

  function addGood($id) {
  }

  function getGoodTitles($n) {
  }
}
?>

再度、test.phpを開きます。

test.php
test_debug.jpg

そうすると、一つだけクエリが発行されているのがわかります。
すでにお気づきの方がいるかもしれません。そうです、テストケースで実行しているRankigクラスのメソッド名がgoogTitlesとなっているのです。
よくあるtypoですね。修正し、再度テストケースを実行します。

test.php
test_debug_passed.jpg

これでテストケースのコードは大丈夫のようです。この段階でテストは通っていなくとも大丈夫です。
なんせ、まだ本体のコードを書いていないのですから。

コーディング

それでは本体のコードを書いていきます。先ほど追加した空のメソッドに以下のようにコーディングします。

models/ranking.php

  function addGood($id) {
    $this->id = $id;
    if(!$this->exists()) {
      return false;
    }
    return $this->updateAll(array('good' => 'good + 1'), array('id = ' => $id));
  }

  function getGoodTitles($n) {
    $params = array(
      'limit' => $n,
      'page' => 1,
      'order' => 'good DESC',
      'recursive' => -1,
    );
    return $this->find('all', $params);
  }

コーディングが完了したらtest.phpのページを再度開きます。

test.php
test_passed.jpg

テストが通ると、テストした件数と緑色のバーが表示されます。
この緑色のバーと作成したテスト件数があっていれば、テストは完全に通っています。

ここではあっさりテストを通してしまいましたが、実は1度のコーディングでテストが完了した訳ではありません。
この程度のコードでも2〜3度、テストを失敗し、コードを書き直しています。

やってみるとわかるのですが、テストケースがあることで簡単に、かつ手軽にコードを書き換えることができます。

テストケースを使ったテスト駆動開発を知るまでは、var_dumpを活用し、逐一途中のデータを見ながらコードを書いていました。
しかし、この方法だと仕様が自分の頭の中にしかないため、メソッドの機能が多様化してしまったり、想定しないバグに出くわしたりします。

テストケースを書くことで、頭の中にあるメソッドの仕様をコードとして書き出すことになります。
そうすることで、曖昧だった点や考慮しなければいけないケースも浮き彫りとなります。
さらにコードとして書き出すことで、メソッドを作る上での目標が定まり、コーディングしやすくなります。

テストケースというコードが増えるため、これを書くのを面倒に思ってしまいますが、この面倒さ以上のメリットがテスト駆動開発にはあると私は思います。

テスト駆動開発をする上でのポイント

最後に、テスト駆動開発でのポイントを書いておきます。参考にしてください。

  • テストケースを必ず始めに書く。
    • 後から書こうと思うと面倒くさくなる。また、後から書くと先ほど書いたメリットを受諾出来ない。
  • メソッド、クラスの機能追加時も必ずテストケースを書く。
    • テストケースも更新していかないと、せっかく書いたコードが無駄になる。テストケースはコードがしっかり動いていることの証拠である。
  • バグを見つけたときこそ、必ずテストケースを書く。
    • テストケースは上記と同様にコードがしっかり動いていることの保証である。
  • バグを見つけたときは、テストケースでしっかり対策する。
    • 統合テストでバグを見つけた場合はデバッグし、原因を見つける。その原因をメソッドレベルにまで落とし、そのメソッドのテストケースを書き、解決する。従ってテストケースも独立していることが重要である。
  • テストケースを出来るだけ独立させる。
    • 対象のメソッド、クラス以外の部分で仕様変更があった場合に、テストが通らなくなるのはテストケースのメンテナンスが面倒になる。出来るだけテストケースの対象を1つのメソッド、あるはクラスにしぼる。

これまでテスト駆動開発をやってきて学んだことを上げてみました。
テスト駆動開発で重要なことは一言で言うとメンテナンスしやすいテストケースを書くことだと思います。
テスト駆動開発をこれから始める方は、この点に注意すると、私の二の舞*1にならないかと思います。

ご参考になれば幸いです。

*1 テスト駆動開発を始めた頃は良かったのだが、その後メンテナンスが大変になった。結局、テストケースを全て捨てることに…。

ホーム > タグ > 技術

スポンサードリンク
書いている人
つぶやき
RSS 気になるニュース
過去の記事

ページの上部に戻る