Symfony 2 Joboard : Функциональное тестирование

Функциональные тесты отлично подходят для полной проверки вашего приложения — от запроса в браузере до выданного сервером ответа. Они покрывает все уровни приложения: маршрутизацию, модели, действия и шаблоны. Они очень похожи на то, с чем вы уже сталкивались: каждый раз при изменения действия, вы проверяли корректность его работы в браузере, кликая на ссылки и проверяя элементы на полученной странице. Другими словами, функциональные тесты автоматизируют эти действия.

Так как всё это мы выполняли вручную, то это было довольно скучным занятием и мы могли простить некоторые ошибки. Каждый раз, изменив что-то в своем коде, вам приходится снова и снова проходится по одному и тому же сценарию. Так быть не должно. Функциональные тесты в Symfony2 призваны облегчить эту задачу. Каждый сценарий может быть автоматически отработан раз за разом имитируя поведение пользователя в браузере. Так же как и unit тесты, они подтверждают корректность изменений вашего кода.

Функциональные тесты придерживаются определенного порядка действий:

  • создание запроса
  • тестирование ответа
  • нажатие на ссылку отправки формы
  • тестирование ответа
  • повторение

Наш функциональный тест

Функциональные тесты — это простые PHP файлы, которые чаще всего находятся в каталоге Tests/Controller вашего бандла. Если вы создаете тест для проверки страниц класса CategoryController, создайте класс CategoryControllerTest, наследующий класс WebTestCase:

<?php
# src/App/JoboardBundle/Tests/Controller/CategoryControllerTest.php
namespace App\JoboardBundle\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Doctrine\Bundle\DoctrineBundle\Command\DropDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\CreateDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\Proxy\CreateSchemaDoctrineCommand;
class CategoryControllerTest extends WebTestCase
{
    private $em;
    private $application;
    public function setUp()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();
        $this->application = new Application(static::$kernel);
        // удаляем базу
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());
        // закрываем соединение с базой
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }
        // создаём базу
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());
        // создаём структуру
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());
        // получаем Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();
        // загружаем фикстуры
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@AppJoboardBundle/DataFixtures/ORM'));
        $purger = new \Doctrine\Common\DataFixtures\Purger\ORMPurger($this->em);
        $executor = new \Doctrine\Common\DataFixtures\Executor\ORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }
    public function testShow()
    {
        $client = static::createClient();
        $client->request('GET', '/category/index');
        $this->assertEquals('App\JoboardBundle\Controller\CategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertTrue(200 === $client->getResponse()->getStatusCode());
    }
}

Запуск функциональных тестов

Так же как и в случае с юнит тестами, запустить их можно с помощью команды phpunit:

phpunit -c app/ src/App/JoboardBundle/Tests/Controller/CategoryControllerTest

Этот тест провалится, так как адрес /category/index не соответствует ни одному действительному адресу в нашем проекте, далее мы исправим его, а сейчас он выглядит вот так:

PHPUnit 4.0.16 by Sebastian Bergmann.
Configuration read from /var/www/joboard/app/phpunit.xml.dist
F
Time: 2.4 seconds, Memory: 54.00Mb
There was 1 failure:
1) App\JoboardBundle\Tests\Controller\CategoryControllerTest::testShow
Failed asserting that false is true.
/var/www/joboard/src/App/JoboardBundle/Tests/Controller/CategoryControllerTest.php:75
FAILURES!
Tests: 1, Assertions: 2, Failures: 1.

Создание функциональных тестов

Создание таких тестов схоже с проигрыванием сценария в браузере. Все сценарии мы уже написали во второй части. Сначала, проверим домашнюю страницу исправив класс JobControllerTest. Замените код на следующий:

Просроченные вакансии не отображаются

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
namespace App\JoboardBundle\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Doctrine\Bundle\DoctrineBundle\Command\DropDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\CreateDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\Proxy\CreateSchemaDoctrineCommand;
class CategoryControllerTest extends WebTestCase
{
    private $em;
    private $application;
    public function setUp()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();
        $this->application = new Application(static::$kernel);
        // удаляем базу
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());
        // закрываем соединение с базой
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }
        // создаём базу
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());
        // создаём структуру
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());
        // получаем Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();
        // загружаем фикстуры
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@AppJoboardBundle/DataFixtures/ORM'));
        $purger = new \Doctrine\Common\DataFixtures\Purger\ORMPurger($this->em);
        $executor = new \Doctrine\Common\DataFixtures\Executor\ORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }
    public function testIndex()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/job/');
        $this->assertEquals('App\JoboardBundle\Controller\JobController::indexAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertTrue($crawler->filter('.jobs td.position:contains("Expired")')->count() == 0);
    }
}

Запускаем тест:

phpunit -c app/ src/App/JoboardBundle/Tests/Controller/JobControllerTest

Для проверки исключения просроченных вакансий, мы проверяем CSS селектор .jobs td.position:contains(“Expired”) на отсутствие этих элементов в полученной HTML странице. (Если вы помните, в фикстурах все просроченные вакансии содержат слово “Expired” в описании)

Проверка на наличие N количества вакансий в категории

Для этого теста, необходимо добавить в шаблон Job/index.html.twig, CSS класс, который будет идентифицировать блок с категорией, например category-1, где 1 — идентификатор категории. Это позволит нам найти каждую категорию и сосчитать количество опубликованных вакансий. Откройте src/App/JoboardBundle/Resources/views/Job/index.html.twig и замените существующий код на следующий:

<!-- ... -->
{% block content %}
    <div id="jobs">
        {% for category in categories %}
            <div class="category-{{ category.id }}">
                <div class="category">
<!-- ... -->                

Добавьте следующий код в конец функции testIndex(). Для доступа к нашим параметрам, заданным app/config/config.yml воспользуемся ядром системы.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
public function testIndex()
{
    //...
    $kernel = static::createKernel();
    $kernel->boot();
    // Находим категорию "дизайн"
    $categoryDesign = $this->em->getRepository('AppJoboardBundle:Category')->findOneBySlug('dizajn');
    // Находим категорию "программирование"
    $categoryProgramming = $this->em->getRepository('AppJoboardBundle:Category')->findOneBySlug('programmirovanie');
    $maxJobsOnHomepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage');
    $this->assertTrue($crawler->filter('.category-' . $categoryProgramming->getId() . ' tr')->count() <= $maxJobsOnHomepage);
}

Заметка: В тесте мы используем slug programmirovanie, потому что это транслитерированное значение для названия категории Программирование. Помните мы добавили новый механизм для транслитерации (функция slugify в предыдущей статье{:target=»_blank»})?

Наличие ссылки на категорию, только в случае большого количества вакансий.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
public function testIndex()
{
    //...
    $this->assertTrue($crawler->filter('.category' . $categoryDesign->getId() . ' .more-jobs')->count() == 0);
    $this->assertTrue($crawler->filter('.category-' . $categoryProgramming->getId() . ' .more-jobs')->count() == 1);
}

В этих тестах мы проверяем наличие ссылки “more jobs” в категории дизайн (.category_design .more_jobs должны отсутствовать), а в категории программирования ссылка должна быть (.category_programming .more_jobs).

Вакансии отсортированы по дате

Для проверки сортировки вакансий, нам достаточно проверить, что первая вакансия в списке именно та, которую мы хотим там видеть. Этого можно добиться проверкой URL адреса на содержание определенного ключа. Так как он может изменяться со временем, сначала надо получить его из БД.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
public function testIndex()
{
    // ...
    $query = $this->em->createQuery('SELECT j from AppJoboardBundle:Job j
                                   LEFT JOIN j.category c
                                   WHERE c.slug = :slug AND j.expires_at > :date
                                   ORDER BY j.created_at DESC');
    $query->setParameter('slug', $categoryProgramming->getSlug());
    $query->setParameter('date', date('Y-m-d H:i:s', time()));
    $query->setMaxResults(1);
    $job = $query->getSingleResult();
    $this->assertTrue($crawler->filter('.category-' . $categoryProgramming->getId() . ' tr')->first()->filter(sprintf('a[href*="/%d/"]', $job->getId()))->count() == 1);
}

Даже если тест проходит успешно, нам все равно следует немного изменить код, чтобы мы могли во всем тесте использовать полученную первую вакансию в категории программирование. Мы не будем перемещать код на уровень модели, так как он принадлежит тесту. Вместо этого мы перенесем код в функцию getMostRecentProgrammingJob в классе теста:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
    public function getMostRecentProgrammingJob()
    {
        $categoryProgramming = $this->em->getRepository('AppJoboardBundle:Category')->findOneBySlug('programmirovanie');
        $query = $this->em->createQuery('SELECT j from AppJoboardBundle:Job j
                                   LEFT JOIN j.category c
                                   WHERE c.slug = :slug AND j.expires_at > :date
                                   ORDER BY j.created_at DESC');
        $query->setParameter('slug', $categoryProgramming->getSlug());
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);
        return $query->getSingleResult();
    }
// ...

Заменим предыдущий код теста:

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
// ...
$job = $this->getMostRecentProgrammingJob();
$this->assertTrue($crawler->filter('.category-' . $categoryProgramming->getId() . ' tr')->first()->filter(sprintf('a[href*="/%d/"]', $job->getId()))->count() == 1);
//...

Каждая вакансия на домашней странице должна быть кликабельна

Чтобы проверить ссылку вакансии на домашней странице мы должны имитировать клик по тексту “Web Developer”. Так как этот текст встречается на странице несколько раз, мы должны четко указать по какому кликать. Затем проверяем каждый параметр, чтобы убедится что маршрут работает корректно.

<?php
# src/App/JoboardBundle/Tests/Controller/JobControllerTest.php
public function testIndex()
{
    // ...
    $link = $crawler->selectLink('Web Разработчик')->first()->link();
    $client->click($link);
    $this->assertEquals('App\JoboardBundle\Controller\JobController::showAction', $client->getRequest()->attributes->get('_controller'));
    $this->assertEquals($job->getCompanySlug(), $client->getRequest()->attributes->get('company'));
    $this->assertEquals($job->getLocationSlug(), $client->getRequest()->attributes->get('location'));
    $this->assertEquals($job->getPositionSlug(), $client->getRequest()->attributes->get('position'));
    $this->assertEquals($job->getId(), $client->getRequest()->attributes->get('id'));
}
// ...

Пример

В этот раздел мы вынесли весь необходимый код для проверки страниц вакансий и категорий. Внимательно просмотрите код, в нем вы найдёте полезные примеры:

<?php
namespace App\JoboardBundle\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Doctrine\Bundle\DoctrineBundle\Command\DropDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\CreateDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\Proxy\CreateSchemaDoctrineCommand;
class JobControllerTest extends WebTestCase
{
    private $em;
    private $application;
    public function setUp()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();
        $this->application = new Application(static::$kernel);
        // удаляем базу
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());
        // закрываем соединение с базой
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }
        // создаём базу
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());
        // создаём структуру
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());
        // получаем Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();
        // загружаем фикстуры
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@AppJoboardBundle/DataFixtures/ORM'));
        $purger = new \Doctrine\Common\DataFixtures\Purger\ORMPurger($this->em);
        $executor = new \Doctrine\Common\DataFixtures\Executor\ORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }
    public function testIndex()
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/job/');
        $this->assertEquals('App\JoboardBundle\Controller\JobController::indexAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertTrue($crawler->filter('.jobs td.position:contains("Expired")')->count() == 0);
        $kernel = static::createKernel();
        $kernel->boot();
        // Находим категорию "дизайн"
        $categoryDesign = $this->em->getRepository('AppJoboardBundle:Category')->findOneBySlug('dizajn');
        // Находим категорию "программирование"
        $categoryProgramming = $this->em->getRepository('AppJoboardBundle:Category')->findOneBySlug('programmirovanie');
        $maxJobsOnHomepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage');
        $this->assertTrue($crawler->filter('.category-' . $categoryProgramming->getId() . ' tr')->count() <= $maxJobsOnHomepage);
        $this->assertTrue($crawler->filter('.category' . $categoryDesign->getId() . ' .more-jobs')->count() == 0);
        $this->assertTrue($crawler->filter('.category-' . $categoryProgramming->getId() . ' .more-jobs')->count() == 1);
        $job = $this->getMostRecentProgrammingJob();
        $this->assertTrue($crawler->filter('.category-' . $categoryProgramming->getId() . ' tr')->first()->filter(sprintf('a[href*="/%d/"]', $job->getId()))->count() == 1);
        $link = $crawler->selectLink('Web Разработчик')->first()->link();
        $client->click($link);
        $this->assertEquals('App\JoboardBundle\Controller\JobController::showAction', $client->getRequest()->attributes->get('_controller'));
        $this->assertEquals($job->getCompanySlug(), $client->getRequest()->attributes->get('company'));
        $this->assertEquals($job->getLocationSlug(), $client->getRequest()->attributes->get('location'));
        $this->assertEquals($job->getPositionSlug(), $client->getRequest()->attributes->get('position'));
        $this->assertEquals($job->getId(), $client->getRequest()->attributes->get('id'));
        // Несуществующая вакансия должна вести на страницу 404
        $client->request('GET', '/job/nocompany/nolocation/0/nodeveloper');
        $this->assertTrue(in_array($client->getResponse()->getStatusCode(), [404, 301]));
        // Завершённая вакансия должна вести на страницу 404
        $client->request('GET', sprintf('job/company-100/moscow-russia/%d/web-developer/', $this->getExpiredJob()->getId()));
        $this->assertTrue(404 === $client->getResponse()->getStatusCode());
    }
    public function getMostRecentProgrammingJob()
    {
        $categoryProgramming = $this->em->getRepository('AppJoboardBundle:Category')->findOneBySlug('programmirovanie');
        $query = $this->em->createQuery('SELECT j from AppJoboardBundle:Job j
                                   LEFT JOIN j.category c
                                   WHERE c.slug = :slug AND j.expires_at > :date
                                   ORDER BY j.created_at DESC');
        $query->setParameter('slug', $categoryProgramming->getSlug());
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);
        return $query->getSingleResult();
    }
    public function getExpiredJob()
    {
        $query = $this->em->createQuery('SELECT j from AppJoboardBundle:Job j WHERE j.expires_at < :date');
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $query->setMaxResults(1);
        return $query->getSingleResult();
    }
}
<?php
namespace App\JoboardBundle\Tests\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Input\ArrayInput;
use Doctrine\Bundle\DoctrineBundle\Command\DropDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\CreateDatabaseDoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Command\Proxy\CreateSchemaDoctrineCommand;
class CategoryControllerTest extends WebTestCase
{
    private $em;
    private $application;
    public function setUp()
    {
        static::$kernel = static::createKernel();
        static::$kernel->boot();
        $this->application = new Application(static::$kernel);
        // удаляем базу
        $command = new DropDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:drop',
            '--force' => true
        ));
        $command->run($input, new NullOutput());
        // закрываем соединение с базой
        $connection = $this->application->getKernel()->getContainer()->get('doctrine')->getConnection();
        if ($connection->isConnected()) {
            $connection->close();
        }
        // создаём базу
        $command = new CreateDatabaseDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:database:create',
        ));
        $command->run($input, new NullOutput());
        // создаём структуру
        $command = new CreateSchemaDoctrineCommand();
        $this->application->add($command);
        $input = new ArrayInput(array(
            'command' => 'doctrine:schema:create',
        ));
        $command->run($input, new NullOutput());
        // получаем Entity Manager
        $this->em = static::$kernel->getContainer()
            ->get('doctrine')
            ->getManager();
        // загружаем фикстуры
        $client = static::createClient();
        $loader = new \Symfony\Bridge\Doctrine\DataFixtures\ContainerAwareLoader($client->getContainer());
        $loader->loadFromDirectory(static::$kernel->locateResource('@AppJoboardBundle/DataFixtures/ORM'));
        $purger = new \Doctrine\Common\DataFixtures\Purger\ORMPurger($this->em);
        $executor = new \Doctrine\Common\DataFixtures\Executor\ORMExecutor($this->em, $purger);
        $executor->execute($loader->getFixtures());
    }
    public function testShow()
    {
        $client = static::createClient();
        $kernel = static::createKernel();
        $kernel->boot();
        // получаем параметры из конфига app/config.yml
        $maxJobsOnCategory = $kernel->getContainer()->getParameter('max_jobs_on_category');
        $maxJobsOnHomepage = $kernel->getContainer()->getParameter('max_jobs_on_homepage');
        $categories = $this->em->getRepository('AppJoboardBundle:Category')->getWithJobs();
        // Категории на домашней странице должны быть кликабельны
        foreach($categories as $category) {
            $crawler = $client->request('GET', '/job/');
            $link = $crawler->selectLink($category->getName())->link();
            $crawler = $client->click($link);
            $this->assertEquals('App\JoboardBundle\Controller\CategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
            $this->assertEquals($category->getSlug(), $client->getRequest()->attributes->get('slug'));
            $jobsNo = $this->em->getRepository('AppJoboardBundle:Job')->countActiveJobs($category->getId());
            // категории в которых вакансий больше чем $maxJobsOnHomepage должны иметь ссылку "Ещё"
            if ($jobsNo > $maxJobsOnHomepage) {
                $crawler = $client->request('GET', '/job/');
                $link = $crawler->filter(".category-" . $category->getId() . " .more-jobs a")->link();
                $crawler = $client->click($link);
                $this->assertEquals('App\JoboardBundle\Controller\CategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
                $this->assertEquals($category->getSlug(), $client->getRequest()->attributes->get('slug'));
            }
            $pages = ceil($jobsNo/$maxJobsOnCategory);
            // только $maxJobsOnCategory вакансий
            $this->assertTrue($crawler->filter('.jobs tr')->count() <= $maxJobsOnCategory);
            $this->assertRegExp("#" . $jobsNo . " вакансии#iu", $crawler->filter('.pagination_desc')->text());
            if ($pages > 1) {
                $this->assertRegExp("#страница 1/" . $pages . "#iu", $crawler->filter('.pagination_desc')->text());
                for ($i = 2; $i <= $pages; $i++) {
                    $link = $crawler->selectLink($i)->link();
                    $crawler = $client->click($link);
                    $this->assertEquals('App\JoboardBundle\Controller\CategoryController::showAction', $client->getRequest()->attributes->get('_controller'));
                    $this->assertEquals($i, $client->getRequest()->attributes->get('page'));
                    $this->assertTrue($crawler->filter('.jobs tr')->count() <= $maxJobsOnCategory);
                    if($jobsNo >1) {
                        $this->assertRegExp("#" . $jobsNo . " вакансии#iu", $crawler->filter('.pagination_desc')->text());
                    }
                    $this->assertRegExp("#страница " . $i . "/" . $pages . "#iu", $crawler->filter('.pagination_desc')->text());
                }
            }
        }
    }
}

На сегодня это всё. В следующей части рассмотрим формы в Symfony2.