Monthly Archives: 7月 2010

CakePHPシェルの単体テストのやり方

このエントリーをはてなブックマークに追加
はてなブックマーク - CakePHPシェルの単体テストのやり方
Share on Facebook

CakePHPのシェルの単体テストは少々面倒くさいです。WEB表示を前提としたコントローラやコンポーネントと違ってCLIへの出力をするので、CakePHP内のリクエスト順が違います。そのためDispatcherの設定などを変えなければなりません。

ただ、深く考えずにシェルのテストをしたいだけなら、Coreのテストからソースをコピーするだけで動きます。

以下に簡単なサンプルを載せます。

サンプル

テスト対象のシェル: uso.php

// app/vendors/shells/uso.php
<?php
class UsoShell extends Shell {

    function main() {
        $this->out("uhouho");
    }
}
?>

テストケース: uso.test.php

// app/tests/cases/shells/uso.test.php
<?php
App::import("Shell", "Uso");

if (!defined('DISABLE_AUTO_DISPATCH')) {
    define('DISABLE_AUTO_DISPATCH', true);
}

if (!class_exists('ShellDispatcher')) {
    ob_start();
    $argv = false;
    require CAKE . 'console' .  DS . 'cake.php';
    ob_end_clean();
}

Mock::generatePartial('ShellDispatcher', 'UsoShellMockShellDispatcher', array(
    'getInput', 'stdout', 'stderr', '_stop', '_initEnvironment'
));

class UsoShellTestCase extends CakeTestCase {
    
    function setUp() {
        $this->Dispatcher =& new UsoShellMockShellDispatcher();
        $this->Shell =& new UsoShell($this->Dispatcher);
        $this->Shell->Dispatch = $this->Dispatcher;
        $this->Shell->Dispatch->shellPaths = Configure::read("shellPaths");
    }
    
    function tearDown() {
        unset($this->Shell, $this->Dispatcher);
        ClassRegistry::flush();
    }
    
    // テストメソッド
    function testMain() {
        $this->Shell->Dispatch->expectAt(0, "stdout", array("uhouho\n", false));
        $this->Shell->main();
    }
}
?>

実行方法と出力結果

$ cd c:/xampp/htdocs/cakephp/app
$ ../cake/console/cake testsuite app case shells/uso


Welcome to CakePHP v1.3.2 Console
---------------------------------------------------------------
App : app
Path: c:\xampp\htdocs\cakephp\app
---------------------------------------------------------------
CakePHP Test Shell
---------------------------------------------------------------
Running app case shells/uso
1/1 test cases complete: 1 passes.
Time taken by tests (in seconds): 0.022739887237549
Peak memory use: (in bytes): 12,622,648

解説

uso.test.phpの先頭部分で何をやっているのかわかりづらいと思います。要はモックオブジェクトを使って、Dispatcherの書き換えと、CLIへの入出力の無効化をしています。あとはそのモックをsetUp()内で読み込んでいます。詳しいことは下記の「参考」にあるアドレスを見てください。

また、「expectAt()」というメソッドはSimpleTestのアサーションの一つです。詳しいことはSimpleTest for PHP mock objects documentationを参考にしてください。

地味な注意ですが、expectAt()で予期している文字列は “uhouho” ではなく “uhouho\n“です。$this->out()は改行記号付きで標準出力するメソッドです。何回やってもfailになるのでおかしいと思い、Coreのテストを見てやっと気付きました。

参考

翻訳: Unit Testing CakePHP Shells

このエントリーをはてなブックマークに追加
はてなブックマーク - 翻訳: Unit Testing CakePHP Shells
Share on Facebook

Unit Testing CakePHP Shells | Mark Story

シェルの単体テストをどうやっていいかわからないなら、上の記事はなかなかおすすめです。テストしたいクラスとShellDispatcherをモック化する方法が紹介されています。

サンプルコードは、CakePHPに組み込まれているCoreテストの「/cake/tests/cases/console/libs/shell.test.php」にほぼ同じものがあります。筆者のMark Story氏も言っていますが、具体的な例が見たければそちらのソースコードを参照するといいでしょう。

以下、翻訳です。


シェルは単体テストをするのが難しいものの一つです。WEBコンテキストと違いCLIコンテキストの中で動作するため、うまくテストするのはちょっとしたチャレンジになるでしょう。特に高いハードルとなるのは、CLI(コマンドライン環境)とシェルを分離することと、正しい引数・パラメータをシミュレーションすることです。

環境依存要素の除去

シェルとCLIを分離するには、モックオブジェクトを使います。まずはテスト対象のシェルとShellDispatcherをモック化しましょう。ShellDispatcherをモッキングするのは特に重要です。以下、自作のクラス「AclExtrasShell」をテストする場合を例に説明します。シェルのテストでは、CLI環境と連動しているメソッドをモッキングする必要があります。

if (!defined('DISABLE_AUTO_DISPATCH')) {
    define('DISABLE_AUTO_DISPATCH', true);
}
 
if (!class_exists('ShellDispatcher')) {
    ob_start();
    $argv = false;
    require CAKE . 'console' .  DS . 'cake.php';
    ob_end_clean();
}
 
Mock::generatePartial(
    'ShellDispatcher', 'AclExtrasMockShellDispatcher',
    array('getInput', 'stdout', 'stderr', '_stop', '_initEnvironment')
);

入出力メソッドと _stop メソッドをモッキングすることで、ShellDispatcherからstdin(標準入力)とstdout(標準出力)を分離しました。また、テストケースがCLIコンテキストから独立して動作するようにしました。続いて、実際にテストしたいクラス内のいくつかのメソッドをモック化しましょう。

Mock::generatePartial(
    'AclExtrasShell', 'MockAclExtrasShell',
    array('in', 'hr', 'out', 'err', 'createFile', '_stop', 'getControllerList')
);

繰り返しますが、入力と出力に関わるメソッドはとても重要です。_stopもモック化することで、シェルが終了しようとしてもテストが継続できるようにします。これら二つのクラスをメソッドをモック化したら、テストメソッドの準備に取りかかりましょう。

テストケースの準備

シェルのテストと普通のテストケースは、なんら変わりません。ただしシェルの場合は少し違ったstartTestとendTestを必要とします。私はふだんは以下のようにします。

class AclExtrasShellTestCase extends CakeTestCase {
//...
function startTest() {
    $this->Dispatcher =& new AclExtrasMockShellDispatcher();
    $this->Task =& new MockAclExtrasShell($this->Dispatcher);
    $this->Task->Dispatch =& $this->Dispatcher;
    $this->Task->Dispatch->shellPaths = Configure::read('shellPaths');
}
//...
function endTest() {
    unset($this->Task, $this->Dispatcher);
    ClassRegistry::flush();
}

startTestの中で、モック化したShellDispatcherとテスト対象のシェルのインスタンスを作成します(4-5行目)。続いて手動でDispatcherとシェルをDispatchプロパティにアサインします(6行目)。さらにshellPathsを設定することで、オブジェクトとシェルのシミュレーションが可能になりました。endTestメソッドでは後掃除をし、テストの度に新しいオブジェクトを使えるようにします。注意として、もしシェル内でほかのシェルを利用している場合は、そちらもモック化などの準備をする必要があります。全部のDispatchの設定をするのもお忘れ無く。

in()とout()をアサートする

in()とout()はモック化されているので、代わりにsetReturnValue()とsetReturnValueAt()を使ってユーザーからの入力をシミュレートします。出力のアサーションにはexpectAt()を使います。例えば:

$this->Task->expectOnce('out');
$this->Task->expectAt(0, 'out', array(new PatternExpectation('/recovered/')));

「/recovered/」というパターンが一度出力されるであろう部分で、上記のように予想をセットします。呼び出される回数と個別に呼び出される部分を一緒に書くとうまくいくようです。個別に呼び出される部分だけ書いても、メソッドが呼ばれなかった場合はテストはfailになります。コール回数の予想だけだと、メソッドが呼ばれても何が起こっているのか知ることはできません。

さらに複雑な例

上記の通りやればうまくいきますが、もっと複雑なシェルのやりとりはどうすればいいでしょうか?そういう時はより細かいメソッドに分割すると良いでしょう。分割できないなら、より多くの手間を費やしてin()とout()の予想を書くほかないです。例として、ViewTaskのテストの一部分を取り上げましょう(bakeの一部分です)。

$this->Task->connection = 'test_suite';
$this->Task->args = array();
 
$this->Task->Controller->setReturnValue('getName', 'ViewTaskComments');
$this->Task->Project->setReturnValue('getAdmin', 'admin_');
$this->Task->setReturnValueAt(0, 'in', 'y');
$this->Task->setReturnValueAt(1, 'in', 'n');
$this->Task->setReturnValueAt(2, 'in', 'y');
 
$this->Task->expectCallCount('createFile', 4);
$this->Task->expectAt(0, 'createFile', array(
    TMP . 'view_task_comments' . DS . 'admin_index.ctp',
    new PatternExpectation('/ViewTaskComment/')
));
$this->Task->expectAt(1, 'createFile', array(
    TMP . 'view_task_comments' . DS . 'admin_view.ctp',
    new PatternExpectation('/ViewTaskComment/')
));
$this->Task->expectAt(2, 'createFile', array(
    TMP . 'view_task_comments' . DS . 'admin_add.ctp',
    new PatternExpectation('/Add ViewTaskComment/')
));
$this->Task->expectAt(3, 'createFile', array(
    TMP . 'view_task_comments' . DS . 'admin_edit.ctp',
    new PatternExpectation('/Edit ViewTaskComment/')
));
 
$this->Task->execute();

上記のテストでは複数のモックを使っています。また、どうやってシェルメソッドの予想とアサーションを書くかを示しています。PatternExpectationsをどう使うかと同様、モック化されたメソッドの入力をどうアサートするかわかるでしょう。

シェルクラスのテストが少しは楽になりましたか?もっと多くの例が知りたければ、coreのシェルクラスのテストケースを見るといいでしょう。

PHP5の13の組み込み例外

このエントリーをはてなブックマークに追加
はてなブックマーク - PHP5の13の組み込み例外
Share on Facebook

今さらですが、PHP5の組み込み例外についてメモしておきます。どこまで使い分けるべきかわかりませんが、Exceptionよりもう少し細かい分類がしたいときに使えば良いかと思います。

大きく分けるとExceptionの下にLogicException系RuntimeException系があります。継承関係は SPL-StandardPHPLibrary: Exception Class Reference がわかりやすいです。

13の例外

  1. BadFunctionCallException
    • 未定義の関数が呼ばれた、または引数が足りない
    • LogicExceptionを継承
  2. BadMethodCallException
    • 未定義の関数が呼ばれた、または引数が足りない
    • BadFunctionCallExceptionを継承
  3. DomainException
    • 定義済みのデータドメインと値が合わない
    • LogicExceptionを継承
  4. InvalidArgumentException
    • 引数が期待されている値と異なる
    • LogicExceptionを継承
  5. LengthException
    • 長さが不正
    • LogicExceptionを継承
  6. LogicException
    • 論理式が不正
    • Exceptionsを継承
  7. OutOfBoundsException
    • 値が正しいキーではない
    • RuntimeExceptionを継承
  8. OutOfRangeException
    • 値が範囲外
    • LogicExceptionを継承
  9. OverflowException
    • 新しい要素がコンテナ内に入らない
    • RuntimeExceptionを継承
  10. RangeException
    • 不正な範囲が与えられた
    • RuntimeExceptionを継承
  11. RuntimeException
    • ランタイム(プログラム実行時)のエラーに対する例外
    • Exceptionを継承
  12. UnderflowException
    • 空のコンテナから要素を削除することはできない
    • RuntimeExceptionを継承
  13. UnexpectedValueException
    • 予期しない値が渡された
    • RuntimeExceptionを継承

参考

ディレクトリ・ファイル操作

このエントリーをはてなブックマークに追加
はてなブックマーク - ディレクトリ・ファイル操作
Share on Facebook

CakePHPにはディレクトリ操作とファイル操作をするためのクラスが用意されています。地味に便利なのは、フォルダを丸ごと削除するFolder::delete()です。PHP単体でやるとディレクトリ内のファイルをチェックしなければならず、長くなるので。

ディレクトリ操作

Folderクラスを使います。

[CakePHP]folder.phpの使い方

※CakePHP1.3から、mkdir, mv, ls, cp, rmは非推奨になったようです。代わりにそれぞれ、create, move, read, copy, deleteを使うようにした方が良さそうです。

ファイル操作

[CakePHP]file.phpの使い方

参考

良いモデラー: “Surya Marali”

このエントリーをはてなブックマークに追加
はてなブックマーク - 良いモデラー: “Surya Marali”
Share on Facebook

My World in Three Dimensions!

部屋のモデルがメイン。レンダリング後の画像がいろいろあります。インテリアが細かく作り込まれています。

作者の”Surya Marali”さんのダウンロード可能なモデルは下にあります。

Surya Murali によるモデル – Google 3D ギャラリー検索

GSUで家を作るときのメモ

このエントリーをはてなブックマークに追加
はてなブックマーク - GSUで家を作るときのメモ
Share on Facebook

何となくGoogle Sketch Upで家を作ることがあります。現実感をもたせるために実際の建築と同じ縮尺で作りたいと思い、いろいろな規格を調べてみました。せっかくなのでメモしておきます。建築家に求められるほど厳密なものではないですが、趣味で使うには充分だと思います。

平均的な値と、簡略化した値(だいたい0.1m単位)を書いておきます。ミリ単位の細かい値だと操作が面倒なので。

ちなみに家とマンションとでは基準が異なるようです。以下は一戸建ての家のケースです。

壁・床の厚さ

部位 平均的な値 簡略化する場合
145mm-200mm 0.1m, 0.2m
150mm-300mm 0.2m, 0.3m

ドアのサイズ

玄関、居室、リビング、トイレといった用途毎にサイズが違うようです。また、ドアを販売しているメーカー等もありますが、明確に規格が決まっているわけではないようです。YKK AP株式会社のホームページにはいろいろなドアのカタログがあって面白かったです。

部位 平均的な値 簡略化する場合
玄関 780mm, 922mm 0.8m, 0.9m
居室 684mm, 764mm, 812mm 0.7m, 0.8m
トイレ 606mm 0.6m

トイレのドアは小さいようです。我が家も計ってみたらそうでした。

参考

mod_rewriteでクエリ付きのURIをリダイレクトする

このエントリーをはてなブックマークに追加
はてなブックマーク - mod_rewriteでクエリ付きのURIをリダイレクトする
Share on Facebook

http://hige.jp/moja.php?category=1 → http://hige.jp/moja.php?category=999

というリダイレクトをApacheで実現するにはどうすればいいでしょうか?リダイレクトの方法はいろいろありますが、今回RedirectやAliasディレクティブは使えません。これらはクエリ文字列(?category=999の部分)を把握できないようです。

そんな時は、mod_rewriteの RewriteCond ${QUERY_STRING} を使うと良いです。具体的には以下の用にします。

<IfModule mod_rewrite.c>
    RewriteEngine on
    RewriteCond %{QUERY_STRING} category=1
    RewriteRule ^/moja.php /moja.php?category=999 [R=301,L]
</IfModule>

これでApacheを再起動すれば、リダイレクトが成功するようになっています。

参考

コントローラを単純化するために例外を使う

このエントリーをはてなブックマークに追加
はてなブックマーク - コントローラを単純化するために例外を使う
Share on Facebook

2009年6月12日の記事でだいぶ古いのですが、CakePHPのコードを良くするTIPSがあったので訳してみました。

Simplifying Controller logic with Exceptions | Mark Story

要約すると、「モデル内で例外を吐くようにすると、エラーコードが読みやすくなり、コントローラのテストもしやすくなるよ」ということです。


増大するコードと格闘する日々・・・どうにかクリエイティブな方法で解決したい。そんな悩みを、モデルのメソッドから例外を投げる方法で解決しました。別にビックリするようなものではないです。ただfalseを返すより、ちょっと便利な点がいくつかあります。

第一に、if-elseを減らすことができます。第二に、エラーが起こる部分のソースにエラーメッセージを書くことができるので、何度も使うメソッドならエラーメッセージを重複させずに書くことができます。

例えば以下のメソッドは、リモートアドレスからリソースをダウンロードし、 ローカルファイルシステムとデータベースに記録するものです。(訳注: ダウンロードの時点で失敗すると例外を、保存に成功するとtrueを、保存に失敗するとfalseを返します。)

public function downloadResource($url, $userId, $type = 'image') {
    if (!isset($this->_fetchMimes[$type])) {
        throw new OutOfBoundsException(__('Invalid media type', true));
    }
    $this->_loadSocket($url);
    $resource = $this->Socket->get($url);
    if (!isset($this->Socket->response['header']['Content-Type'])) {
        throw new OutOfBoundsException(__('Submitted url has no Mime-Type', true));
    }
    $allowedContentTypes = $this->_fetchMimes[$type];
    if (!in_array($this->Socket->response['header']['Content-Type'], $allowedContentTypes)) {
        throw new OutOfBoundsException(__('Submitted url has an invalid Mime-Type', true));
    }
    $newFile = array(
        'File' => array(
            'file' => $this->_saveFetchedFile($resource, $url, $this->Socket->response['header']['Content-Type']),
            'user_id' => $userId,
            'title' => $url,
        )
    );
    $this->create($newMedia);
    if ($this->save()) {
        return true;
    }
    return false;
}

見てわかる通り、起きてはならないことが起きた時には OutOfBoundsExceptions を投げています。最近のバージョンのPHPに含まれているSPLライブラリ (訳注: 標準PHPライブラリ) には、便利なクラスが多数用意されています。もちろん自分で例外を作ることもできますが、たいていは組み込みの例外を使うだけで十分でしょう。

これを使えば、コントローラのメソッドをかなりスッキリさせることができます。何重ものifでチェックする必要が無くなります。また、シンプルでエラーの見通しも良いコードを書くことができます。

try {
    $this->File->downloadResource($this->data['File']['url'], $this->Auth->user('id'), 'image');
    //do some additional file handling and data processing.
 
    $this->Session->setFlash(__('File uploaded successfully', true));
} catch(OutOfBoundsException $e) {
    $this->Session->setFlash($e->getMessage());
}
$this->redirect(array('action' => 'index'));

このメソッドの例外発生をテストするのは簡単です。assertFalse を使うのではなく、pass()とfail()を使えばいいのです。

// (訳注) 不正なURLを送った時に例外が発生することをテストする
try {
    $this->File->downloadResource('http:/bogus.com/', 1, 'image');  // 間違ったURL
    $this->fail('No exception thrown with bogus arguments');
} catch (Exception $e) {
    $this->pass('Exception thrown');
}

例外を使うと便利になることは多いです。だからと言って、falseを返すタイプのメソッドすべてを例外を返すようにする必要はないでしょう。たとえばヘルパから例外を返すようにすると、要望を満たしてくれるよりも苦痛を感じることのほうが多くなるでしょう。

どんなツールもそうですが、正しく使えばメンテナンス性の良いコードを生み出すことができるのです。

モデルを使わないコンポーネントの単体テスト

このエントリーをはてなブックマークに追加
はてなブックマーク - モデルを使わないコンポーネントの単体テスト
Share on Facebook

CakePHP 1.3.2でコンポーネントを単体テストする場合、モデルの有無によって大幅に面倒くささが変わってきます。なぜならコンポーネントはモデルを直接操作できないので、間にコントローラを作成する必要があるからです。

公式マニュアルにある方法はモデルを使うケースのみで、しかもわかりづらいです (どのファイルのどのブロックに書いてるのかわからない)。今回はモデル無しのコンポーネントのテスト方法をメモしておきます。

やり方

以下のコンポーネント、「HigeComponent」をテストする場合を考えます。

// app/controllers/components/hige.php
<?php
class HigeComponent extends Object {
    function moja() {
        return "mojamoja";
    }
}
?>

テストケースは下記の通り、コンポーネントを読み込んで直接実行するだけです。

// app/tests/cases/components/hige.test.php
<?php
App::import("Component", "Hige");
    
class HigeComponentTestCase extends CakeTestCase {
    function setUp() {
        $this->component = new HigeComponent();
    }
    
    // テストケース
    function testMoja() {
        $result = $this->component->moja();
        $expected = "moja";
        
        $this->assertEqual($result, $expected);
    }
}
?>

あとは、下記の通りコマンドを打ち込めばOK。(cake.batにパスを通さずやってます)

cd c:\xampp\htdocs\cakephp\app
../cake/console/cake testsuite app case components/hige

出力結果


Welcome to CakePHP v1.3.2 Console
---------------------------------------------------------------
App : app
Path: c:\xampp\htdocs\cakephp\app
---------------------------------------------------------------
CakePHP Test Shell
---------------------------------------------------------------
Running app case components/hige
1/1 test cases complete: 1 passes.
Time taken by tests (in seconds): 0.016170024871826
Peak memory use: (in bytes): 11,534,288

参考

assertionは下記を参考に。

コントローラからビューに渡る変数をアサーションする

このエントリーをはてなブックマークに追加
はてなブックマーク - コントローラからビューに渡る変数をアサーションする
Share on Facebook

公式マニュアルの「コントローラのテスト」では、ただ単に変数をdebugして吐いてるだけで一切アサーションをしていません。これってテストと言えるのでしょうか?コントローラのメソッドが実行された後に予期した値が入っているかチェックするにはどうすればいいでしょうか?

調べたところ、コントローラ内でsetされた変数は viewVars というメンバ変数に格納されるようです。例えば、下記のようなコントローラがあった場合。

// app/controllers/usos_controller.php
class UsosController extends AppController {

    var $name = 'Usos';

    function hige() {
        $this->set('moja', $this->Uso->find("all"));
    }
}
?>

これをコントローラのテストクラスで取得するには下記のようにすれば良いです。

// app/tests/cases/controllers/usos_controller.test.php
<?php
/* Usos Test cases generated on: 2010-07-16 16:07:53 : 1279264793*/
App::import('Controller', 'Usos');

class TestUsosController extends UsosController {
    var $autoRender = false;
 
    function redirect($url, $status = null, $exit = true) {
        $this->redirectUrl = $url;
    }
 
    function render($action = null, $layout = null, $file = null) {
        $this->renderedAction = $action;
    }
 
    function _stop($status = 0) {
        $this->stopped = $status;
    }
}


class UsosControllerTestCase extends CakeTestCase {
    var $fixtures = array('app.uso');

    function startTest() {
        $this->Usos =& new TestUsosController();
        $this->Usos->constructClasses();
    }

    function endTest() {
        unset($this->Usos);
        ClassRegistry::flush();
    }

    function testHige() {
        $this->Usos->hige();
        $result = $this->Usos->viewVars;
        var_dump($result);
    }
}
?>

出力結果

array(1) {
  ["moja"]=>
  array(1) {
    [0]=>
    array(1) {
      ["Uso"]=>
      array(4) {
        ["id"]=>
        string(1) "1"
        ["name"]=>
        string(11) "Miles Davis"
        ["created"]=>
        string(19) "2010-07-16 15:00:49"
        ["modified"]=>
        string(19) "2010-07-16 15:00:49"
      }
    }
  }
}

参考