Symfony 2 Joboard : Юнит тестирование

Тесты в Symfony2

В Symfony2 существует два типа автоматизированных тестов: юнит тесты и функциональные тесты. Юнит тесты проверяют корректность работы методов и функций. Каждый тест должен быть максимально независим от другого. Функциональные тесты отвечают за корректность работы приложения в целом.

В этой статье рассмотрим юнит тесты, а функциональные оставим на следующий раз. Symfony2 включает в себя стороннюю библиотеку PHPUnit, которая предоставляет собой фреймворк для тестирования. Для запуска тестов требуется установить PHPUnit 3.5.11 или выше.

Если вы этого ещё не сделали, то вот необходимые команды:

sudo apt-get install phpunit
sudo pear channel-discover pear.phpunit.de
sudo pear channel-discover pear.symfony-project.com
sudo pear channel-discover components.ez.no
sudo pear channel-discover pear.symfony.com
sudo pear update-channels
sudo pear upgrade-all
sudo pear install pear.symfony.com/Yaml
sudo pear install --alldeps phpunit/PHPUnit
sudo pear install --force --alldeps phpunit/PHPUnit

Или более простым способом:

wget https://phar.phpunit.de/phpunit.phar
chmod +x phpunit.phar
sudo mv phpunit.phar /usr/local/bin/phpunit

Заметка: Для Windows пользователей необходимо скопировать phpunit.phar в корень проекта и запускать командой php phpunit.phar.

Каждый тест, будь-то юнит тест или функциональный, это PHP класс, который должен располагаться в подкаталоге Tests/ вашего бандла. Если вы будете следовать этому правилу, то все тесты можно будет запускать следующей командой:

phpunit -c app/

Параметр  указывает на то, что конфигурационный файл находится в каталоге app/. В файле app/phpunit.xml.dist вы найдете все допустимые параметры PHPUnit. Юнит тест обычно направлен на определенный PHP класс. Давайте напишем тест для метода Joboard:slugify(). Создайте новый файл JoboardTest.php в каталоге src/App/JoboardBundle/Tests/Utils. По соглашению, каталог Tests/ должен повторять структуру вашего бандла. Поэтому при создании теста для класса находящегося в директории Utils/, сам тест должен находиться в каталоге Tests/Utils/.

<?php
# src/App/JoboardBundle/Tests/Utils/JoboardTest.php
namespace App\JoboardBundle\Tests\Utils;
use App\JoboardBundle\Utils\Joboard;
class JoboardTest extends \PHPUnit_Framework_TestCase
{
    public function testSlugify()
    {
        $this->assertEquals('company', Joboard::slugify('Company'));
        $this->assertEquals('ooo-company', Joboard::slugify('ooo company'));
        $this->assertEquals('company', Joboard::slugify(' company'));
        $this->assertEquals('company', Joboard::slugify('company '));
    }
}

Чтобы запустить только этот тест, достаточно выполнить следующую команду:

phpunit -c app/ src/App/JoboardBundle/Tests/Utils/JoboardTest

Если все прошло успешно, то вы увидите следующий результат:

PHPUnit 4.0.16 by Sebastian Bergmann.
Configuration read from /var/www/joboard/app/phpunit.xml.dist
.
Time: 59 ms, Memory: 2.75Mb
OK (1 test, 4 assertions)

Полный список утверждений (assertion) вы можете найти в документации к PHPUnit.

Добавление тестов для новых компонентов

Slug для пустой строки — это и есть пустая строка. Можете проверить. Но пустая строка в URL это не очень хорошо. Давайте изменим метод slugify(), чтобы он возвращал ‘n-a’ в случае пустой строки. Вы можете сначала написать тест, а потом обновить сам метод или наоборот. Особой разницы нет, но если вы сначала напишите тест, то получите дополнительную уверенность что ваш код работает именно так, как вы запланировали.

<?php
# src/App/JoboardBundle/Tests/Utils/JoboardTest.php
// ...
$this->assertEquals('n-a', Joboard::slugify(''));
// ...

Если мы теперь запустим тест, то получим ошибку:

PHPUnit 4.0.16 by Sebastian Bergmann.
Configuration read from /var/www/joboard/app/phpunit.xml.dist
F
Time: 61 ms, Memory: 3.00Mb
There was 1 failure:
1) App\JoboardBundle\Tests\Utils\JoboardTest::testSlugify
Failed asserting that two strings are equal.
--- Expected
+++ Actual
@@ @@
-'n-a'
+''
/var/www/joboard/src/App/JoboardBundle/Tests/Utils/JoboardTest.php:15
FAILURES!
Tests: 1, Assertions: 5, Failures: 1.

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

<?php
# src/App/JoboardBundle/Utils/Joboard.php
// ...
    static public function slugify($text)
    {
        if (empty($text)) {
            return 'n-a';
        }
        // ...
    }

Теперь тест должен пройти успешно.

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

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

Но как так получилось?

В базе данных эти поля определенно не пустые. Немного подумав вы понимаете, что когда строка состоит не только из буквенно-цифровых символов, метод slugify() преобразует эту строку в пустую. Итак, вы открываете класс Joboard, чтобы устранить проблему. Но не стоит так спешить, сначала лучше добавить тест:

$this->assertEquals('n-a', Joboard::slugify(' - '));

После того как вы убедились, что тест не проходит, исправьте класс Joboard, переместив проверку на пустую строку в конец метода:

<?php
# src/App/JoboardBundle/Utils/Joboard.php
static public function slugify($text)
{
    // ...
    if (empty($text))
    {
        return 'n-a';
    }
    return $text;
}

Теперь тест выполняется успешно вместе со всеми остальными. Метод slugify() содержал в себе ошибку несмотря на полное покрытие тестом. Это вполне нормально, невозможно предусмотреть все варианты во время разработки. Но когда вы сталкиваетесь с новой проблемой, сначала следует исправить тест, а потом уже приступать к решению. Так же это означает, что ваш код со временем будет становиться только лучше.

Продолжаем улучшать метод slugify

Вы наверняка знаете, что Symfony создали французы. Давайте добавим тест с французским словом содержащим ‘accent’:

<?php
$this->assertEquals('developpeur-web', Joboard::slugify('Développeur Web'));

Тест провалится. Вместо того чтобы заменить é на е, slugify() вставляет символ “-”. Это довольно сложная проблема под названием транслитерация. Если у вас установлен PHP 5.4 и выше (если нет, то срочно установите!), то php функция transliterator_transliterate{:target=»_blank»} решит эту проблему за вас (так же необходимо расширение intl >= 2.0.0). Замените код метода slugify():

<?php
# src/App/JoboardBundle/Utils/Joboard.php
// ...
static public function slugify($text)
{
    $text = transliterator_transliterate("Any-Latin; NFD; [:Nonspacing Mark:] Remove; NFC; [:Punctuation:] Remove; Lower();", $text);
    $text = preg_replace('/[-\s]+/', '-', $text);
    $text = trim($text, '-');
    if (empty($text))
    {
        return 'n-a';
    }
    return $text;
}

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

Покрытие кода

При написании тестов довольно легко пропустить какой то участок кода. Если вы добавили новый функционал или просто хотите проверить весь ли ваш код покрыт тестами, вы можете это проверить при помощи параметра --coverage-html:

phpunit --coverage-html=web/cov/ -c app/

Результат вы можете просмотреть в файле http://joboard.local/cov/index.html. Такая проверка работает только если у вас активирован XDebug и установлены все необходимые зависимости. Установить XDebug можно командой:

sudo apt-get install php5-xdebug

Ваш файл cov/index.html должен выглядеть примерно следующим образом:

Code Coverage

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

Юнит тестирование Doctrine

Такой вид тестирования сложнее, так как требует подключения к базе данных. У вас уже настроено одно соединение, но для теста лучше создать новое. В начале нашего обучения мы познакомились со средами разработки (dev, prod), как способом изменения настройки приложения. По умолчанию все тесты в Symfony исполняются в среде test, поэтому создадим новое соединения с БД для этой среды.

Сделайте копию файла parameters.yml в каталоге app/config и переименуйте её в parameters_test.yml. Откройте этот файл измените имя БД на joboard_test. Теперь надо импортировать наш файл через файл конфигурация для тестового окружения config_test.yml:

# app/config/config_test.yml
imports:
    - { resource: config_dev.yml }
    - { resource: parameters_test.yml }
# ...

Тестирование сущности Job

Для начала создадим файл JobTest.php в каталоге Tests/Entity:

<?php
# src/App/JoboardBundle/Tests/Entity/JobTest.php
namespace App\JoboardBundle\Entity;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use App\JoboardBundle\Utils\Joboard as Joboard;
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 JobTest 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 testGetCompanySlug()
    {
        $job = $this->em->createQuery('SELECT j FROM AppJoboardBundle:Job j')
            ->setMaxResults(1)
            ->getSingleResult();
        $this->assertEquals($job->getCompanySlug(), Joboard::slugify($job->getCompany()));
    }
    public function testGetPositionSlug()
    {
        $job = $this->em->createQuery('SELECT j FROM AppJoboardBundle:Job j')
            ->setMaxResults(1)
            ->getSingleResult();
        $this->assertEquals($job->getPositionSlug(), Joboard::slugify($job->getPosition()));
    }
    public function testGetLocationSlug()
    {
        $job = $this->em->createQuery('SELECT j FROM AppJoboardBundle:Job j')
            ->setMaxResults(1)
            ->getSingleResult();
        $this->assertEquals($job->getLocationSlug(), Joboard::slugify($job->getLocation()));
    }
    public function testSetExpiresAtValue()
    {
        $job = new Job();
        $job->setExpiresAtValue();
        $this->assertEquals(time() + 86400 * 30, $job->getExpiresAt()->format('U'));
    }
    protected function tearDown()
    {
        parent::tearDown();
        $this->em->close();
    }
}

Функция setUp изменяет вашу БД каждый раз при запуске теста. Сначала она удаляет имеющеюся БД, затем создаёт её заново и заполняет данными из фикстур. Таким образом каждый раз при запуске теста у вас будет именно та БД, на которую рассчитан тест.

Тестирование класса репозитория

Теперь создадим тест для класса JobRepository, чтобы проверить что созданные ранее функции возвращают требуемые значения. Создайте новую директорию Tests/Repository, а в ней php файл JobRepositoryTest.php:

<?php
# src/App/JoboardBundle/Tests/Repository/JobRepositoryTest.php
namespace App\JoboardBundle\Tests\Repository;
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 JobRepositoryTest 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 testCountActiveJobs()
    {
        $query = $this->em->createQuery('SELECT c FROM AppJoboardBundle:Category c');
        $categories = $query->getResult();
        foreach($categories as $category) {
            $query = $this->em->createQuery('SELECT COUNT(j.id) FROM AppJoboardBundle:Job j
                                             WHERE j.category = :category AND j.expires_at > :date');
            $query->setParameter('category', $category->getId());
            $query->setParameter('date', date('Y-m-d H:i:s', time()));
            $jobsDb = $query->getSingleScalarResult();
            $jobsRep = $this->em->getRepository('AppJoboardBundle:Job')->countActiveJobs($category->getId());
            // Этот тест проверяет сравнивает количество активных вакансий из репозитория
            // и количество активных ваканий из базы
            $this->assertEquals($jobsRep, $jobsDb);
        }
    }
    public function testGetActiveJobs()
    {
        $query = $this->em->createQuery('SELECT c from AppJoboardBundle:Category c');
        $categories = $query->getResult();
        foreach ($categories as $category) {
            $query = $this->em->createQuery('SELECT COUNT(j.id) from AppJoboardBundle:Job j
                                             WHERE j.expires_at > :date AND j.category = :category');
            $query->setParameter('date', date('Y-m-d H:i:s', time()));
            $query->setParameter('category', $category->getId());
            $jobsDb = $query->getSingleScalarResult();
            $jobs_rep = $this->em->getRepository('AppJoboardBundle:Job')->getActiveJobs($category->getId(), null, null);
            // Проверят количество активных вакансий в категории на соотвествие
            // количеству активных вакансий в базе
            $this->assertEquals($jobsDb, count($jobs_rep));
        }
    }
    public function testGetActiveJob()
    {
        $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);
        $jobDb = $query->getSingleResult();
        $jobRep = $this->em->getRepository('AppJoboardBundle:Job')->getActiveJob($jobDb->getId());
        // Если вакансия активна, то метод getActiveJob() должен вернуть не null значение
        $this->assertNotNull($jobRep);
        $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);
        $jobExpired = $query->getSingleResult();
        $jobRep = $this->em->getRepository('AppJoboardBundle:Job')->getActiveJob($jobExpired->getId());
        // Если вакансия завершена, то метод getActiveJob() должен вернуть значение null
        $this->assertNull($jobRep);
    }
    protected function tearDown()
    {
        parent::tearDown();
        $this->em->close();
    }
}

Проделаем тоже самое с классом CategoryRepositoryTest:

<?php
# src/App/JoboardBundle/Tests/Repository/CategoryRepositoryTest.php
namespace App\JoboardBundle\Tests\Repository;
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 CategoryRepositoryTest 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 testGetWithJobs()
    {
        $query = $this->em->createQuery('SELECT c FROM AppJoboardBundle:Category c
                                         LEFT JOIN c.jobs j
                                         WHERE j.expires_at > :date');
        $query->setParameter('date', date('Y-m-d H:i:s', time()));
        $categoriesDb = $query->getResult();
        $categoriesRep = $this->em->getRepository('AppJoboardBundle:Category')->getWithJobs();
        // Этот тест проверяет количество категорий имеющих активные вакансии,
        // сравнивается значение из репозитория со значением из базы
        $this->assertEquals(count($categoriesRep), count($categoriesDb));
    }
    protected function tearDown()
    {
        parent::tearDown();
        $this->em->close();
    }
}

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

phpunit --coverage-html=web/cov/ -c app src/App/JoboardBundle/Tests/Repository/

Теперь по адресу http://joboard.local/cov/Repository/index.html и вы увидите, что классы репозитория не на 100% покрыты тестами.

Php Codecoverage

Добавим еще несколько тестов для JobRepository чтобы добиться 100%-ого результата. На данный момент в нашей базе данных существует две категории вакансий, одна из которых не содержит ни одной активной вакансии, а другая только одну. Поэтому при тестировании параметров $max и $offset наш тест обрабатывает только категории с минимальным количеством активных вакансий равному 3. Добавьте следующий код внутрь foreach из функции testGetActiveJobs():

<?php
// ...
foreach ($categories as $category) {
    // ...
    // Если в категории хотя бы 3 активные вакансии, то также проверяем параметры $max и $offset
    if($jobsDb > 2 ) {
        $jobsRep = $this->em->getRepository('AppJoboardBundle:Job')->getActiveJobs($category->getId(), 2);
         // Этот тест проверяем, что действительно было выбрано только 2 активных вакансии
        $this->assertEquals(2, count($jobsRep));
         $jobsRep = $this->em->getRepository('AppJoboardBundle:Job')->getActiveJobs($category->getId(), 2, 1);
          // Мы установили лимит на 2 результата, и смещение со второй вакансии
         $this->assertEquals(2, count($jobsRep));
    }
}
// ...

Теперь снова проверьте покрытие кода :

phpunit --coverage-html=web/cov/ -c app src/App/JoboardBundle/Tests/Repository/

Теперь вы увидите что наши тесты покрывают код на 100%.

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