Symfony勉強会 #6 LTの補足 コントローラのテストを短くする試み

2012年7月2日

symfony-logo

Symfony2勉強会 #6 でLTをしてきました。

まとまりのないLTになってしまい、思い出すのも恥ずかしいのですが、LTの内容を補足します。

目次

  1. LTで話したこと
  2. 個人的に長いと思っているテストコード
  3. 分割した後のテストコード(動きません)
  4. containerから生成したtokenが不正
  5. テスト時のみ、csrfを無効にすると、様々な弊害が
  6. CSRF対策が有効かどうかを判定するif文
  7. DIした情報は、画面遷移で初期化されて消える
  8. 諸先輩からのアドバイス
  9. ふりかえり

LTで話したこと

  • Symfony2のコントローラのファンクショナルテストは、複数アクションにまたがるので一つ一つのテストケースが長くなりがち。
  • テストケースが長いとメンテナンスしづらいので、短くしたい。
  • POSTリクエストのアクションをテストしようとして、CSRF対策に苦しんだ。
  • SessionをDIしても、テストケース内で画面遷移が発生するとDIした情報が消える。
  • 結局何も解決できていない。

個人的に長いと思っているテストコード

下記のように、ひとつのテストケースに複数の画面遷移が発生して、長くなっているテストを分割して短くしようと試みてみました。

public function test成功シナリオ()
{
    $client = static::createClient();

    // /reservation/new
    $crawler = $client->request('GET', '/reservation/new');
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("予約")')->count());
    $form = $crawler->filter('#reservation_new_submit')->form();
    $data = array(
        'reservation_location[departureAt][date][year]' => '2012',
        'reservation_location[departureAt][date][month]' => '4',
        'reservation_location[departureAt][date][day]' => '1',
        'reservation_location[departureAt][time][hour]' => '12',
        'reservation_location[departureAt][time][minute]' => '0',
        'reservation_location[departureLocation]' => '1',
        'reservation_location[returnAt][date][year]' => '2012',
        'reservation_location[returnAt][date][month]' => '4',
        'reservation_location[returnAt][date][day]' => '2',
        'reservation_location[returnAt][time][hour]' => '17',
        'reservation_location[returnAt][time][minute]' => '30',
        'reservation_location[returnLocation]' => '1',
    );
    $crawler = $client->submit($form, $data);

    // reservation/car
    $crawler = $client->followRedirect();
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("車種選択")')->count());
    $form = $crawler->filter('.car-class-box:first-child')->selectButton('この車種を選択する')->form();
    $crawler = $client->submit($form);

    // reservation/option
    $crawler = $client->followRedirect();
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("予約/オプション選択")')->count());
    $form = $crawler->selectButton('予約内容を確認する')->form();
    $data = array(
        'reservation_option[useInsurance]' => 1,
        'reservation_option[note]' => 'メモ',
    );
    $crawler = $client->submit($form, $data);

    // reservation/confirm
    $crawler = $client->followRedirect();
    $this->assertTrue($crawler->filter('h1:contains("予約内容確認")')->count() > 0);
    $form = $crawler->selectButton('予約を確定する')->form();
    $crawler = $client->submit($form);

    // reservation/finish
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("予約完了")')->count());
}

分割した後のテストコード(動きません)

下記のように分割されたテストを目指したのですが、様々なところでつまづき、思うようにテストは動きませんでした。

public function test予約登録画面のGET()
{
    $client = static::createClient();
    // /reservation/new
    $crawler = $client->request('GET', '/reservation/new');
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("予約")')->count());
}

public function test予約登録画面のPOST()
{
    $client = static::createClient();
    $container = $client->getContainer();
    $datum = array();
    $datum = $this->getLocationData();
    $datum['_token'] = $container->get('form.csrf_provider')->generateCsrfToken('unknown');
    $data['reservation_location'] = $datum;
    $crawler = $client->request('POST', '/reservation/new', $data);
    //csrf token エラーが発生してリダイレクトしない
    $crawler = $client->followRedirect();
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("車種選択")')->count());
}

public function test車種選択画面のGET()
{
    $client = static::createClient();
    $container = $client->getContainer();
    $container->get('session')->set('reservation/location', $this->getLocationData());
    $crawler = $client->request('GET', '/reservation/car');
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("車種選択")')->count());
}

public function test車種選択画面のPOST()
{
    $client = static::createClient();
    $container = $client->getContainer();
    $container->get('session')->set('reservation/location', $this->getLocationData());
    $datum = array();
    $datum = $this->getCarData();
    $datum['_token'] = $container->get('form.csrf_provider')->generateCsrfToken('unknown');
    $data['reservation_car'] = $datum;
    $crawler = $client->request('POST', '/reservation/car', $data);
    //csrf token エラーが発生してリダイレクトしない
    //DI した Session が消えてリダイレクトしない
    $crawler = $client->followRedirect();
    $this->assertTrue($client->getResponse()->isSuccessful());
    $this->assertSame(1, $crawler->filter('title:contains("予約/オプション選択")')->count());
}

//以下略
//...
private function getLocationData()
{
    $data = array();
    $data['departureAt'] = array(
        'date' => array('year' => '2012', 'month' => '6', 'day' => '1'),
        'time' => array('hour' => '0', 'minute' => '0'),
    );
    $data['departureLocation'] = '1';
    $data['returnAt'] = array(
        'date' => array('year' => '2012', 'month' => '6', 'day' => '2')
        'time' => array('hour' => '0', 'minute' => '0'),
    );
    $data['returnLocation'] = '1';
    return $data;
}

private function getCarData()
{
    $data = array();
    $data['carClass'] = '1';
    return $data;
}

containerから生成したtokenが不正

下記コードでtokenを取得すると、生成されるtokenが異なるためエラーとなります。

containerから直接tokenを呼び出すと、同一セッションのtokenとみなされないことが原因でしょうか。

$container->get('form.csrf_provider')->generateCsrfToken('unknown');

テスト時のみ、csrfを無効にすると、様々な弊害が

テスト時のみcsrfを無効にすると、様々なところでエラーが起きます。ymlの設定ファイルで無効にできます。

# Symfony/app/config/config_test.yml
framework:
    csrf_protection: false

エラーとなったコード

// その1 $form['_token'] がなくなる
$form = $this->createForm(new ReservationLocationType(), new Reservation());
$token = $form['_token']->getData();
Field "_token" does not exist.
// その2 オプション指定できなくなる
$form = $this->createForm($type, $reservation, array('csrf_protection' => false));
The option "csrf_protection" does not exist
//その3 twigでエラー その1と同じ 問題
<input type="hidden" name="reservation_car[_token]" value="{{ form._token.get('value') }}" />
Method "_token" for object

CSRF対策が有効かどうかを判定するif文

下記if文で判定可能ですが、プロダクトコードが下記のif文であふれるので、使用していません。

if ($this->container->hasParameter('form.type_extension.csrf.enabled')) {
}

DIした情報は、画面遷移で初期化されて消える

$container->get('session')->set('reservation/location', $this->getLocationData());

初回のリクエスト時はDIした情報が消えませんが、テストケース内で画面遷移が発生するとDIした情報が消えます。

諸先輩からのアドバイス

コントローラのテストを短くする試みについて、諸先輩からアドバイスいただきました。

  • コントローラのファンクショナルテストが長くなるのはどうしようもない。そういうもの。ある程度諦める。
  • コントローラのテストよりも、ドメインレイヤの設計をしっかりとやって、サービスにロジックを閉じ込め、そこを重点的にテストする。
  • アプリケーションの挙動に主眼を置くなら、behatを学んで、試してみるとよい

コントローラのファンクショナルテストはそういうもの、と一旦バッサリ斬っていただき、別のアプローチを提案していただきました。

迷いながら進んでいたところでしたので、とても心が軽くなりました。

同時に、ドメインレイヤの設計について興味が湧きました。参考書籍を読んで勉強したいと思っています。

アドバイスしてくださった皆様、感謝の気持ちでいっぱいです。ありがとうございました。

追記 twitterにて下記の記事をご紹介いただきました。画面遷移しても、DIした情報を維持することができるようです。
Practical Symfony: モックオブジェクトによるページフローのテスト | PHPメンターズ

ふりかえり

よかったこと

  • 困っていることを公開してみて、フィードバックをいただくことができた。
  • DDDに興味が湧いた。
  • ひどいLTだったけれど、チャレンジしてよかった。

反省点

  • 準備不足。LT初心者のくせに、前日に慌ててスライド作ってたらダメ。
  • どのくらい時間がかかるか把握できておらず、早口に。
  • 一枚のスライドに載せる情報量が多かった。
  • 5分しかないので、話すテーマはひとつに絞ったほうがよい。
  • なれないうちはカンペ必要。
  • もっと軽い内容にしてもよかったかも。

反省は次回に活かしたいと思います。

-技術ブログ
-,