English spoken conference
Caution: You are browsing the legacy symfony 1.x part of this website.

День 9: Функциональное тестирование

1.4 / Doctrine
Symfony version
1.2
Language ORM

Замечания по переводу: ahudenko[at]yandex.ru

Вчера мы поговорили о том как проводить модульное тестирование наших Jobeet классов, используя библиотеку Lime, поставляемую с symfony.

Сегодня мы будем писать функциональные тесты для уже готовых сценариев модулей job и category.

Функциональные тесты

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

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

note

Функциональное тестирование не заменяет таких инструментов как "Selenium". Selenium запускается прямо в браузере, параллельно тестирует несколько платформ и браузеров, пригоден для тестирования JavaScript-приложений.

Класс sfBrowser

В symfony функциональные тесты запускаются через специальный браузер, реализованный классом sfBrowser Он действует как браузер скроенный для вашего приложения и напрямую подключающийся к нему, минуя веб-сервер. Он дает Вам доступ ко всем объектам symfony до и после каждого запроса, позволяет Вам исследовать их, удобно проверять то, что хотите.

sfBrowser предоставляет методы которые эмулируют поведение стандартного браузера:

Метод Описание
get() Открывает URL
post() Посылает POST запрос URL
call() Вызывает (используется для методов PUT и DELETE)
back() Переходит на предыдущую страницу истории
forward() Переходит на следующую страницу истории
reload() Обновляет текущую страницу
click() Кликает по ссылке или кнопке
select() Выбирает radiobutton или checkbox
deselect() Снимает выбор с radiobutton или checkbox
restart() Перезапускает браузер

Вот пример использования методов sfBrowser:

$browser = new sfBrowser();
 
$browser->
  get('/')->
  click('Design')->
  get('/category/programming?page=2')->
  get('/category/programming', array('page' => 2))->
  post('search', array('keywords' => 'php'))
;

sfBrowser содержит методы для настройки поведения браузера:

Метод Описание
setHttpHeader() Устанавливает HTTP заголовок
setAuth() Устанавливает базовые параметры авторизации
setCookie() Устанавливает cookie
removeCookie() Удаляет cookie
clearCookies() Удаляет все cookies
followRedirect() Выполняет перенаправление

Класс sfTestFunctional

У нас есть браузер, но что бы проводить реальное тестирование нужен способ для наблюдения за объектами symfony. Это может быть реализовано при помощи Lime и некоторых методов sfBrowser, таких как getResponse() и getRequest(), но symfony предлагает решение по лучше.

Тестирующие методы, предоставляемые другим классом - sfTestFunctional, который в конструкторе принимает объект класса sfBrowser. Класс sfTestFunctional предоставляет тестирование объектам тестерам. Несколько тестеров идет в комплекте с symfony, также Вы можете создать свой.

Как мы уже сказали вчера, функциональные тесты хранятся в директории test/functional. Для Jobeet, тесты находятся в test/functional/frontend/, так как каждое приложение имеет собственную поддиректорию. Эта директория уже содержит два файла: categoryActionsTest.php и jobActionsTest.php, так как все команды генерации модулей, создают простейшие функциональные тесты:

// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestFunctional(new sfBrowser());
 
$browser->
  get('/category/index')->
 
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'index')->
  end()->
 
  with('response')->begin()->
    isStatusCode(200)->
    checkElement('body', '!/This is a temporary page/')->
  end()
;

Скрипт, приведенный выше, может выглядеть для Вас непонятным из-за, необычно выглядящих, вызовов методов sfBrowser и sfTestFunctional, которые реализуют плавающий синтаксис(fluent interface), когда каждый метод возвращают ссылку на тот объект в котором вызван. Это позволяет Вам записывать последовательные вызовы методов в более читабельном виде. Выше приведенный пример эквивалентен этому:

// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestFunctional(new sfBrowser());
 
$browser->get('/category/index');
$browser->with('request')->begin();
$browser->isParameter('module', 'category');
$browser->isParameter('action', 'index');
$browser->end();
 
$browser->with('response')->begin();
$browser->isStatusCode(200);
$browser->checkElement('body', '!/This is a temporary page/');
$browser->end();

Тесты запускаются в контексте блока тестера. Контекст блока начинается с with('TESTER NAME')->begin() и заканчивается end():

$browser->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'index')->
  end()
;

Этот код тестирует является ли значение параметра запроса module равным category, а значение параметра action равным index.

tip

Когда Вам нужно вызвать только один метод тестера, Вам не обязательно создавать блок: with('request')->isParameter('module', 'category').

Тестер запроса

Класс request tester предоставляет тестирующие методы для исследования и тестирования объекта sfWebRequest:

Метод Описание
isParameter() Проверяет значение параметра запроса
isFormat() Проверяет формат запроса
isMethod() Проверяет метод
hasCookie() Проверяет имеет ли запрос cookie с заданным именем
isCookie() Проверяет значение cookie

Тестер ответа

Класс response tester предоставляет тестирующие методы для объекта sfWebResponse:

Метод Описание
checkElement() Проверяет в ответном HTML наличие элементов
соответствующих CSS селектору
checkForm() Checks an sfForm form object
debug() Prints the response output to ease debug
matches() Tests a response against a regexp
isHeader() Проверяет значение заголовка (header)
isStatusCode() Проверяет код статуса ответа
isRedirected() Проверяет является ли ответ перенаправлением
isValid() Проверяет, что ответ является правильным XML-документом
(Вы также проверяете запрос при помощи типа документа,
указав true в качестве аргумента)

note

Мы опишем больше классов тестеров в последующие дни (для форм, пользователя, кэша, ...)

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

Как и для модульных тестов, запуск функциональных тестов может быть осуществлен напрямую:

$ php test/functional/frontend/categoryActionsTest.php

Или через команду test:functional:

$ php symfony test:functional frontend categoryActions

Тестирование в командной строке

Тестовые данные

Как и в модульных тестах для Doctrine, нам необходимо загружать тестовые данные каждый раз, как мы запускаем функциональное тестирование. Мы можем повторно использовать код написанный вчера:

include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestFunctional(new sfBrowser());
Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');

Загрузка данных для функциональных тестов чуть проще, чем для модульных, так как база данных уже настроена стартовым скриптом.

Как и для модульных тестов, мы не будем копировать этот фрагмент кода в каждый тестовый файл, а быстренько создадим наш собственный класс, наследующий от sfTestFunctional:

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function loadData()
  {
    Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');
 
    return $this;
  }
}

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

Написание функциональных тестов похоже на выполнение сценариев в браузере. У нас уже готовы все сценарии необходимые для тестирование сюжетов второго дня:

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

// test/functional/frontend/jobActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The homepage')->
  get('/')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'index')->
  end()->
  with('response')->begin()->
    info('  1.1 - Expired jobs are not listed')->
    checkElement('.jobs td.position:contains("expired")', false)->
  end()
;

Как и в Lime, мы можем вставить информационное сообщение путем вызова метода info(), для того чтобы результат был более читаемым. Для проверки того, что на главной странице отсутствуют истекшие вакансии мы проверяем что для CSS селектора .jobs td.position:contains("expired") нигде в полученом HTML нет совпадений(помним что в fixture-файлах только истекшие вакансии в поле position содержат "expired"). Когда в метод checkElement() передается второй элемент с типом Boolean, метод проверяет существование элементов(nodes) совпадающих с CSS селектором.

tip

Метод checkElement() способен интерпретировать большинство валидных CSS3 селекторов.

Только N вакансий отображается для каждой категории

Добавим следующий код в конец тестового файла:

// test/functional/frontend/jobActionsTest.php
$max = sfConfig::get('app_max_jobs_on_homepage');
 
$browser->info('1 - The homepage')->
  get('/')->
  info(sprintf('  1.2 - Only %s jobs are listed for a category', $max))->
  with('response')->
    checkElement('.category_programming tr', $max)
;

Метод checkElement(), принимая в качестве второго аргумента целое число, может проверить найдено ли 'n' элементов, соответствующих CSS селектору.

Ссылка на страницу категории отображается только при большом количестве вакансий

// test/functional/frontend/jobActionsTest.php
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.3 - A category has a link to the category page only if too many jobs')->
  with('response')->begin()->
    checkElement('.category_design .more_jobs', false)->
    checkElement('.category_programming .more_jobs')->
  end()
;

Здесь мы проверяем, отсутствие ссылки "more jobs" для категории дизайн (.category_design .more_jobs не существует), и наличие ее для категории программирование(.category_design .more_jobs).

Вакансии сортируются по дате

$q = Doctrine_Query::create()
  ->select('j.*')
  ->from('JobeetJob j')
  ->leftJoin('j.JobeetCategory c')
  ->where('c.slug = ?', 'programming')
  ->andWhere('j.expires_at > ?', date('Y-m-d', time()))
  ->orderBy('j.created_at DESC');
 
$job = $q->fetchOne();
 
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.4 - Jobs are sorted by date')->
  with('response')->begin()->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $job->getId()))->
  end()
;

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

Несмотря на то, что тест работает как есть, нужно слегка отрефакторить код. Получение первой вакансии в категории programming может быть повторно использовано где-то в другом месте наших тестов. Мы не будем перемещать код на уровень модели, так как метод специфичен только для тестов. Вместо этого переместим код в класс JobeetTestFunctional, который мы создали раньше. Действия этого класса специфичны для домена(Domain Specific) класса функционального тестировщика Jobeet:

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function getMostRecentProgrammingJob()
  {
    $q = Doctrine_Query::create()
      ->select('j.*')
      ->from('JobeetJob j')
      ->leftJoin('j.JobeetCategory c')
      ->where('c.slug = ?', 'programming');
    $q = Doctrine_Core::getTable('JobeetJob')->addActiveJobsQuery($q);
 
    return $q->fetchOne();
  }
 
  // ...
}

Теперь Вы можете заменить предыдущий код теста следующим:

// test/functional/frontend/jobActionsTest.php
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.4 - Jobs are sorted by date')->
  with('response')->begin()->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]',
      $browser->getMostRecentProgrammingJob()->getId()))->
  end()
;

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

$job = $browser->getMostRecentProgrammingJob();
 
$browser->info('2 - The job page')->
  get('/')->
 
  info('  2.1 - Each job on the homepage is clickable and give detailed information')->
  click('Web Developer', array(), array('position' => 1))->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'show')->
    isParameter('company_slug', $job->getCompanySlug())->
    isParameter('location_slug', $job->getLocationSlug())->
    isParameter('position_slug', $job->getPositionSlug())->
    isParameter('id', $job->getId())->
  end()
;

Чтобы протестировать ссылку на вакансию на главной странице, мы эмулируем клик по тексту "Web Developer". Так как он встречается на странице много раз, мы явно просим браузер кликнуть по первому(array('position' => 1)).

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

Обучение на примере

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

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function loadData()
  {
    Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');
 
    return $this;
  }
 
  public function getMostRecentProgrammingJob()
  {
    $q = Doctrine_Query::create()
      ->select('j.*')
      ->from('JobeetJob j')
      ->leftJoin('j.JobeetCategory c')
      ->where('c.slug = ?', 'programming');
    $q = Doctrine_Core::getTable('JobeetJob')->addActiveJobsQuery($q);
 
    return $q->fetchOne();
  }
 
  public function getExpiredJob()
  {
    $q = Doctrine_Query::create()
      ->from('JobeetJob j')
      ->where('j.expires_at < ?', date('Y-m-d', time()));
 
    return $q->fetchOne();
  }
}
 
// test/functional/frontend/jobActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The homepage')->
  get('/')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'index')->
  end()->
  with('response')->begin()->
    info('  1.1 - Expired jobs are not listed')->
    checkElement('.jobs td.position:contains("expired")', false)->
  end()
;
 
$max = sfConfig::get('app_max_jobs_on_homepage');
 
$browser->info('1 - The homepage')->
  info(sprintf('  1.2 - Only %s jobs are listed for a category', $max))->
  with('response')->
    checkElement('.category_programming tr', $max)
;
 
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.3 - A category has a link to the category page only if too many jobs')->
  with('response')->begin()->
    checkElement('.category_design .more_jobs', false)->
    checkElement('.category_programming .more_jobs')->
  end()
;
 
$browser->info('1 - The homepage')->
  info('  1.4 - Jobs are sorted by date')->
  with('response')->begin()->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))->
  end()
;
 
$job = $browser->getMostRecentProgrammingJob();
 
$browser->info('2 - The job page')->
  get('/')->
 
  info('  2.1 - Each job on the homepage is clickable and give detailed information')->
  click('Web Developer', array(), array('position' => 1))->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'show')->
    isParameter('company_slug', $job->getCompanySlug())->
    isParameter('location_slug', $job->getLocationSlug())->
    isParameter('position_slug', $job->getPositionSlug())->
    isParameter('id', $job->getId())->
  end()->
 
  info('  2.2 - A non-existent job forwards the user to a 404')->
  get('/job/foo-inc/milano-italy/0/painter')->
  with('response')->isStatusCode(404)->
 
  info('  2.3 - An expired job page forwards the user to a 404')->
  get(sprintf('/job/sensio-labs/paris-france/%d/web-developer', $browser->getExpiredJob()->getId()))->
  with('response')->isStatusCode(404)
;
 
// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The category page')->
  info('  1.1 - Categories on homepage are clickable')->
  get('/')->
  click('Programming')->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'show')->
    isParameter('slug', 'programming')->
  end()->
 
  info(sprintf('  1.2 - Categories with more than %s jobs also have a "more" link', sfConfig::get('app_max_jobs_on_homepage')))->
  get('/')->
  click('27')->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'show')->
    isParameter('slug', 'programming')->
  end()->
 
  info(sprintf('  1.3 - Only %s jobs are listed', sfConfig::get('app_max_jobs_on_category')))->
  with('response')->checkElement('.jobs tr', sfConfig::get('app_max_jobs_on_category'))->
 
  info('  1.4 - The job listed is paginated')->
  with('response')->begin()->
    checkElement('.pagination_desc', '/32 jobs/')->
    checkElement('.pagination_desc', '#page 1/2#')->
  end()->
 
  click('2')->
  with('request')->begin()->
    isParameter('page', 2)->
  end()->
  with('response')->checkElement('.pagination_desc', '#page 2/2#')
;

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

Временами функциональные тесты не работают. Бывает сложно определить проблему, так как symfony эмулирует браузер без какого бы то ни было графического интерфейса. К счастью, есть метод ~debug|Debug~() для вывода заголовков(response header) и содержания:

$browser->with('response')->debug();

Метод debug() может быть вставлен где угодно в response блоке тестера и это приведет к остановке выполнения скрипта.

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

Команда test:functional может быть использована для запуска всех функциональных тестов приложения:

$ php symfony test:functional frontend

Команда выводит по одной строке на каждый тест:

Functional tests harness

Использование тестов

Как Вы уже могли догадаться, есть команда для запуска всех тестов проекта(модульных и функциональных):

$ php symfony test:all

Tests harness

Когда Вы имеете большой набор тестов, запуск всех тестов каждый раз, когды Вы вносите изменения, может занять много времени, особенно, если некоторые тесты не срабатывают. Это происходит потому, что каждый раз, когда Вы исправляете тест (т.е. ошибку, вызвавшую падение теста), Вы должны запустить весь набор тестов снова, чтобы убедиться, что Вы не сломали ничего другого. Но до тех пор, пока несработавшие тесты не исправлены, нет никакого смысла запускать все остальные. Задача test:all имеет опцию --only-failed, которая заставляет ее перезапускать только те тесты, которые не сработали при предыдущем запуске:

$ php symfony test:all --only-failed

При первом запуске задачи все тесты отработают, как обычно. Но для всех последующих запусков, только те тесты, которые не сработали в предыдущий раз, будут запущены. Как только Вы исправите свой код, некоторые тесты завершатся успешно, и будут исключены из последующих запусков. Как только все тесты сработают, снова запустится полный набор тестов... и Вы сможете повторить все сначала.

tip

Если Вы хотите включить свой набор тестов в процесс непрерывной интеграции (continuous integration), используйте опцию --xml, чтобы заставить задачу test:all сгенерировать XML-вывод, совместимый с JUnit.

 $ php symfony test:all --xml=log.xml

Увидимся завтра

Что хочется сказать в завершение нашего тура по инструментам тестирования symfony. Теперь у Вас нет ни единой отговорки, чтобы не тестировать ваши приложения! Фреймворк Lime и фреймворк функционального тестирования - мощные инструменты предоставляемые symfony, чтобы помочь Вам легко писать тесты.

Мы лишь поверхностно затронули функциональное тестирование. Впредь, каждый раз при добавлении нового функционала, мы будем писать для него тесты, параллельно изучая другие возможности фреймворка тестирования.

Завтра мы поговорим о еще одной великолепной особенности symfony - фреймворке форм.