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

День 8: Модульное тестирование

1.4 / Doctrine
Symfony version
1.2
Language ORM

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

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

Сегодня мы поговорим о чем-то совершенно другом: автоматизированное тестирование. Эта тема настолько большая, что нам понадобится целых два дня, чтобы охватить ее полностью.

Тестирование в Symfony

В Symfony используется два вида автоматического тестирования: модульное тестирование и функциональное тестирование.

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

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

Все тесты в Symfony должны находиться в директории проекта test/. Внутри нее две поддиректории, одна для модульных тестов (test/unit/) и одна для функциональных (test/functional/).

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

Модульное тестирование

Написание модульных тестов - хорошая практика для веб-разработки, но, наверное, одна из самых сложных во внедрении. Так как веб-разработчики обычно, не тестируют результаты своей работы, то у них возникает много вопросов: Как написать тест перед внедрением новой функциональности? Что именно мне нужно тестировать? Проверяют ли мои тесты все граничные значения? Как я могу быть уверенным, что все хорошо протестировано? Но обычно, первый вопрос намного прозаичней: С чего начать?

Несмотря на то, что мы - ярые сторонники тестирования, принцип symfony - прагматичность: лучше иметь несколько каких-нибудь тестов, чем не иметь их вовсе. У Вас уже есть много непокрытого тестами кода? Не проблема. Чтобы получить преимущества от использования тестирования, не обязательно иметь исчерпывающие комплекты тестов. Со временем ваш код будет становиться лучше, будет расти охватываемость кода, и сами Вы станете более уверенными в нем.

Проблема многих библиотек тестирования - чрезмерная сложность в изучении. Вот почему Symfony использует очень простую библиотеку lime, чтобы сделать написание тестов безумно легким.

note

Не смотря на то, что в этом руководстве широко описывается встроенная библиотек lime, Вы можете использовать любую другую, на пример прекрасную библиотеку PHPUnit.

Lime - фреймворк тестирования

Все модульные тесты, написанные с использованием lime, начинаются примерно так:

require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(1);

Во первых, подключается загрузочный файл unit.php, производит некоторые настройки. Далее, создается новый объект limit_test и при создании указывается количество тестов, которые планируется выполнить.

note

План тестирования позволяет lime контролировать сообщения об ошибках, когда запущено несколько тестов(ситуация, когда тестовый скрипт вызывает Fatal error).

Тестирование происходит через вызов метода или функции с заранее определенными параметрами и сравнение полученных результатов с ожидаемыми. Это сравнение определяет успешность выполнения теста.

Для облегчения сравнения объект lime_test предоставляет несколько методов:

Метод Описание
ok($test) Проверяет значение и успешен если оно true.
is($value1, $value2) Сравнивает два значения и успешен, если они
равны (==).
isnt($value1, $value2) Сравнивает два значения и успешен, если они
не равны.
like($string, $regexp) Проверяет строку на совпадение с регулярным
выражением.
unlike($string, $regexp) Удостоверяется, что строка не совпадает с
регулярным выражением.
is_deeply($array1, $array2) Удостоверяется, что два масива имеют одни и
те же значения.

tip

Вы можете удивиться, почему lime определяет так много методов, если все тесты можно написать используя только ok(). Преимущество альтернативных методов в том, что мы получаем более ясные сообщения об ошибках провалившихся тестов и тем самым улучшаем читабельность результатов тестирования.

Объект lime_test предоставляет также другие удобные методы тестирования:

Метод Описание
fail() Всегда завершается ошибкой - полезен для тестирования
исключений(exceptions).
pass() Всегда успешен - полезен для тестирования исключений.
skip($msg, $nb_tests) Считается как $nb_tests тестов. Пропускает $nb_tests тестов.
Полезен для тестов условий.
todo() Считается как один тест -- полезен для тестов которые
еще не написаны.

Напоследок, метод comment($msg) выводит комментарий и ничего не тестирует.

Запуск модульных тестов

Все модульные тесты хранятся в директории test/unit/. По правилам Symfony имя теста формируется из имени класса, который он тестирует, с добавлением суффикса Test. Хотя Вы и можете организовать хранение файлов в test/unit/ на свое усмотрение, мы рекомендуем дублировать структуру директорий lib/.

Что бы продемонстрировать модульное тестирование, протестируем класс Jobeet.

Создайте файл test/unit/JobeetTest.php следующего содержания:

// test/unit/JobeetTest.php
require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(1);
$t->pass('This test always passes.');

Для запуска теста необходимо запустить этот файл:

$ php test/unit/JobeetTest.php

Или использовать команду test:unit:

$ php symfony test:unit Jobeet

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

note

К сожалению в командной строке Windows результаты тестирования не подсвечиваются красным или зеленым цветом. Но если Вы используете Cygwin, Вы можете заставить Symfony использовать цвета, указав опцию --color для задачи.

Тестирование slugify

Начнем наше путешествие в удивительный мир модульного тестирования с написания тестов для метода Jobeet::slugify().

Метод ~slug|Slug~ify() мы создали на 5-ый день разработки, служит он для очистки строк, делает их безопасными для включения в URL. Обработка строк представляет сабой такие простые преобразования, как замена всех не ASCII символов на тире(-) или приведение всех символов к нижнему регистру:

Input Output
Sensio Labs sensio-labs
Paris, France paris-france

Замените содержание тест-файла следующим кодом:

// test/unit/JobeetTest.php
require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(6);
 
$t->is(Jobeet::slugify('Sensio'), 'sensio');
$t->is(Jobeet::slugify('sensio labs'), 'sensio-labs');
$t->is(Jobeet::slugify('sensio   labs'), 'sensio-labs');
$t->is(Jobeet::slugify('paris,france'), 'paris-france');
$t->is(Jobeet::slugify('  sensio'), 'sensio');
$t->is(Jobeet::slugify('sensio  '), 'sensio');

Если Вы внимательно посмотрите на написанный код, то увидите, что каждая строка выполняет один тест, а каждый тест проверяет одно правило. Что Вы должны четко усвоить: один тест - одно правило.

Теперь Вы можете запустить тест-файл. Если все тесты выполнятся успешно, то Вы получите "зеленую полоску". Если нет, то ужасная "красная полоска" оповестит Вас, что некоторые тесты провалены и что-то нужно иправить.

slugify() тесты

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

Все тест-методы lime в качестве последнего аргумента принимают строку, которая служит описанием для теста. Это очень удобно для быстрого описания того, что Вы тестируете. Это так же может послужить своеобразной документацией для тест-методов. Давайте добавим несколько сообщений в тест-файл slugify:

require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(6);
 
$t->comment('::slugify()');
$t->is(Jobeet::slugify('Sensio'), 'sensio', '::slugify() converts all characters to lower case');
$t->is(Jobeet::slugify('sensio labs'), 'sensio-labs', '::slugify() replaces a white space by a -');
$t->is(Jobeet::slugify('sensio   labs'), 'sensio-labs', '::slugify() replaces several white spaces by a single -');
$t->is(Jobeet::slugify('  sensio'), 'sensio', '::slugify() removes - at the beginning of a string');
$t->is(Jobeet::slugify('sensio  '), 'sensio', '::slugify() removes - at the end of a string');
$t->is(Jobeet::slugify('paris,france'), 'paris-france', '::slugify() replaces non-ASCII characters by a -');

slugify() тесты с сообщениями

Обрабатывая описание теста, lime пытается определить что же он тестирует. Вы наверное заметили, что все предложения, описывающие тесты, начинаются с имени тестируемого метода.

sidebar

Охват кода

Когда Вы пишете тесты, некоторые части кода легко могут быть пропущены.

Что бы помочь Вам проверить весь ли код протестирован, Symfony предоставляет команду test:coverage. Выполните эту команду, указав в качестве параметров тест-файл или директорию с тестами и класс или директорию с классами, которые тестируются, и Вы увидите на сколько тесты охватывают код:

$ php symfony test:coverage test/unit/JobeetTest.php lib/Jobeet.class.php

Если Вы хотите получить более подробную информацию о коде, не охваченном тестами используйте опцию --detailed:

$ php symfony test:coverage --detailed test/unit/JobeetTest.php lib/Jobeet.class.php

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

Так же test:coverage полагается на информацию собираемую XDebug, поэтому нужно заранее установить и настроить это расширение.

Написание тестов для нового функционала

Метод slug для пустой строки возвращает пустую строку. Можете проверить, оно работает. Но пустая строка в URL не лучшая идея. Давайте изменим метод slugify() так, что бы он возвращал "n-a" для пустых строк.

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

$t->is(Jobeet::slugify(''), 'n-a', '::slugify() converts the empty string to n-a');

Метод разработки, когда для новых функций сперва пишутся тесты, а затем код, известен как Test Driven Development (TDD).

Если Вы запустите тестирование сейчас, то получите красную полоску. Если нет, значит ваш код тестирует не то, что нужно.

Теперь отредактируем класс Jobeet, добавив следующее условие в начале метода slugify:

// lib/Jobeet.class.php
static public function slugify($text)
{
  if (empty($text))
  {
    return 'n-a';
  }
 
  // ...
}

Теперь тест будет пройден как положено, и Вы получите заветную зеленую полоску, но только в том случае если не забыли обновить план тестирования. Иначе, Вы увидите сообщение о том, что запланировано шесть тестов, а запущено на один больше. Поддерживайте количество запланированных тестов в актуально состоянии, это необходимо, что бы информировать Вас если один из тестов завершится преждевременно, вызвав Fatal Error.

Добавление тестов из-за бага

Положим, пришло время и один из пользователей сообщил о жутком баге: по ссылкам на некоторые вакансии отображается страница с 404-ой ошибкой. После проведенного расследования, Вы находите причину, у этих вакансий пустой slug компании, позиции или местоположения. Как это возможно? Вы просматриваете в базе значения полей, которые определенно не должны быть пустыми. И пока Вы размышляете над багом.. Бац! Причина найдена. Если строка содержит только не ASCII символы, метод slugify() преобразовывает ее в пустую строку. Вы на радостях открываете класс Jobeet и тут же исправляете проблему. Это плохая идея.. Сперва напишем тест:

$t->is(Jobeet::slugify(' - '), 'n-a', '::slugify() converts a string that only contains non-ASCII characters to n-a');

slugify() баг

Убедившись, что тест не проходит, отредактируем класс Jobeet - переместим проверку на пустую строку в конец метода:

static public function slugify($text)
{
  // ...
 
  if (empty($text))
  {
    return 'n-a';
  }
 
  return $text;
}

Теперь новый тест выполняется успешно, так же как и другие. Но метод slugify() все еще содержит ошибки не смотря на то, что на 100% охвачен тестами.

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

sidebar

На пути к лучшему методу slugify

Возможно Вы знаете, что Symfony создан французами, давайте добавим тест со словом на французком языке, которое содержит "ударение".

$t->is(Jobeet::slugify('Développeur Web'), 'developpeur-web', '::slugify() removes accents');

Тест должен провалиться. Вместо того чтобы заменить é на e, slugify заменил их на тире(-). Это весьма сложная проблема, называемая транслитерацией. Будем надеятся, что у Вас установлена библиотека "iconv|iconv Library", так как она нам пригодится. Замените код slugify() на следующий:

// code derived from http://php.vrana.cz/vytvoreni-pratelskeho-url.php
static public function slugify($text)
{
  // replace non letter or digits by -
  $text = preg_replace('#[^\\pL\d]+#u', '-', $text);
 
  // trim
  $text = trim($text, '-');
 
  // transliterate
  if (function_exists('iconv'))
  {
    $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
  }
 
  // lowercase
  $text = strtolower($text);
 
  // remove unwanted characters
  $text = preg_replace('#[^-\w]+#', '', $text);
 
  if (empty($text))
  {
    return 'n-a';
  }
 
  return $text;
}

Не забывайте сохранять свои PHP файлы в кодировке UTF-8, это кодировка используемая в Symfony по умолчанию, так же она используется "iconv" для транслитерации.

Измените тест-файл, что бы последний тест выполнялся только при наличии "iconv":

if (function_exists('iconv'))
{
  $t->is(Jobeet::slugify('Développeur Web'), 'developpeur-web', '::slugify() removes accents');
}
else
{
  $t->skip('::slugify() removes accents - iconv not installed');
}

Doctrine Unit Tests

Конфигурация базы данных

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

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

$ php symfony configure:database --name=doctrine --class=sfDoctrineDatabase --env=test "mysql:host=localhost;dbname=jobeet_test" root mYsEcret

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

note

Если Вы из любопытства откроете файл config/databases.yml, то увидите, как Symfony позволяет с легкостью изменять конфигурацию в зависимости от окружения.

Теперь у нас есть настроенная база данных и мы можем инициализировать ее используя команду doctrine:insert-sql:

$ mysqladmin -uroot -pmYsEcret create jobeet_test
$ php symfony doctrine:insert-sql --env=test

sidebar

Принципы конфигурации в Symfony

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

Так же настройки могут зависеть от окружения. Это возможно во многих конфигурационных файлах, которые мы использовали до сих пор: databases.yml, app.yml, view.yml и settings.yml. Во всех этих файлах главный ключ это окружение. All - означает что настройки используются для всех окружений:

# config/databases.yml
dev:
  doctrine:
    class: sfDoctrineDatabase
 
test:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:host=localhost;dbname=jobeet_test'
 
all:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:host=localhost;dbname=jobeet'
      username: root
      password: null

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

Теперь у нас есть отдельная база данных для тестов, надо каким-то образом загрузить в нее тестовые данные. В третий день мы узнали о команде doctrine:data-load, но для тестов нам необходимо перезагружать данные каждый раз, когда запускается тестирование, что бы быть уверенными в достоверности тестовых данных.

Команда doctrine:data-load для загрузки данных использует метод Doctrine_Core::loadData():

Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');

note

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

Метод loadData() принимает в качестве первого параметра имя директории или файла. Так же он может принимать масив директорий и/или файлов.

У нас уже есть некоторые начальные данные в data/fixtures/. Для тестов мы поместим данные в директорию test/fixtures/. Эти данные будут использоваться для модульного и функционального тестирования Doctrine.

Скопируйте файлы из data/fixtures/ в test/fixtures/.

Тестирование JobeetJob

Давайте создадим несколько модульных тестов для класса модели JobeetJob.

Так как все модульные тесты для Doctrine будут начинаться с одного и того же кода, создадим в test/bootstrap/ файл Doctrine.php следующего содержания:

// test/bootstrap/Doctrine.php
include(dirname(__FILE__).'/unit.php');
 
$configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true);
 
new sfDatabaseManager($configuration);
 
Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');

Приятно очевидный скрипт:

  • Для контролеров frontend'а мы инициализируем объект конфигурации тестового окружения:

    $configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true);
  • Мы создаем менеджер базы данных. Он загружает конфигурацию из databases.yml и настраивает соединение для Doctrine

    new sfDatabaseManager($configuration);
  • Мы загружаем наши тестовые данные используя Doctrine_Core::loadData():

    Doctrine_Core::loadData(sfConfig::get('sf_test_dir').'/fixtures');

note

Doctrine подключается к базе только если есть SQL запросы для исполнения.

Теперь у нас все готово, что бы начать тестировать класс JobeetJob.

Начнем с создания файла JobeetJobTest.php в test/unit/model:

// test/unit/model/JobeetJobTest.php
include(dirname(__FILE__).'/../../bootstrap/Doctrine.php');
 
$t = new lime_test(1);

Далее создадим тест для метода getCompanySlug():

$t->comment('->getCompanySlug()');
$job = Doctrine_Core::getTable('JobeetJob')->createQuery()->fetchOne();
$t->is($job->getCompanySlug(), Jobeet::slugify($job->getCompany()), '->getCompanySlug() return the slug for the company');

Заметьте что мы тестируем только метод getCompanySlug() и не проверяем правильность работы slug, так как мы уже протестировали его ранее в другом месте.

Написание тестов для метода save() немного сложнее:

$t->comment('->save()');
$job = create_job();
$job->save();
$expiresAt = date('Y-m-d', time() + 86400 * sfConfig::get('app_active_days'));
$t->is($job->getDateTimeObject('expires_at')->format('Y-m-d'), $expiresAt, '->save() updates expires_at if not set');
 
$job = create_job(array('expires_at' => '2008-08-08'));
$job->save();
$t->is($job->getDateTimeObject('expires_at')->format('Y-m-d'), '2008-08-08', '->save() does not update expires_at if set');
 
function create_job($defaults = array())
{
  static $category = null;
 
  if (is_null($category))
  {
    $category = Doctrine_Core::getTable('JobeetCategory')
      ->createQuery()
      ->limit(1)
      ->fetchOne();
  }
 
  $job = new JobeetJob();
  $job->fromArray(array_merge(array(
    'category_id'  => $category->getId(),
    'company'      => 'Sensio Labs',
    'position'     => 'Senior Tester',
    'location'     => 'Paris, France',
    'description'  => 'Testing is fun',
    'how_to_apply' => 'Send e-Mail',
    'email'        => '[email protected]',
    'token'        => rand(1111, 9999),
    'is_activated' => true,
  ), $defaults));
 
  return $job;
}

note

Каждый раз добавляя тесты, не забывайте обновить в конструкторе lime_test количество ожидаемых тестов (the plan). Для файла JobeetJobTest Вам нужно изменить его с 1 на 3.

Тесты для других классов Doctrine

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

Полное модульное тестирование

Для запуска всех модульных тестов проекта используется команда test:unit:

$ php symfony test:unit

Результат выполнения команды, для каждого тест-файла выводится успешно он отработал или содержит ошибки:

Полное модульное тестирование

tip

Если в выводе команды test:unit для какого-то из файлов возвращается "dubious status", значит выполнение этого файла завершено преждевременно. Запустите этот файл отдельно, что бы получить подробную информацию об ошибках.

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

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

Уверен, изучение symfony это не только изучение всех возможностей, предоставляемых фреймворком, но также и знакомство с философией программирования и лучшими методологиями разработки. Тестирование - одна из них. Рано или поздно, модульные тесты сохранят день для Вас. Они позволяют быть более уверенным в работоспособности кода и дают свободу рефакторинга, рефакторинга без страха. Модульные тесты - верные стражи, которые всегда оповестят Вас, если что-то сломается. Код Symfony охвачен более чем девятью тысячами тестов.

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