EC-CUBE 2系

単体テストガイドライン

by TheVOS posted Sep 28, 2019
?

Shortcut

PrevPrev Article

NextNext Article

ESCClose

Larger Font Smaller Font Up Down Go comment Print
Extra Form
Original source http://svn.ec-cube.net/open_trac/wiki/EC...9%E3%82%8B

本ガイドラインはEC-CUBEの単体テストをPHPUnitを使って行う上でのガイドラインを
株式会社SHIFT様(http://www.shiftinc.jp/)のご協力によりまとめたものとなります。

各クラス共通のガイドライン

1. テストを含めたフォルダ構成

テストコードを含んだフォルダ構成は以下のようになります。
tests以下には、テストコード本体の他にテスト用のユーティリティや設定ファイル等が含まれます。

build.xmlテストやインスペクションを行うための設定ファイルです。
tests
├phpunit.xmlPHPUnitで使う各種設定を記載したファイルです。
SVN上にはphpunit.xml.baseというファイルがありますが、ローカルではこれをコピーしてphpunit.xmlを作成してください。
├ruleset.xmlPHP_CodeSniffer(インスペクションツール)用の設定ファイルです。
├require.phpテストに必要なファイルをインクルードするためのクラスです。
SVN上にはrequire.php.baseというファイルがありますが、ローカルではこれをコピーしてrequire.phpを作成してください。
└classテスト用のクラスを格納するディレクトリです。
 ├Common_TestCase.php他のテストクラスの基底となるクラスです。
 ├replaceテスト用に実装を入れ替えているクラスを格納するディレクトリです。
 └test/utilテスト用のユーティリティを格納するディレクトリです。

テストコードはそれぞれ対応するソースコードと同じ階層に保存します。

2. テストの実行方法

2.1.  実行の準備(初回のみ)

単体テストを実行するためには、ローカルの環境にPHPUnitをインストールしておく必要があります。
また、インクルードパス等をローカルの環境に合わせて書き換えるため、SVNに含まれているファイルをコピーしてローカル用の設定ファイルを作成する必要があります。
手順は下記の通りです。

  • tests/phpunit.xml.baseをコピーしてtests/phpunit.xmlを作成します。
  • <filter><blacklist>タグ以下の「/usr/local/lib」の部分を、ローカルで使われる各種ライブラリが含まれているパスと置換します。
    • この設定はどのファイルをテストのカバレッジ測定の対象にするかを設定するためのものなので、設定をし直さなくても単体テスト自体は問題なく動作します。
  • tests/require.php.baseをコピーしてtests/require.phpを作成します。
  • PHPUnitのモジュールが使用できるように、インクルードパスを設定します。

tests/phpunit.xml、tests/require.phpはsvn:ignoreに設定されているため、自由に書き換えてもコミットはされません。

2.2.  実行

全体のテストを行う場合には、phingのtestターゲットを実行します。
テストの内容はbuild.xmlの中に定義されているため、実際にはphpunitコマンドが発行されます。

% phing test

テストが完了すると、結果がreportsディレクトリ以下に出力されます。

  • reports/tap.log TAP形式のテスト結果
  • reports/unitreport.xml xUnit形式のテスト結果
  • reports/coverage/coverage.xml カバレッジ測定結果のXML(主にJenkinsで処理するためのものなので気にしなくて良いです)
  • reports/coverage/.html カバレッジ測定結果のHTML

個々のテストを行う場合には、テスト対象のディレクトリを指定してphpunitコマンドを実行します。
標準出力ですぐにテスト結果を確認したい場合にはこちらのやり方のほうが良いでしょう。

% phpunit –c tests/phpunit.xml tests/class/pages/LC_Page/LC_Page_InitTest.php
% phpunit –c tests/phpunit.xml tests/class/pages

下の例のようにディレクトリを指定した場合には、ディレクトリ以下にあるテストケースが実行されます。

また、--colorsオプションを付けると、結果が色付きで表示され見やすくなります。

% phpunit --colors –c tests/phpunit.xml tests/class/pages/LC_Page/LC_Page_InitTest.php

カバレッジを測定したい場合には、専用のオプションを指定します。

% phpunit -c tests/phpunit.xml --coverage-html reports/coverage tests/class/pages/LC_Page/LC_Page_InitTest.php

3. テストクラスの構成

テストクラスは、基本的にtests/class/Common_TestCase.phpを継承して作成します。
Common_TestCaseの中には、次節で述べるAssertionを一度に行うverify()関数や
テストの開始時と終了時にDBの準備・後片付けを行うsetUp()/tearDown()関数が含まれています。
個々のテストクラスでは、Common_TestCaseのsetUp()/tearDown()の処理に必要な処理を追加して使います。
また、テストに使用するユーティリティクラスもCommon_TestCaseでまとめてrequireします。

<?php
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が存在します。
これらを細かく使用してテストの期待値を確認することもできますが、複数のasseritionを並べると
最初の方で失敗した場合に後のassertionが実行されず、全体の修正までに時間がかかってしまう場合があります。
これを防ぐため、基本的に期待値と実際の結果はarrayに格納して一度でassertできるようにします。
もちろん、それぞれの値が1つずつの場合はarrayに入れなくても構いません。

<?php
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つの条件をテストします。

  • 良い例
    <?php
    function testAbs_正の値の場合() {
      $expected = 1;
      // テスト対象functionの呼び出し
      $actual = abs(1);
      $this->verify();
    }
    
    function testAbs_負の値の場合() {
      $expected = 2;
      // テスト対象funcitonの呼び出し
      $actual = abs(-2);
      $this->verify();
    }
    
    
  • 悪い例
<?php
function testAbs() {
  $expected[0] = 1;
  $actual[0] = abs(1);

  $expected[1] = 2;
  $actual[1] = -2;

  $this->verify();
}

「悪い例」の書き方の場合、ケースの中身を確認しないと
・何種類のテストを行っているのかが把握できない
・どんな観点でテストを行っているのかが把握できない
といった問題点があります。
1funciton1条件の前提を守った上で、なるべく条件分岐を網羅するようにテストを作成していきます。

6. テストfunctionの命名規則

テストfunctionの名称は、テストの内容を分かりやすくするため

test【function名】_【条件】_【期待する結果】()

という形式で統一します。命名規則を統一することで可読性が上がり、
テストコードを書いた本人でなくてもJenkinsのテストレポートを見るとどのようなテストが行われているかが一目でわかります。
また、条件・期待する結果は日本語で記載することでさらに分かりやすくすることができます。

testAction_必須項目が入力されていない場合_エラー画面に遷移する()

7. テストクラスの分け方

テストクラスはテスト対象のfunction毎に1つずつ分けて作成します。
ただし、後述する「定数による条件分岐」をテストする場合には条件毎にクラスを分ける必要があるためさらに細分化されます。

8.テストクラスの命名規則

上で述べたとおり基本的にテストクラスはテスト対象のfunctionに対応するため、

【対象クラス】_【対象function】Test.php

という名称にします。さらに条件毎にファイルを分ける場合には、

【対象クラス】_【対象function】_【条件】Test.php

とします。

  • LC_Page_Products_Detail_ActionTest.php
    LC_Page_Products_Detail_Action_HasErrorTest.php
    

条件によってファイル名を分ける場合には、ファイル名は日本語を避けて定義するようにしてください。

9. より網羅的にテストを書く方法

1. 定数による条件分岐

defineを使って定義されている定数は、テスト中に自由に上書きすることができません。
そこで、定数の値によって条件が分岐する場合は定数の値ごとにテストコードのファイルを分割します。

  • ソースコード
    <?php
    function sfGetHashString($str, $salt) {
      $res = '';
      if ($salt == '') {
        $salt = AUTH_MAGIC;
      }
      if (AUTH_TYPE == 'PLAIN') {
        $res = $str;
      } else {
        $res = hash_hmac(PASSWORD_HASH_ALGOS, $str . ':' . AUTH_MAGIC, $salt);
      }
      return $res;
    }
    
  • テストコード
    <?php
    $HOME = realpath(dirname(__FILE__)) . "/../../../..";
    // このテスト専用の定数の設定。必ずCommon_TestCase.phpより先に定義する
    define('AUTH_TYPE', 'PLAIN');
    require_once($HOME, "/tests/class/Common_TestCase.php");
    
    // クラス名に条件(authTypePlain)も含める
    class SC_Utils_sfGetHasString_authTypePlainTest extends Common_TestCase {
    
      // AUTH_TYPE=PLAINであることを想定した実際のテストコード
    }
    

このようにCommon_TestCaseより先に指定したい定数を定義することで、このテストクラスに限定した値を定義することができます。

2. exitする箇所のテスト

PHPUnitでテストを行う際には実際にphpのプログラムを走らせることになりますが、
プログラム中にexitする箇所があるとそこでPHPUnit自体も終了してしまうため、有効なテスト結果を得ることができません。
EC-CUBEの場合は、pages以下のクラスでSC_Response_Ex::sendRedirect()やSC_Response_Ex::actionExit()を呼んでいる箇所がそれにあたります。
このような部分をきちんとテストするために、テスト実施時はSC_Response_Exの実装を切り替えてexitしないようにします。
実装を切り替えた後のクラスはtests/class/replace以下に存在します。
このクラスをCommon_TestCaseから呼び出すことにより、テスト時の実装を切り替えます。

<?php
 /**
   * 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をそのまま実行すると中の分岐が多すぎてテストしきれない場合があります。
そのような場合は、内側のfunctionの実装をモックに切り替えて欲しい値を自由に返すようにし、外側のfunctionだけをテストするようにします。

  • ソースコード
    <?php
    class Sample {
    function hoge() {
      if (fuga()) {
        return 1;
      } else {
        return 2;
      }
    }
    
    function fuga() {
       return rand() % 2 == 0;
    }
    }
    
  • テストコード
    <?php
    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の内容を期待値に合わせてリセットする必要があります。
そのため、EC-CUBEのユーティリティであるSC_Queryクラスを使ってsetUp()の中でデータの準備を行い、tearDown()でロールバックを行います。

<?php
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・モバイル・スマートフォンのいずれになっているかによって条件が分岐する場合は、
テスト専用のユーティリティを使用して擬似的に端末の種別を設定します。ユーティリティはtests/class/test/util/User_Utils.phpに定義されています。

<?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の値を書き換えることによりテストで要求する分岐を実現します。
情報を書き換えるfunctionは、端末種別設定と同じくtests/class/test/util/User_Utils.php内で定義されています。

<?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('ログイン状態');
  }


Facebook [en]Comment