= 単体テストガイドライン = [[PageOutline]] 本ガイドラインはEC-CUBEの単体テストをPHPUnitを使って行う上でのガイドラインを[[BR]] 株式会社Shift様([http://www.shiftinc.jp/])のご協力によりまとめたものとなります。 = 各クラス共通のガイドライン = == 1. テストを含めたフォルダ構成 == テストコードを含んだフォルダ構成は以下のようになります。[[BR]] tests以下には、テストコード本体の他にテスト用のユーティリティや設定ファイル等が含まれます。 ||build.xml||テストやインスペクションを行うための設定ファイルです。|| ||tests|| ||├phpunit.xml||PHPUnitで使う各種設定を記載したファイルです。|| ||├ruleset.xml||PHP_CodeSniffer(インスペクションツール)用の設定ファイルです。|| ||├require.php||テストに必要なファイルをインクルードするためのクラスです。|| ||└class||テスト用のクラスを格納するディレクトリです。|| || ├Common_TestCase.php||他のテストクラスの基底となるクラスです。|| || ├replace||テスト用に実装を入れ替えているクラスを格納するディレクトリです。|| || └test/util||テスト用のユーティリティを格納するディレクトリです。|| テストコードはそれぞれ対応するソースコードと同じ階層に保存します。 == 2. テストの実行方法 == 全体のテストを行う場合には、phingのtestターゲットを実行します。[[BR]] テストの内容はbuild.xmlの中に定義されているため、実際にはphpunitコマンドが発行されます。 {{{ % phing test }}} 個々のテストを行う場合には、テスト対象のディレクトリを指定してphpunitコマンドを実行します。 {{{ % phpunit –c tests/phpunit.xml tests/class/pages/LC_Page/LC_Page_InitTest.php % phpunit –c tests/phpunit.xml tests/class/pages }}} 下の例のようにディレクトリを指定した場合には、ディレクトリ以下にあるテストケースが実行されます。 == 3. テストクラスの構成 == テストクラスは、基本的にtests/class/Common_TestCase.phpを継承して作成します。[[BR]] Common_TestCaseの中には、次節で述べるAssertionを一度に行うverify()関数や[[BR]] テストの開始時と終了時にDBの準備・後片付けを行うsetUp()/tearDown()関数が含まれています。[[BR]] 個々のテストクラスでは、Common_TestCaseのsetUp()/tearDown()の処理に必要な処理を追加して使います。[[BR]] また、テストに使用するユーティリティクラスもCommon_TestCaseでまとめてrequireします。 {{{ SampleTest extends Common_TestCase { protected function setUp() { parent::setUp(); // 個々のテストケースで必要な処理 } protected function tearDown() { // 個々のテストケースで必要な処理 parent::tearDown(); } public function testFunctionName_❍❍の場合_△△になる() { $this->expected = array(‘hoge’, ‘fuga’); $this->actual = array(); $this->actual[0] = functionName(‘a’); $this->actual[1] = functionName(‘b’); $this->verify(); } } }}} == 4. Assertion(期待値の確認)の方法 == PHPUnitにはassertEquals()、assertTrue()など様々な期待値の確認用funcitonが存在します。[[BR]] これらを細かく使用してテストの期待値を確認することもできますが、複数のasseritionを並べると[[BR]] 最初の方で失敗した場合に後のassertionが実行されず、全体の修正までに時間がかかってしまう場合があります。[[BR]] これを防ぐため、基本的に期待値と実際の結果はarrayに格納して一度でassertできるようにします。[[BR]] もちろん、それぞれの値が1つずつの場合はarrayに入れなくても構いません。 {{{ protected function verify($msg = null) { $this->assertEquals($this->expected, $this->actual, $msg); } public function testHoge() { $this->expected = array(1, “山田”, “太郎”); // テスト対象を実行して$actualに結果を格納  $this->verify(); } }}} == 5. テストfunctionの分け方 == 原則として、1つのテストfunctionで1つの条件をテストします。 * 良い例 {{{ function testAbs_正の値の場合() { $expected = 1; // テスト対象functionの呼び出し $actual = abs(1); $this->verify(); } function testAbs_負の値の場合() { $expected = 2; // テスト対象funcitonの呼び出し $actual = abs(-2); $this->verify(); } }}} * 悪い例 {{{ function testAbs() { $expected[0] = 1; $actual[0] = abs(1); $expected[1] = 2; $actual[1] = -2; $this->verify(); } }}} 「悪い例」の書き方の場合、ケースの中身を確認しないと[[BR]] ・何種類のテストを行っているのかが把握できない[[BR]] ・どんな観点でテストを行っているのかが把握できない[[BR]] といった問題点があります。[[BR]] 1funciton1条件の前提を守った上で、なるべく条件分岐を網羅するようにテストを作成していきます。 == 6. テストfunctionの命名規則 == テストfunctionの名称は、テストの内容を分かりやすくするため {{{ test【function名】_【条件】_【期待する結果】() }}} という形式で統一します。命名規則を統一することで可読性が上がり、[[BR]] テストコードを書いた本人でなくてもJenkinsのテストレポートを見るとどのようなテストが行われているかが一目でわかります。[[BR]] また、条件・期待する結果は日本語で記載することでさらに分かりやすくすることができます。 * 例 {{{ testAction_必須項目が入力されていない場合_エラー画面に遷移する() }}} == 7. テストクラスの分け方 == テストクラスはテスト対象のfunction毎に1つずつ分けて作成します。[[BR]] ただし、後述する「定数による条件分岐」をテストする場合には条件毎にクラスを分ける必要があるためさらに細分化されます。 == 8.テストクラスの命名規則 == 上で述べたとおり基本的にテストクラスはテスト対象のfunctionに対応するため、 {{{ 【対象クラス】_【対象function】Test.php }}} という名称にします。さらに条件毎にファイルを分ける場合には、 {{{ 【対象クラス】_【対象function】_【条件】Test.php }}} とします。 * 例 {{{ LC_Page_Products_Detail_ActionTest.php LC_Page_Products_Detail_Action_HasErrorTest.php }}} 条件によってファイル名を分ける場合には、ファイル名は日本語を避けて定義するようにしてください。 == 9. より網羅的にテストを書く方法 == 1. 定数による条件分岐 2. exitする箇所のテスト PHPUnitでテストを行う際には実際にphpのプログラムを走らせることになりますが、[[BR]] プログラム中にexitする箇所があるとそこでPHPUnit自体も終了してしまうため、有効なテスト結果を得ることができません。[[BR]] EC-CUBEの場合は、pages以下のクラスでSC_Response_Ex::sendRedirect()やSC_Response_Ex::actionExit()を呼んでいる箇所がそれにあたります。[[BR]] このような部分をきちんとテストするために、テスト実施時はSC_Response_Exの実装を切り替えてexitしないようにします。[[BR]] 実装を切り替えた後のクラスはtests/class/replace以下に存在します。[[BR]] このクラスをCommon_TestCaseから呼び出すことにより、テスト時の実装を切り替えます。 {{{ /** * actionExit()呼び出しを書き換えてexit()させない例です。 */ public function testExit() { $resp = new SC_Response_Ex(); $resp->actionExit(); $this->expected = TRUE; $this->actual = $resp->isExited(); // exit()したかどうかをチェックします。 $this->verify('exitしたかどうか'); } }}} == 10. テストを書きやすくする対策 == 1. functionを「単体」でテストする functionの中でさらにfunctionが呼ばれている場合、いちばん外側のfunctionをそのまま実行すると中の分岐が多すぎてテストしきれない場合があります。[[BR]] そのような場合は、内側のfunctionの実装をモックに切り替えて欲しい値を自由に返すようにし、外側のfunctionだけをテストするようにします。 * ソースコード {{{ class Sample { function hoge() { if (fuga()) { return 1; } else { return 2; } } function fuga() { return rand() % 2 == 0; } } }}} * テストコード {{{ class SampleTest extends PHPUnit_Framcework_TestCase { function testHoge_fugaがtrueの場合() { $sample = new Sample_Mock(); $sample->fuga_val = TRUE; $this->assertEquals(1, $sample->hoge()); } } class Sample_Mock extends Sample { $fuga_val; function fuga() { return $fuga_val; } } }}} この例はそれほど分岐が複雑ではありませんが、内側の関数fuga()の返り値がランダムなのでテストで要求する値を返却させるためにオーバーライドしています。 2. ユーティリティクラスを使ってデータ準備を効率化する テストで常に同じ結果を得られるようにするには、テスト実行のたびにDBの内容を期待値に合わせてリセットする必要があります。[[BR]] そのため、EC-CUBEのユーティリティであるSC_Queryクラスを使ってsetUp()の中でデータの準備を行い、tearDown()でロールバックを行います。 {{{ class SampleTest extends PHPUnit_Framework_TestCase { // データ準備 protected function setUp() { $this->objQuery = SC_Query_Ex::getSingletonInstance(); $this->objQuery->begin(); $this->setUpCustomer(); // 実際にデータを投入する箇所 } // ロールバック protected function tearDown() { $this->objQuery->rollback(); } // データの定義 protected function setUpCustomer() { $arrValue['customer_id'] = $this->customer_id; $arrValue['name01'] = $this->name01; $arrValue['name02'] = $this->name02; $arrValue['kana01'] = $this->name01; $arrValue['email'] = 'test@example.com'; $arrValue['secret_key'] = 'aaaaaa'; $arrValue['status'] = 2; // 会員 $arrValue['create_date'] = 'CURRENT_TIMESTAMP'; $arrValue['update_date'] = 'CURRENT_TIMESTAMP'; $this->objQuery->insert('dtb_customer', $arrValue); } } }}} よく使うデータ定義はtests/class/util以下のユーティリティクラスから取得できるようにしておき、データの再利用性を高めます。 3. ユーティリティクラスを使って端末の種類を設定する 端末の種類がPC・モバイル・スマートフォンのいずれになっているかによって条件が分岐する場合は、[[BR]] テスト専用のユーティリティを使用して擬似的に端末の種別を設定します。ユーティリティはtests/class/test/util/User_Utils.phpに定義されています。 {{{ /** * 端末種別をテストケースから自由に設定する例です。 */ public function testDeviceType() { $this->expected = array(DEVICE_TYPE_MOBILE, DEVICE_TYPE_SMARTPHONE); $this->actual = array(); // 端末種別を設定 User_Utils::setDeviceType(DEVICE_TYPE_MOBILE); $this->actual[0] = SC_Display_Ex::detectDevice(); User_Utils::setDeviceType(DEVICE_TYPE_SMARTPHONE); $this->actual[1] = SC_Display_Ex::detectDevice(); $this->verify('端末種別'); } }}} 4. ユーティリティクラスを使ってログイン状態を設定する ユーザがログインしているかどうかによって処理が分岐する場合は、セッションの情報とDBの値を書き換えることによりテストで要求する分岐を実現します。[[BR]] 情報を書き換えるfunctionは、端末種別設定と同じくtests/class/test/util/User_Utils.php内で定義されています。 {{{ /** * ログイン状態をテストケースから自由に切り替える例です。 */ public function testLoginState() { $this->expected = array(FALSE, TRUE); $this->actual = array(); $objCustomer = new SC_Customer_Ex(); // ログインしていない状態に設定 User_Utils::setLoginState(FALSE); $this->actual[0] = $objCustomer->isLoginSuccess(); // ログインしている状態に設定 User_Utils::setLoginState(TRUE, null, $this->objQuery); $this->actual[1] = $objCustomer->isLoginSuccess(); $this->verify('ログイン状態'); } }}}