CakePHP Advent Calendar 2017の8日目です!もう8日経つの・・早い・・

@mosaxiv さんの昨日の記事は、私もslackの#japaneseで追っていたのですが、恐ろしい話ですね。。私もRequestHandlerComponentは非常に便利なので活用しまくっているのですが、CakePHP3をご利用の方は確認しましょう!

コントローラー周りのテストの話です

今回は、CakePHP3を使っていてコントローラーのテストをどのようにするか?という小技を紹介したいと思います。
ただし、私から「こうすると良いぜ!!」という教科書的な記事ではなく、むしろ皆様にオススメの方法やべき論を聞いて見たいな!と思って書きましたw
標準で生えているAPIの話と、個人的に工夫している点などを織り交ぜて挙げていきたいと思います。

前提: コントローラーのテストで何を見るか?

Modelとちがい「結合テスト」と呼ばれる部類の検査が主になると思い1、実際にCakePHPのテストスイートとしては 「TestCase」とは別の「IntegrationTestCase」を用意しています。

https://api.cakephp.org/3.5/class-Cake.TestSuite.IntegrationTestCase.html

この中で「実際にどんなことが検査項目として有り得そうか」というパターンを考えることになるのですが、普段は大体以下のような点を意識しています。

  1. そのURLにアクセスして、返答が来るか
  2. リクエスト内容に対しての返答内容は適切か
    • response header / status code
      • 特に権限管理etc
    • response body(view vars)
  3. DBへの作用を伴うアクションの場合、適切に更新がなされているか
  4. 期待するようにコンポーネントを呼び出せているか

それぞれのトピックについて、イディオムを挙げていきたいと思います。

具体的なケース、サンプル

1. そのURLにアクセスして、返答が来るか

Routingの設定状況の検査も、いちいち単独テストを持たせずにコントローラー側のテストケースでカバーしています

$this->get('/user/123');
$this->assertResponseOk('ユーザーページが開けない');

2. リクエスト内容に対しての返答内容は適切か

レスポンスコードの検査

  • サクセスを期待する assertResponseOk()
  • 具体的にコードを指定する assertResponseCode()
  • クライアントサイドエラーを期待する assertResponseError
  • サーバーサイドエラーを期待する assertResponseFailure
  • リダイレクトを期待する assertRedirect()
    • 更に、その内容を検査する assertRedirectContains()

辺りが利用できます。

$this->get('/users');
$this->assertResponseOk('ユーザー一覧ページにアクセスできない');

$expected = $this->Users
    ->find()
    ->orderDesc('id')
    ->limit(10);
$actual = $this->viewVariable('users');
$this->assertSame(
    $expected->toArray(),
    $actual->toArray()
    'ユーザー一覧で初期状態の取得内容が正しくない'
);
$this->put('/cart/add', ['item_id' => $item]);
$this->assertResponseCode(
    204,
    'カート追加に成功した際のステータスコードが正しくない'
);
$this->assertResponseEmpty('カート追加に成功した際応答に本文が含まれている');

リクエストのコンテキストを設定する方法

例えば「リクエストヘッダーを指定したい」といった時は、 setConfig() を用います。
このメソッドは、「リクエストを1度シミュレーションしたらリセットされる」ということに注意してください。

$this->configRequest([
    'headers' => [
        'User-Agent' => 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
     ]
]);
$this->get('/');
$this->assertRedirect(
    '/m/index',
    'スマホからのアクセスがモバイルサイトにリダイレクトされていない'
);

3. DBへの作用を伴うアクションの場合、適切に更新がなされているか

当該のアクションへのリクエストを行ったあとに、データベースからデータを取得して更新の実施を確認します。

新規セーブの場合

// 空のfixtureを用意するか、
// あるいはテストケース内でテーブルが空であることを保証しておく
$name = '素晴らし太郎';
$this->post('/user/signup', compact('name')]);
$actual = $this->Users->findByName($name);
$this->assertTrue(!$actual->isEmpty(), '新規ユーザー登録ができていない');

更新の場合

$user = $this->User->find()->first();
$name = $user->name . '(2)'; 
$this->put('/user/signup', compact('name')]);
$actual = $this->Users->get($user->id);
$this->assertSame($name, $actal->name, 'nameの更新ができていない');

削除の場合

$user = $this->User->find()->first();
$this->session([
    'Auth' => $user->toArray()
]);
$this->post('/user/leave');
$this->assertTrue(
    $this->Users->findById($user->id)->isEmpty(),
    '退会処理ができていない'
);

4. 期待するようにコンポーネントを呼び出せているか

特定のコンポーネントを呼び出すことをアクションの責務としている、という場合もあると思います。
例えば「メールの送信処理をhookする」「Slackに投稿をする」など1は、実際の挙動をテストしたり返り値の検査だけでは難しいのではないでしょうか
その際には、コンポーネントとしてspyを注入して「呼び出されたかを検査する」ということが可能です。

仕組みとしては、Controller.Initializeに処理を購読させることで任意のオブジェクトを注入します。その内容を、予め作成しておいたスパイオブジェクトとして、処理を横取りさせるということです

controllerSpyの利用

名前の通りのcontrollerSpy() というメソッドが、CakePHPに予め用意されています

これを利用して、TestCaseにつき1つまで「任意のオブジェクトを注入する」という事が可能です。

public function controllerSpy($event, $controller = null)
{
    parent::controllerSpy($event, $controller);
    $componentRegistry = new ComponentRegistry($this->_controller);
    $imageUploaderSpy = $this->createPartialMock(ImageUploadComponent::class, ['upload']);
    $imageUploaderSpy->expects($this->once())
        ->method('upload')
        ->willReturn('/path/to/uploaded_image.jpeg');
    $this->_controller->ImageUpload = $imageUploaderSpy;
}

public function testPost()
{
    $this->post(
        '/article/23/comment',
        [
            'body' => 'フォトジェニックなパスタ★',
            'image' => 'dummy-image.jpg'
        ]
    );
}

このような書き方をすると、「処理中において ImageUploadComponentuploadメソッドが1度呼ばれていること」を保証する、というテストを実現することができます。2
「渡されさえすれば画像がアップロードされる」というのは、この場合はコンポーネント側の責務と考えられるので、それは単体テストでカバーすれば良いと思います。

自前で注入する場合

先程の書き方だと、メソッド名で示さなければならないという性質上「クラスに対して1つまで」という制約が出てきてしまいます。 3 例えば、$this->once() によって「呼び出しが1回だけ行われることを期待する」ようなものを書いた場合、同一クラス内にて「呼び出しが行われない」というケースを書くことが不可能になります。
そうした問題を回避するため、「Controller.initializeに対して、明示的にフックする」という代替手段を取ることが可能です。実際、私の所属するチームではこの書き方を採用している箇所があります(一方、controllerSpyは開発していく途上で利用されなくなりました。)

先の例を書き換えると、以下のようになります。

public function testPost()
{
    $imageUploaderInjector = function ($event) {
        $imageUploaderSpy = $this->createPartialMock(ImageUploadComponent::class, ['upload']);
        $imageUploaderSpy->expects($this->once())
            ->method('upload');
        $event->subject()->ImageUpload = $imageUploaderSpy;
    };
    EventManager::instance()->on('Controller.initialize', imageUploaderInjecter);

    $this->post(
        '/article/23/comment',
        [
            'body' => 'フォトジェニックなパスタ★',
            'image' => 'dummy-image.jpg'
        ]
    );
}

まとめ

「どこまでやるのが良いのか、難しいな〜」という疑問はいつもつきまといます。しかしながら、「1ヵ月してからこのコードを読むことになるかもしれない」とか「自分以外の人に、正しく意図が伝わるだろうか」などといった問題を考えると、やはりテストコードが整備されているという恩恵はデカいです。
PHPUnit本体は、今日触れたIntegrationTestCaseを含めてCakePHPのTestSuite以下には豊富なボキャブラリが備わっています。暇つぶしがてら眺めてみると、意外と即効的に使えそうな便利メソッドを発見できるかもしれません。おすすめです!

明日は @sizuhiko さんの更新です。テストについて、聞けるのでしょうか!楽しみです〜


  1. テストに関してはtenkomaさんのアドベントの記事で一通りの説明を取り扱っていらっしゃるので、この記事では細々としたところをピックアップしていきたいです 

  2. phpunitのスタブ/モック周りについては、こちらの記事に何度もお世話になりました・・・ヘルパーメソッドを用いると、テストコードの記述がかなりスッキリするのではないでしょうか。 xUTPなどでいう「Tests as Documentation」という側面で考えると、テストコードの冗長な肥大化は「悪」だと思うので、積極的に活用していきたいです。 http://taisablog.com/archives/55 

  3. メソッド内で条件分岐を行い、「必要なときにだけ必要なものを注入する」ということは可能です。 

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.