11. 11日目: フォームのテスト

  • この記事は Symfony 1.4 向けのオリジナルの Jobeet Tutorial の一部分です。
10 日目では、 Symfony 2.3 での最初のフォームを作成しました。
現在は Jobeet にユーザーが新しいジョブを投稿することができますが、それに対するテストを追加する前に時間切れになってしまいました。
つまり、これらの線に沿って行っていきます。

11.1. フォームの送信

それではジョブの作成と検証の機能テストを追加するために、 JobControllerTest ファイルを開いてみましょう。
ジョブ 作成ページを取得するために、ファイルの終わりに次のコードを追加します。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

// ...

public function testJobForm()
{
    $client = static::createClient();

    $crawler = $client->request('GET', '/job/new');
    $this->assertEquals('Ibw\JobeetBundle\Controller\JobController::newAction', $client->getRequest()->attributes->get('_controller'));
}
フォームを選択するために selectButton() メソッドを使用します。このメソッドは、button タグを選択し、 input タグを送信することができます。
ボタンを表すクローラを入手したら、ボタンノードを包むフォームのインスタンスを取得するために、 form() メソッドを呼び出します。
$form = $crawler->selectButton('Submit Form')->form();

Note

上記の例では、インプットタイプが submit で value属性に 「Submit Form」をもつものを選択します。

そしてまた、 form() メソッドを呼び出す際、デフォルトのものをオーバーライドし、フィールドの値を配列で指定することができます。

$form = $crawler->selectButton('submit')->form(array(
    'name' => 'Fabien',
    'my_form[subject]' => 'Symfony Rocks!'
));

では、実際に選択し、フォームに有効な値を渡してみましょう。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

// ...

public function testJobForm()
{
    $client = static::createClient();

    $crawler = $client->request('GET', '/job/new');
    $this->assertEquals('Ibw\JobeetBundle\Controller\JobController::newAction', $client->getRequest()->attributes->get('_controller'));

    $form = $crawler->selectButton('Preview your job')->form(array(
        'job[company]'      => 'Sensio Labs',
        'job[url]'          => 'http://www.sensio.com/',
        'job[file]'         => __DIR__.'/../../../../../web/bundles/ibwjobeet/images/sensio-labs.gif',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[description]'  => 'You will work with symfony to develop websites for our customers.',
        'job[how_to_apply]' => 'Send me an email',
        'job[email]'        => 'for.a.job@example.com',
        'job[is_public]'    => false,
    ));

    $client->submit($form);
    $this->assertEquals('Ibw\JobeetBundle\Controller\JobController::createAction', $client->getRequest()->attributes->get('_controller'));
}
ブラウザはアップロードするファイルの絶対パスを渡すことでファイルのアップロードもシミュレートします。
フォームを送信した後、実行されたアクションが create であることを確認しました。

11.2. フォームのテスト

フォームが有効な場合、ジョブが作成され、ユーザーがプレビューページにリダイレクトされている必要があります。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

public function testJobForm()
{
    // ...
    $client->followRedirect();
    $this->assertEquals('Ibw\JobeetBundle\Controller\JobController::previewAction', $client->getRequest()->attributes->get('_controller'));
}

11.3. データベースレコードのテスト

最終的に確認したいことは、ジョブがデータベースに作成されたこと、そして、ユーザーがまだジョブを公開していないため is_activated カラムが false に設定されていることです。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

public function testJobForm()
{
    // ...
    $kernel = static::createKernel();
    $kernel->boot();
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');

    $query = $em->createQuery('SELECT count(j.id) from IbwJobeetBundle:Job j WHERE j.location = :location AND j.is_activated IS NULL AND j.is_public = 0');
    $query->setParameter('location', 'Atlanta, USA');
    $this->assertTrue(0 < $query->getSingleScalarResult());
}

11.4. エラーのテスト

ジョブフォームでのジョブの作成は有効な値を送信した場合は期待どおりに動作しています。
それでは、有効ではないデータを送信した場合のテストを追加してみましょう。
public function testJobForm()
{
    // ...
    $crawler = $client->request('GET', '/job/new');
    $form = $crawler->selectButton('Preview your job')->form(array(
        'job[company]'      => 'Sensio Labs',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[email]'        => 'not.an.email',
    ));
    $crawler = $client->submit($form);

    // check if we have 3 errors
    $this->assertTrue($crawler->filter('.error_list')->count() == 3);

    // check if we have error on job_description field
    $this->assertTrue($crawler->filter('#job_description')->siblings()->first()->filter('.error_list')->count() == 1);

    // check if we have error on job_how_to_apply field
    $this->assertTrue($crawler->filter('#job_how_to_apply')->siblings()->first()->filter('.error_list')->count() == 1);

    // check if we have error on job_email field
    $this->assertTrue($crawler->filter('#job_email')->siblings()->first()->filter('.error_list')->count() == 1);
}
ここで、ジョブのプレビューページで見つかる admin バーをテストする必要があります。
ジョブがまだアクティブ化されていないときは、ジョブの、編集・削除・公開をすることができます。
これらの 3 つのアクションをテストするには、はじめにひとつのジョブを作成しておく必要があります。
しかし、ジョブ作成のコードがすでにコピー&ペーストで増えてしまっています。そこで、 JobControllerTest クラスにジョブ作成メソッドを追加してみましょう。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

// ...

public function createJob($values = array())
{
    $client = static::createClient();
    $crawler = $client->request('GET', '/job/new');
    $form = $crawler->selectButton('Preview your job')->form(array_merge(array(
        'job[company]'      => 'Sensio Labs',
        'job[url]'          => 'http://www.sensio.com/',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[description]'  => 'You will work with symfony to develop websites for our customers.',
        'job[how_to_apply]' => 'Send me an email',
        'job[email]'        => 'for.a.job@example.com',
        'job[is_public]'    => false,
  ), $values));

    $client->submit($form);
    $client->followRedirect();

    return $client;
}
createJob() メソッドは、ジョブを作成し、リダイレクトをたどり、ブラウザを返します。
引数に配列を渡すことが出来、デフォルト値にマージされます。
パブリッシュアクションのテストは今より簡単です。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

public function testPublishJob()
{
    $client = $this->createJob(array('job[position]' => 'FOO1'));
    $crawler = $client->getCrawler();
    $form = $crawler->selectButton('Publish')->form();
    $client->submit($form);

    $kernel = static::createKernel();
    $kernel->boot();
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');

    $query = $em->createQuery('SELECT count(j.id) from IbwJobeetBundle:Job j WHERE j.position = :position AND j.is_activated = 1');
    $query->setParameter('position', 'FOO1');
    $this->assertTrue(0 < $query->getSingleScalarResult());
}

削除アクションのテストは非常に似ています。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

// ...

public function testDeleteJob()
{
    $client = $this->createJob(array('job[position]' => 'FOO2'));
    $crawler = $client->getCrawler();
    $form = $crawler->selectButton('Delete')->form();
    $client->submit($form);

    $kernel = static::createKernel();
    $kernel->boot();
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');

    $query = $em->createQuery('SELECT count(j.id) from IbwJobeetBundle:Job j WHERE j.position = :position');
    $query->setParameter('position', 'FOO2');
    $this->assertTrue(0 == $query->getSingleScalarResult());
}

11.5. SafeGuard のテスト

ジョブが公開されている場合、もう編集することはできません。
Edit リンクがプレビューページに表示されていない場合でも、この要件のためにいくつかのテストを追加してみましょう​​。
まず、 createJob() メソッドに別の引数を追加してジョブの自動発行を可能にします。また、役職の値でジョブをひとつ選択して返す getJobByPosition() メソッドを作成します。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

// ...

public function createJob($values = array(), $publish = false)
{
    $client = static::createClient();
    $crawler = $client->request('GET', '/job/new');
    $form = $crawler->selectButton('Preview your job')->form(array_merge(array(
        'job[company]'      => 'Sensio Labs',
        'job[url]'          => 'http://www.sensio.com/',
        'job[position]'     => 'Developer',
        'job[location]'     => 'Atlanta, USA',
        'job[description]'  => 'You will work with symfony to develop websites for our customers.',
        'job[how_to_apply]' => 'Send me an email',
        'job[email]'        => 'for.a.job@example.com',
        'job[is_public]'    => false,
  ), $values));

    $client->submit($form);
    $client->followRedirect();

    if($publish) {
      $crawler = $client->getCrawler();
      $form = $crawler->selectButton('Publish')->form();
      $client->submit($form);
      $client->followRedirect();
    }

  return $client;
}

public function getJobByPosition($position)
{
    $kernel = static::createKernel();
    $kernel->boot();
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');

    $query = $em->createQuery('SELECT j from IbwJobeetBundle:Job j WHERE j.position = :position');
    $query->setParameter('position', $position);
    $query->setMaxResults(1);
    return $query->getSingleResult();
}

ジョブが公開されている場合は、編集ページは 404 ステータスコードを返す必要があります。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

// ...

public function testEditJob()
{
    $client = $this->createJob(array('job[position]' => 'FOO3'), true);
    $crawler = $client->getCrawler();
    $crawler = $client->request('GET', sprintf('/job/%s/edit', $this->getJobByPosition('FOO3')->getToken()));
    $this->assertTrue(404 === $client->getResponse()->getStatusCode());
}
テストを実行すると、期待される結果を取得できないでしょう。昨日、このセキュリティ対策を実装するのを忘れたためです。
テストを書くことは、すべてのエッジケースを考える必要があるので、バグを発見するための素晴らしい方法です。
バグの修正はとてもシンプルで、ジョブがアクティブ化されていれば 404 ページに転送するだけです。

src/Ibw/JobeetBundle/Controller/JobController.php

// ...

public function editAction($token)
{
    $em = $this->getDoctrine()->getManager();

    $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

    if (!$entity) {
        throw $this->createNotFoundException('Unable to find Job entity.');
    }

    if ($entity->getIsActivated()) {
        throw $this->createNotFoundException('Job is activated and cannot be edited.');
    }

  // ...
}

11.6. テストで未来に戻る

ジョブが 5 日以内に期限が切れる、または、すでに期限切れした場合、ユーザーは現在から 30 日間、ジョブの検証を延長することができます。
ブラウザでこの要件をテストすることは簡単ではありません。有効期限がジョブ作成の 30 日後に自動的に設定されてしまうためです。
また、ジョブページを取得するときに、ジョブを延長するためのリンクが存在しません。
確かに、データベース内の有効期限をハックするか、常にリンクを表示するようテンプレートを微調整することは出来ます。しかし、それは退屈で間違いやすいです。
すでに推測してきたように、いくつかのテストを書くことは、時間の節約になります。
はじめに、いつものように extend メソッドに新しいルートを追加する必要があります。

src/Ibw/JobeetBundle/Resources/config/routing/job.yml

# ...

ibw_job_extend:
    pattern:  /{token}/extend
    defaults: { _controller: "IbwJobeetBundle:Job:extend" }
    requirements: { _method: post }

その後、 admin.html.twig の一部、 Extend リンクのコードを extend_form で置き換えます。

src/Ibw/JobeetBundle/Resources/views/Job/admin.html.twig

<!-- ... -->

{% if job.expiresSoon %}
    <form action="{{ path('ibw_job_extend', { 'token': job.token }) }}" method="post">
        {{ form_widget(extend_form) }}
        <button type="submit">Extend</button> for another 30 days
    </form>
{% endif %}

<!-- ... -->

その後、extend アクションと extend フォームを作成します。

src/Ibw/JobeetBundle/Controller/JobController.php

// ...

public function extendAction(Request $request, $token)
{
    $form = $this->createExtendForm($token);
    $request = $this->getRequest();

    $form->bind($request);

    if($form->isValid()) {
        $em=$this->getDoctrine()->getManager();
        $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

        if(!$entity){
            throw $this->createNotFoundException('Unable to find Job entity.');
        }

        if(!$entity->extend()){
            throw $this->createNodFoundException('Unable to extend the Job');
        }

        $em->persist($entity);
        $em->flush();

        $this->get('session')->getFlashBag()->add('notice', sprintf('Your job validity has been extended until %s', $entity->getExpiresAt()->format('m/d/Y')));
    }

    return $this->redirect($this->generateUrl('ibw_job_preview', array(
        'company' => $entity->getCompanySlug(),
        'location' => $entity->getLocationSlug(),
        'token' => $entity->getToken(),
        'position' => $entity->getPositionSlug()
    )));
}

private function createExtendForm($token)
{
    return $this->createFormBuilder(array('token' => $token))
        ->add('token', 'hidden')
        ->getForm();
}

また、preview アクションに extend フォームを追加します。

src/Ibw/JobeetBundle/Controller/JobController.php

// ...

public function previewAction($token)
{
    $em = $this->getDoctrine()->getManager();

    $entity = $em->getRepository('IbwJobeetBundle:Job')->findOneByToken($token);

    if (!$entity) {
        throw $this->createNotFoundException('Unable to find Job entity.');
    }

    $deleteForm = $this->createDeleteForm($entity->getId());
    $publishForm = $this->createPublishForm($entity->getToken());
    $extendForm = $this->createExtendForm($entity->getToken());

    return $this->render('IbwJobeetBundle:Job:show.html.twig', array(
        'entity'      => $entity,
        'delete_form' => $deleteForm->createView(),
        'publish_form' => $publishForm->createView(),
        'extend_form' => $extendForm->createView(),
    ));
}

アクションによって期待されるように、ジョブの extend() メソッドはジョブが延長されているときは true を返し、そうでない場合は false を返します。

src/Ibw/JobeetBundle/Entity/Job.php

// ...

public function extend()
{
    if (!$this->expiresSoon())
    {
        return false;
    }

    $this->expires_at = new \DateTime(date('Y-m-d H:i:s', time() + 86400 * 30));

    return true;
}

最終的に、テストシナリオを追加します。

src/Ibw/JobeetBundle/Tests/Controller/JobControllerTest.php

// ...

public function testExtendJob()
{
    // A job validity cannot be extended before the job expires soon
    $client = $this->createJob(array('job[position]' => 'FOO4'), true);
    $crawler = $client->getCrawler();
    $this->assertTrue($crawler->filter('input[type=submit]:contains("Extend")')->count() == 0);

    // A job validity can be extended when the job expires soon

    // Create a new FOO5 job
    $client = $this->createJob(array('job[position]' => 'FOO5'), true);

    // Get the job and change the expire date to today
    $kernel = static::createKernel();
    $kernel->boot();
    $em = $kernel->getContainer()->get('doctrine.orm.entity_manager');
    $job = $em->getRepository('IbwJobeetBundle:Job')->findOneByPosition('FOO5');
    $job->setExpiresAt(new \DateTime());
    $em->flush();

    // Go to the preview page and extend the job
    $crawler = $client->request('GET', sprintf('/job/%s/%s/%s/%s', $job->getCompanySlug(), $job->getLocationSlug(), $job->getToken(), $job->getPositionSlug()));
    $crawler = $client->getCrawler();
    $form = $crawler->selectButton('Extend')->form();
    $client->submit($form);

    // Reload the job from db
    $job = $this->getJobByPosition('FOO5');

    // Check the expiration date
    $this->assertTrue($job->getExpiresAt()->format('y/m/d') == date('y/m/d', time() + 86400 * 30));
}

11.7. メンテナンスタスク

Symfony は Web フレームワークであっても、コマンドラインツールが付属しています。
アプリケーションバンドルのデフォルトのディレクトリ構造を作成し、モデルのさまざまなファイルを生成するために使用しています。
新しいコマンドを追加するのはとても簡単です。
ユーザーがジョブを作成した際、管理者はジョブをオンラインに置くためにアクティブ化する必要があります。
アクティブ化されない場合、データベースは古いジョブがたまってしまいます。
データベースから古いジョブを削除するコマンドを作成してみましょう。
このコマンドは、 cron で定期的に実行する必要があります。

src/Ibw/JobeetBundle/Command/JobeetCleanupCommand.php

<?php
namespace Ibw\JobeetBundle\Command;

use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Ibw\JobeetBundle\Entity\Job;

class JobeetCleanupCommand extends ContainerAwareCommand {

  protected function configure()
  {
      $this
          ->setName('ibw:jobeet:cleanup')
          ->setDescription('Cleanup Jobeet database')
          ->addArgument('days', InputArgument::OPTIONAL, 'The email', 90)
    ;
  }

  protected function execute(InputInterface $input, OutputInterface $output)
  {
      $days = $input->getArgument('days');

      $em = $this->getContainer()->get('doctrine')->getManager();
      $nb = $em->getRepository('IbwJobeetBundle:Job')->cleanup($days);

      $output->writeln(sprintf('Removed %d stale jobs', $nb));
  }
}

JobRepository クラスにクリーンアップメソッドを追加する必要があります。

src/Ibw/JobeetBundle/Repository/JobRepository.php

// ...

public function cleanup($days)
{
    $query = $this->createQueryBuilder('j')
        ->delete()
        ->where('j.is_activated IS NULL')
        ->andWhere('j.created_at < :created_at')
        ->setParameter('created_at',  date('Y-m-d', time() - 86400 * $days))
        ->getQuery();

    return $query->execute();
}

プロジェクトフォルダから次のコマンドを実行します。

$ php app/console ibw:jobeet:cleanup

または、

$ php app/console ibw:jobeet:cleanup 10

10 日以上経過した古いジョブを削除します。

See also

Symfony2日本語ドキュメント

豊富な日本語ドキュメントがありますので合わせて読み進めてみましょう。

Note

Creative Commons License

このチュートリアルは、クリエイティブ・コモンズ・ライセンス 表示 - 継承 3.0 非移植 (CC BY-SA 3.0) のもとでライセンスされています。 翻訳の元にしたオリジナルはこちらです。 Symfony2 Jobeet http://www.intelligentbee.com/blog/tag/symfony2-jobeet/.