Крок 17: Тестування

5.2 version
Maintained

Тестування

Оскільки ми починаємо додавати все більше і більше функціональності в застосунок, напевно, саме час поговорити про тестування.

Цікавий факт: я знайшов помилку під час написання тестів у цьому розділі.

Symfony використовує PHPUnit для модульного тестування. Встановімо його:

1
$ symfony composer req phpunit --dev

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

SpamChecker — це перший клас, для якого ми будемо писати тести. Згенеруйте модульний тест:

1
$ symfony console make:unit-test 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()
+    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-запиту.

Цей перший тест перевіряє, чи повертає головна сторінка HTTP-відповідь 200.

Твердження, такі як assertResponseIsSuccessful, додані поверх PHPUnit, щоб полегшити вашу роботу. Існує багато таких тверджень, визначених Symfony.

Порада

Ми використовували шлях / для URL-адреси замість того, щоб генерувати його через роутер. Це робиться спеціально, оскільки тестування URL-адрес кінцевих користувачів є частиною того, що ми хочемо протестувати. Якщо ви зміните шлях маршруту — тести зламаються, як приємне нагадування про те, що ви, ймовірно, маєте перенаправити стару URL-адресу на нову, щоб забезпечити взаємодію з пошуковими системами й веб-сайтами, які посилаються на ваш веб-сайт.

Примітка

Ми могли б згенерувати тест за допомогою бандла Maker:

1
$ symfony console make:functional-test 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_ENVtest, як встановлено під час запуску PHPUnit, це змінює ім’я бази даних з main на main_test, щоб тести мали власну базу даних. Це дуже важливо, оскільки нам потрібні стабільні дані для виконання наших тестів, і ми, звичайно, не хочемо змінювати те, що ми зберігаємо в продакшн базі даних.

Перш ніж виконати тест, нам потрібно «ініціалізувати» test базу даних (створити базу даних і виконати її міграцію):

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

Якщо ви зараз виконаєте тести, PHPUnit більше не буде взаємодіяти з вашою продакшн базою даних. Щоб виконати тільки нові тести, передайте шлях до їх класу:

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

Зверніть увагу, що ми явно встановлюємо APP_ENV навіть під час запуску PHPUnit, щоб дозволити Symfony CLI встановити ім’я бази даних у main_test.

Порада

Якщо тест не проходить, може бути корисним проаналізувати об’єкт Response. Отримайте до нього доступ за допомогою $client->getResponse() та виведіть використовуючи echo, щоб побачити, що він собою представляє.

Визначення фікстур

Щоб мати змогу тестувати список коментарів, пагінацію та відправку форми, нам необхідно заповнити базу даних деякими даними. Ми хочемо, щоб дані були однаковими між виконанням тестів, щоб тести проходили успішно. Фікстури — це саме те, що нам потрібно.

Встановіть бандл Doctrine Fixtures:

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 коментар. div:contains() не є валідним селектором CSS, але Symfony має кілька приємних доповнень, запозичених у jQuery.

Замість того, щоб натискати на текст (тобто View), ми могли б вибрати посилання за допомогою селектора CSS:

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 Profiler. Зверніть увагу на те, як продумано повторно використовується зображення, яке вказує на те, що сайт знаходиться у розробці!

Виконайте тести ще раз, щоб перевірити, чи все проходить успішно:

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

Автоматизація робочого процесу за допомогою Makefile

Необхідність запам’ятовувати послідовність команд для виконання тестів дратує. Це, принаймні, має бути задокументовано. Але документація — це крайній випадок. Натомість, як щодо автоматизації однотипних завдань? Це послужило б у якості документації, допомогло б іншим розробникам досліджувати проект та полегшити й прискорити розробку.

Використання 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 req "dama/doctrine-test-bundle:^6" --dev

Вам потрібно буде підтвердити виконання рецепту (тому, що він не є «офіційно» підтримуваним бандлом):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Symfony operations: 1 recipe (d7f110145ba9f62430d1ad64d57ab069)
  -  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
@@ -27,6 +27,10 @@
         </whitelist>
     </filter>

+    <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.