Шаг 17: Тестирование

5.2 version
Maintained Unmaintained
5.0

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

Поскольку мы всё больше и больше добавляем новой функциональности в приложение, наверное, сейчас самое время обсудить тестирование.

Забавный факт: я нашёл баг во время написания тестов в этой главе.

Symfony использует PHPUnit для модульного тестирования. Давайте установим его:

1
$ symfony composer req phpunit --dev

Написание модульных тестов

Давайте начнём с тестирования класса SpamChecker. Создайте образец теста для него:

1
$ symfony console make:test TestCase SpamCheckerTest

Протестировать SpamChecker непросто, потому что этот класс взаимодействует с удалённым API-интерфейсом Akismet, который нам явно не стоит использовать во время тестирования. Поэтому будем имитировать работу этого API.

Давайте напишем наш первый тест для случая, когда API возвращает ошибку:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -2,12 +2,26 @@

 namespace App\Tests;

+use App\Entity\Comment;
+use App\SpamChecker;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpClient\MockHttpClient;
+use Symfony\Component\HttpClient\Response\MockResponse;
+use Symfony\Contracts\HttpClient\ResponseInterface;

 class SpamCheckerTest extends TestCase
 {
-    public function testSomething(): void
+    public function testSpamScoreWithInvalidRequest()
     {
-        $this->assertTrue(true);
+        $comment = new Comment();
+        $comment->setCreatedAtValue();
+        $context = [];
+
+        $client = new MockHttpClient([new MockResponse('invalid', ['response_headers' => ['x-akismet-debug-help: Invalid key']])]);
+        $checker = new SpamChecker($client, 'abcde');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Unable to check for spam: invalid (Invalid key).');
+        $checker->getSpamScore($comment, $context);
     }
 }

С помощью класса MockHttpClient можно создать фиктивную реализацию любого HTTP-сервера. Для этого классу нужно передать массив с экземплярами MockResponse, каждый из которых содержит ожидаемые тело и заголовки ответа.

Затем вызываем метод getSpamScore() и проверяем, что было выброшено исключение, используя встроенный в PHPUnit метод expectException().

Запустите тесты и посмотрите, что все они успешно выполнились:

1
$ symfony php bin/phpunit

Давайте добавим тесты на позитивный сценарий:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
--- a/tests/SpamCheckerTest.php
+++ b/tests/SpamCheckerTest.php
@@ -24,4 +24,32 @@ class SpamCheckerTest extends TestCase
         $this->expectExceptionMessage('Unable to check for spam: invalid (Invalid key).');
         $checker->getSpamScore($comment, $context);
     }
+
+    /**
+     * @dataProvider getComments
+     */
+    public function testSpamScore(int $expectedScore, ResponseInterface $response, Comment $comment, array $context)
+    {
+        $client = new MockHttpClient([$response]);
+        $checker = new SpamChecker($client, 'abcde');
+
+        $score = $checker->getSpamScore($comment, $context);
+        $this->assertSame($expectedScore, $score);
+    }
+
+    public function getComments(): iterable
+    {
+        $comment = new Comment();
+        $comment->setCreatedAtValue();
+        $context = [];
+
+        $response = new MockResponse('', ['response_headers' => ['x-akismet-pro-tip: discard']]);
+        yield 'blatant_spam' => [2, $response, $comment, $context];
+
+        $response = new MockResponse('true');
+        yield 'spam' => [1, $response, $comment, $context];
+
+        $response = new MockResponse('false');
+        yield 'ham' => [0, $response, $comment, $context];
+    }
 }

В PHPUnit есть провайдеры данных, при помощи которых в разных тестах можно использовать одни и те же тестовые данные.

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

Тестирование контроллеров несколько отличается от тестирования «обычного» PHP-класса, поскольку процесс происходит в контексте HTTP-запроса.

Создайте функциональный тест для контроллера конференций:

tests/Controller/ConferenceControllerTest.php
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class ConferenceControllerTest extends WebTestCase
{
    public function testIndex()
    {
        $client = static::createClient();
        $client->request('GET', '/');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h2', 'Give your feedback');
    }
}

Используя Symfony\Bundle\FrameworkBundle\Test\WebTestCase, как базовый класс вместо PHPUnit\Framework\TestCase позволяет нам добится хорошей абстракции для функциональных тестов.

Переменная $client предназначена для имитации браузера. Вместо отправки HTTP-запросов на сервер, происходит обращение к приложению Symfony напрямую. Такой подход имеет свои преимущества: он намного быстрее реального взаимодействия между клиентом и сервером, а ещё позволяет контролировать состояние сервисов после каждого HTTP-запроса.

Первый тест проверяет, что главная страница возвращает статус 200 в HTTP-ответе.

Такие проверки, как assertResponseIsSuccessful, добавлены поверх PHPUnit, чтобы упростить вам работу. В Symfony ещё много подобных проверок.

Совет

Мы использовали / в качестве URL-адреса, вместо получения его через маршрутизатор. Это сделано не случайно, так как тестирование URL-адресов, используемых пользователем, являются частью того, что мы хотим протестировать. Если вы поменяете адрес, тесты уже не будут успешно выполняться. Это послужит полезным напоминанием о том, что вам, скорее всего, нужно сделать редирект со старого адреса на новый, чтобы поисковые машины и ссылающиеся сайты узнали об этом.

Примечание

Создадим тест с помощью бандла maker:

1
$ symfony console make:test WebTestCase Controller\\ConferenceController

Настройка тестового окружения

По умолчанию тесты PHPUnit запускаются в окружении Symfony под названием test. Параметры заданы в конфигурационном файле PHPUnit:

phpunit.xml.dist
1
2
3
4
5
6
7
8
9
<phpunit>
    <php>
        <ini name="error_reporting" value="-1" />
        <server name="APP_ENV" value="test" force="true" />
        <server name="SHELL_VERBOSITY" value="-1" />
        <server name="SYMFONY_PHPUNIT_REMOVE" value="" />
        <server name="SYMFONY_PHPUNIT_VERSION" value="8.5" />
    </php>
</phpunit>

Для того, чтобы заработали тесты, установим переменную окружения с секретным ключом AKISMET_KEY в окружении test:

1
$ APP_ENV=test symfony console secrets:set AKISMET_KEY

Примечание

Как и в предыдущих главах APP_ENV=test задаёт переменную окружения APP_ENV в контексте выполняемой команды. Если у вас Windows, то вместо этого воспользуйтесь флагом --env=test, т.е. выполните команду symfony console secrets:set AKISMET_KEY --env=test.

Работа с тестовой базой данных

Как показано ранее, CLI-команды Symfony автоматически подставляют переменную окружения DATABASE_URL. Когда значение APP_ENV равно test, как при выполнении тестов PHPUnit, Symfony изменит имя базы данных с main на main_test, создав отдельную базу данных для тестов. Это важно, поскольку при выполнении тестов не будут затрагиваться данные, которые хранятся в нашей локальной базе данных, используемой во время разработки приложения.

Для запуска тестов сначала нужно «инициализировать» базу данных test (т.е. создать новую базу данных и выполнить миграцию):

1
2
$ APP_ENV=test symfony console doctrine:database:create
$ APP_ENV=test symfony console doctrine:migrations:migrate -n

Если мы теперь запустим тесты, PHPUnit больше не будет взаимодействовать с нашей рабочей базой данной. Для выполнения только новых тестов передайте путь к нужному тестируемому классу в CLI-команде:

1
$ APP_ENV=test symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Обратите внимание, что даже при запуске тестов PHPUnit нужно явно задать переменную окружения APP_ENV, чтобы CLI-команды Symfony использовали базу данных main_test.

Совет

Если тест не проходит, имеет смысл посмотреть на состояние объекта Response — вывести на экран результат выполнения метода $client->getResponse() через конструкцию echo.

Определение фикстур

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

Установите бандл для фикстур в Doctrine:

1
$ symfony composer req orm-fixtures --dev

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

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,6 +2,8 @@

 namespace App\DataFixtures;

+use App\Entity\Comment;
+use App\Entity\Conference;
 use Doctrine\Bundle\FixturesBundle\Fixture;
 use Doctrine\Persistence\ObjectManager;

@@ -9,8 +11,24 @@ class AppFixtures extends Fixture
 {
     public function load(ObjectManager $manager)
     {
-        // $product = new Product();
-        // $manager->persist($product);
+        $amsterdam = new Conference();
+        $amsterdam->setCity('Amsterdam');
+        $amsterdam->setYear('2019');
+        $amsterdam->setIsInternational(true);
+        $manager->persist($amsterdam);
+
+        $paris = new Conference();
+        $paris->setCity('Paris');
+        $paris->setYear('2020');
+        $paris->setIsInternational(false);
+        $manager->persist($paris);
+
+        $comment1 = new Comment();
+        $comment1->setConference($amsterdam);
+        $comment1->setAuthor('Fabien');
+        $comment1->setEmail('[email protected]');
+        $comment1->setText('This was a great conference.');
+        $manager->persist($comment1);

         $manager->flush();
     }

После применения фикстур все ранее сохранённые данные будут удалены, включая пользователя-администратора. Чтобы этого не произошло, добавим его в фикстуры:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
--- a/src/DataFixtures/AppFixtures.php
+++ b/src/DataFixtures/AppFixtures.php
@@ -2,13 +2,22 @@

 namespace App\DataFixtures;

+use App\Entity\Admin;
 use App\Entity\Comment;
 use App\Entity\Conference;
 use Doctrine\Bundle\FixturesBundle\Fixture;
 use Doctrine\Persistence\ObjectManager;
+use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;

 class AppFixtures extends Fixture
 {
+    private $encoderFactory;
+
+    public function __construct(EncoderFactoryInterface $encoderFactory)
+    {
+        $this->encoderFactory = $encoderFactory;
+    }
+
     public function load(ObjectManager $manager)
     {
         $amsterdam = new Conference();
@@ -30,6 +39,12 @@ class AppFixtures extends Fixture
         $comment1->setText('This was a great conference.');
         $manager->persist($comment1);

+        $admin = new Admin();
+        $admin->setRoles(['ROLE_ADMIN']);
+        $admin->setUsername('admin');
+        $admin->setPassword($this->encoderFactory->getEncoder(Admin::class)->encodePassword('admin', null));
+        $manager->persist($admin);
+
         $manager->flush();
     }
 }

Совет

Если вы не помните, какой сервис вам нужно использовать для выполнения какой-либо задачи, воспользуетесь командой debug:autowiring, указав в качестве аргумента ключевое слово для поиска:

1
$ symfony console debug:autowiring encoder

Применение фикстур

Загрузите фикстуры для окружения test:

1
$ APP_ENV=test symfony console doctrine:fixtures:load

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

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

Напишите новый тест, который щёлкает по любой ссылке конференции на главной странице:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -14,4 +14,19 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertResponseIsSuccessful();
         $this->assertSelectorTextContains('h2', 'Give your feedback');
     }
+
+    public function testConferencePage()
+    {
+        $client = static::createClient();
+        $crawler = $client->request('GET', '/');
+
+        $this->assertCount(2, $crawler->filter('h4'));
+
+        $client->clickLink('View');
+
+        $this->assertPageTitleContains('Amsterdam');
+        $this->assertResponseIsSuccessful();
+        $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+        $this->assertSelectorExists('div:contains("There are 1 comments")');
+    }
 }

Давайте опишем, какие действия должны происходить в тесте на простом английском языке:

  • Как и в первом тесте, сначала переходим на главную страницу;
  • Метод request() возвращает экземпляр Crawler, с помощью которого можно найти нужные элементы на странице (например, ссылки, формы и всё остальное, что можно получить через селекторы CSS или XPath);
  • Благодаря CSS-селектору проверяем, что на главной странице есть две конференции;
  • Далее кликаем по ссылке «View» (из-за того, что один вызов щёлкает только по одной ссылке, Symfony автоматически выберет первую найденную в разметке);
  • Проверяем заголовок страницы, ответ и тег <h2> для того, чтобы знать наверняка, что мы находимся на нужной странице (дополнительно можно было проверить на совпадение маршрут);
  • И последнее: проверяем, что на странице есть 1 комментарий. В Symfony кое-какие некорректные в CSS селекторы позаимствованы из jQuery. Как раз один из таких селекторов мы используем — div:contains().

Опять же при помощи CSS-селектора вместо нажатия на текст (View), выбираем нужную ссылку:

1
$client->click($crawler->filter('h4 + p a')->link());

Проследите, чтобы новый тест выполнился успешно:

1
$ APP_ENV=test symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Отправка формы в функциональном тесте

Хотите пойти дальше и ещё лучше протестировать приложение? Давайте напишем тест, который имитирует отправку форму, добавляя новый комментарий с фотографией к конференции. Непростая задача, не правда? Как бы не так: только посмотрите на используемый для решения код — это не сложнее того, что мы уже делали ранее:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -29,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
         $this->assertSelectorExists('div:contains("There are 1 comments")');
     }
+
+    public function testCommentSubmission()
+    {
+        $client = static::createClient();
+        $client->request('GET', '/conference/amsterdam-2019');
+        $client->submitForm('Submit', [
+            'comment_form[author]' => 'Fabien',
+            'comment_form[text]' => 'Some feedback from an automated functional test',
+            'comment_form[email]' => '[email protected]',
+            'comment_form[photo]' => dirname(__DIR__, 2).'/public/images/under-construction.gif',
+        ]);
+        $this->assertResponseRedirects();
+        $client->followRedirect();
+        $this->assertSelectorExists('div:contains("There are 2 comments")');
+    }
 }

Для отправки формы через метод submitForm() для начала нужно узнать имена элементов с помощью инструментов разработки в браузере или через панель Form в профилировщике Symfony. Обратите внимание на разумное повторное использование изображения-заглушки!

Снова запустите тесты, чтобы убедиться, что они все прошли:

1
$ APP_ENV=test symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Если вы хотите посмотреть результат в браузере, остановите веб-сервер и перезапустите его в окружении test:

1
2
$ symfony server:stop
$ APP_ENV=test symfony server:start -d

Выгрузка фикстур

Если запустить тесты ещё раз, то они больше не пройдут. А всё потому, что в базе данных уже есть комментарии, и при повторном выполнении тестов они снова добавятся. Вот поэтому проверка количества комментариев не даст положительного результата. Таким образом, перед каждым выполнением тестов нужно очищать базу данных и заново загружать фикстуры:

1
2
$ APP_ENV=test symfony console doctrine:fixtures:load
$ APP_ENV=test symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Автоматизация рабочего процесса с помощью Make-файлов

Помнить и набирать длинную последовательность команд, чтобы выполнить тесты — досадно и неприятно. Как минимум это всё нужно указать в документации, но оставим это на крайний случай. А что, если автоматизировать такие рутинные задачи? Вдобавок это дало своего рода документацию, помогло остальным разработчикам, и просто ускорило разработку.

Один из способов автоматизации выполнения команд — воспользоваться Makefile:

Makefile
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
SHELL := /bin/bash

tests: export APP_ENV=test
tests:
    symfony console doctrine:database:drop --force || true
    symfony console doctrine:database:create
    symfony console doctrine:migrations:migrate -n
    symfony console doctrine:fixtures:load -n
    symfony php bin/phpunit [email protected]
.PHONY: tests

Предупреждение

Следуя правилам Makefile отступ должен состоять из одного символа табуляции вместо пробелов.

Обратите внимание на флаг -n в команде Doctrine; это глобальный флаг для Symfony-команд, который выполняет их в обычном (неинтерактивном) режиме.

Теперь, если вам нужно выполнить тесты, то используйте команду make tests:

1
$ make tests

Очистка базы данных после каждого теста

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

Переместите тест testConferencePage после testCommentSubmission:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -15,21 +15,6 @@ class ConferenceControllerTest extends WebTestCase
         $this->assertSelectorTextContains('h2', 'Give your feedback');
     }

-    public function testConferencePage()
-    {
-        $client = static::createClient();
-        $crawler = $client->request('GET', '/');
-
-        $this->assertCount(2, $crawler->filter('h4'));
-
-        $client->clickLink('View');
-
-        $this->assertPageTitleContains('Amsterdam');
-        $this->assertResponseIsSuccessful();
-        $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
-        $this->assertSelectorExists('div:contains("There are 1 comments")');
-    }
-
     public function testCommentSubmission()
     {
         $client = static::createClient();
@@ -44,4 +29,19 @@ class ConferenceControllerTest extends WebTestCase
         $client->followRedirect();
         $this->assertSelectorExists('div:contains("There are 2 comments")');
     }
+
+    public function testConferencePage()
+    {
+        $client = static::createClient();
+        $crawler = $client->request('GET', '/');
+
+        $this->assertCount(2, $crawler->filter('h4'));
+
+        $client->clickLink('View');
+
+        $this->assertPageTitleContains('Amsterdam');
+        $this->assertResponseIsSuccessful();
+        $this->assertSelectorTextContains('h2', 'Amsterdam 2019');
+        $this->assertSelectorExists('div:contains("There are 1 comments")');
+    }
 }

Теперь тесты завершились неудачно.

Для очистки базы данных между прогонами тестов установите DoctrineTestBundle:

1
$ symfony composer config extra.symfony.allow-contrib true
1
$ symfony composer req "dama/doctrine-test-bundle:^6" --dev

Чтобы установить этот бандл потребуется вручную в терминале подтвердить выполнение его рецепта (так как он не поддерживается «официально»):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Symfony operations: 1 recipe (a5c79a9ff21bc3ae26d9bb25f1262ed7)
  -  WARNING  dama/doctrine-test-bundle (>=4.0): From github.com/symfony/recipes-contrib:master
    The recipe for this package comes from the "contrib" repository, which is open to community contributions.
    Review the recipe at https://github.com/symfony/recipes-contrib/tree/master/dama/doctrine-test-bundle/4.0

    Do you want to execute this recipe?
    [y] Yes
    [n] No
    [a] Yes for all packages, only for the current installation session
    [p] Yes permanently, never ask again for this project
    (defaults to n): p

Активируйте обработчик в PHPUnit:

patch_file
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -29,6 +29,10 @@
         </include>
     </coverage>

+    <extensions>
+        <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
+    </extensions>
+
     <listeners>
         <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
     </listeners>

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

Тесты снова заработали:

1
$ make tests

Использование настоящего браузера для функциональных тестов

Только что мы рассмотрели использование специального браузера для функциональных тестов, который напрямую вызывает код Symfony. Однако вместо этого можно задействовать настоящий браузер, отправляя реальные HTTP-запросы. Встречайте, Symfony Panther:

1
$ symfony composer req panther --dev

После установки можно написать тесты, в которых используется настоящий браузер Google Chrome, как показано ниже:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
--- a/tests/Controller/ConferenceControllerTest.php
+++ b/tests/Controller/ConferenceControllerTest.php
@@ -2,13 +2,13 @@

 namespace App\Tests\Controller;

-use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+use Symfony\Component\Panther\PantherTestCase;

-class ConferenceControllerTest extends WebTestCase
+class ConferenceControllerTest extends PantherTestCase
 {
     public function testIndex()
     {
-        $client = static::createClient();
+        $client = static::createPantherClient(['external_base_uri' => $_SERVER['SYMFONY_PROJECT_DEFAULT_ROUTE_URL']]);
         $client->request('GET', '/');

         $this->assertResponseIsSuccessful();

Переменная окружения SYMFONY_PROJECT_DEFAULT_ROUTE_URL содержит URL-адрес локального веб-сервера.

Выполнение функциональных тестов по принципу чёрного ящика с помощью Blackfire

Приложение Blackfire player — ещё один способ запустить функциональные тесты. Помимо функционального тестирования, этот инструмент также может проводить тестирование производительности.

Для более подробного изучения этой темы посмотрите шаг «Производительность».


  • « Previous Шаг 16: Защита от спама с помощью API
  • Next » Шаг 18: Переход к асинхронности

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.