CakePHP 2.xのtestActionをちゃんと理解しよう

CakePHPでの開発を始めて1年半近く経ちました。しばらく、テストは目視でのテストばかりやっていたのですが、最近になってようやくPHPUnitを使ったテストを始めました。

以前にJavaの開発でJUnitを使っていたことはあるので、xUnitを使ったユニットテストのやり方とか考え方は理解しています。なので、CakePHPでのModelのテストは難なく理解出来たのですが、Controllerをどうやってテストするのか?というのが難しかった。
そもそも、ControllerというはHTTPのRequestとResponseが前提なので、Modelのように単純にテストコード内でインスタンスを作って、メソッドを呼び出して、結果を検証して・・・というようには行きません。
以前のJUnitでやっていた頃はWebのフレームワークとしてStrutsを使っていたけど、あのときはActionのテストってどうやったかな・・・。

CakePHP2.xとPHPUnit

CakePHPでは2.xからユニットテストフレームワークがPHPUnitに変わりました。テストコードのベースクラスとなるCakeTestCaseはPHPUnit_Framework_TestCaseのサブクラスなので、CakePHPのTestSuiteは完全にPHPUnitを継承したテストフレームワークといえるでしょう。なので、PHPUnitのドキュメントはまず読んでおくべきです。これから説明するtestActionの使い方でも強く関連してきます。

もちろん、CakePHP 2.x BookのTestingのページも必読。(書いてないことも多いけど)

class PostsController extends AppController {
  public $components = array('SocialBookmark');

  public function view($id) {
    $post = $this->Post->read(null, $id);
    $hatena = $this->SocialBookmark->getHatena($post['Post']['permalink']);
    $this->set(compact('post', 'hatena'));
  }
}

こんな適当なControllerがあったとします。
テストコードはこんな感じになるでしょう。

class TestPostsController extends PostsController {
  public $autoRender = false;

  public function redirect($url, $status = null, $exit = true) {
    $this->redirectUrl = $url;
  }
}

class PostsControllerTest extends ControllerTestCase {
  public $fixtures = array('app.post');

  public function setUp() {
    parent::setUp();
    ob_end_clean();
    $this->Posts = new TestPostsController();
    $this->Posts->constructClasses();
  }

  public function tearDown() {
    unset($this->Posts);
    ob_start();
    parent::tearDown();
  }

  public function testView() {
    …
  }
}

testAction

さて、問題はtestViewをどう書くかです。

Controllerのメソッドを直接呼び出す方式もありますが、定石はtestActionを使う方法だと思います。

public function testView() {
  $return = $this->testAction('/posts/view/1', array('method' => 'get', 'return' => 'contents'));
  debug($return);

これで$returnにはViewのレンダリングまで終えた文字列がセットされるので、適宜assertすれば良いでしょう。
POSTの場合は、こうなります。

public function testAdd() {
  $data['Post']['title'] = '記事のタイトル';
  $data['Post']['content'] = '記事の本文';
  $return = $this->testAction('/post/add', array('data' => $data, 'method' => 'post', 'return' => 'contents'));
  debug($return);
}

TestPostsControllerは必要か?

ところで、testAction(‘/posts/view/1’)などすると、これはどのControllerが動くのでしょうか?実際のPostsController?それともテストコード内に書いたTestPostsController?

そもそも、TestPostsControllerは一体何のためにあるのでしょうか?cake bake TestCaseをやると、一緒にbakeされます。(bakeするとTestクラスの親クラスはControllerTestCaseではなくCakeTestCaseになります。testActionを使うためにControllerTestCaseに書き直しています)

上記の書き方でtestActionした場合、実行されるControllerはPostsControllerになります。TestPostsControllerは無用の長物です。
とはいえ、TestPostsControllerを呼び出す方法もあります。

$return = $this->testAction('/test_posts/view/1', array('method' => 'get', 'return' => 'contents');

んー。そりゃそうか。でも、これはこれで役立つこともあると思います。
でも、テストコードの中で直接Controllerのメソッドを呼び出したり、testActionで/test_postsのような呼び出し方をしないのであれば、あえてTestPostsControllerを作る必要はなさそうです。

$this->generate()を使う方法

setUp()のこの部分に注目してください。これもまたbakeで焼かれるコードなのですが・・・。

$this->Posts = new TestPostsController();
$this->Posts->constructClasses();

ここでインスタンスを作るのがTestPostsControllerではなくてPostsControllerであったとしても一緒なのですが、この$this->Postsは、testActionで呼び出されるのでしょうか?
これは先に説明したとおりで、呼び出されませんね。つまり、このコードもControllerのメソッドを直接呼び出すときに使うというだけで、testActionとは無関係なんですね。

では、testActionから呼び出すControllerをTestPostsControllerにする方法はないでしょうか?

$this->Posts = $this->generate('TestPosts');
$this->testAction('/posts/view/1');

この書き方なら、testActionで実行されるControllerはTestPostsControllerになります!
ちなみに、$this->generate()の戻り値を$this->Postsにセットしていますが、実はセットしなくても構いません。というのは、generateメソッドは引数として渡されたController名のControllerのモックを作成し、それを$this->controllerにセットしています。そして、testActionでは、$this->controllerにセットされたControllerがある場合、そのControllerに対してRequest(のモック)をディスパッチするのです。
(あらかじめ$this->controllerにセットされたControllerがない場合は、testAction内で引数にセットされたURLよりControllerのモックを作成し、Requestをディスパッチします。)

さらに、この方法を使うと、こんなことも出来ます。
(TestPostsControllerも作らないとすると・・・)

$this->Posts = $this->generate('Posts', array('components' => array('SocialBookmark' => array('getHatena', 'getGooglePlus')));
$this->Posts->SocialBookmark->expects($this->once())->method('getHatena');
$this->Posts->SocialBookmark->expects($this->never())->method('getGooglePlus');
$this->testAction('/posts/view/1');

コンポーネントのモックでテストしているわけですね。getHatena()が1回呼び出されて、getGooglePlus()は呼び出されないことを検証できます。(他意はありません)

ということでtestActionは奥が深いですね。

この記事を書いた人

井上 研一

株式会社ビビンコ代表取締役、ITエンジニア/経済産業省推進資格ITコーディネータ。AI・IoTに強いITコーディネータとして活動。2018年、株式会社ビビンコを北九州市に創業。IoTソリューションの開発・導入や、画像認識モデルを活用したアプリの開発などを行っている。近著に「使ってわかった AWSのAI」、「ワトソンで体感する人工知能」。日本全国でセミナー・研修講師としての登壇も多数。