ホーム > CakePHP > CakePHPを使ったテスト駆動開発

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

ちょっと一言

テストに関する情報が少ないですよね...。CakeMatsuriに出ていなければ、私も知りませんでした。

  • http://topsy.com/1-byte.jp/2010/07/19/cakephp%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%9F%E3%83%86%E3%82%B9%E3%83%88%E9%A7%86%E5%8B%95%E9%96%8B%E7%99%BA/?utm_source=pingback&utm_campaign=L2 Tweets that mention 1-byte.jp – CakePHPを使ったテスト駆動開発 — Topsy.com

    [...] This post was mentioned on Twitter by cakephp_jp, mikage_sbm. mikage_sbm said: あとでやってみる /// 1-byte.jp – CakePHPを使ったテスト駆動開発 http://bit.ly/cakJqc [...]

blog comments powered by Disqus

ホーム > CakePHP > CakePHPを使ったテスト駆動開発

スポンサードリンク
書いている人
つぶやき
  • setup.pyのinstall_requiresにgitのリポジトリを指定したい。どうすればいいかな。 3 days ago
  • ツイッタークライアントを久々に開いたけど、なんか違和感。 5 days ago
  • 最近、イベント参加出来てないな(_ _) 5 days ago
  • More updates...
RSS 気になるニュース
過去の記事

ページの上部に戻る