Étape 17: Tester

5.0 version
Maintained

Tester

Comme nous commençons à ajouter de plus en plus de fonctionnalités dans l’application, c’est probablement le bon moment pour parler des tests.

Fun fact : j’ai trouvé un bogue en écrivant les tests de ce chapitre.

Symfony s’appuie sur PHPUnit pour les tests unitaires. Installons-le :

1
$ symfony composer req phpunit

Écrire des tests unitaires

SpamChecker est la première classe pour laquelle nous allons écrire des tests. Générez un test unitaire :

1
$ symfony console make:unit-test SpamCheckerTest

Tester le SpamChecker est un défi car nous ne voulons certainement pas utiliser l’API Akismet. Nous allons mocker l’API.

Écrivons un premier test pour le cas où l’API renverrai une erreur :

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);
     }
 }

La classe MockHttpClient permet de simuler n’importe quel serveur HTTP. Elle prend un tableau d’instances MockResponse contenant le corps et les en-têtes de réponse attendus.

Ensuite, nous appelons la méthode getSpamScore() et vérifions qu’une exception est levée via la méthode``expectException()`` de PHPUnit.

Lancez les tests pour vérifier qu’ils passent :

1
$ symfony php bin/phpunit

Ajoutons des tests pour les cas qui fonctionnent :

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];
+    }
 }

Les data providers de PHPUnit nous permettent de réutiliser la même logique de test pour plusieurs scénarios.

Écrire des tests fonctionnels pour les contrôleurs

Tester les contrôleurs est un peu différent de tester une classe PHP « ordinaire » car nous voulons les exécuter dans le contexte d’une requête HTTP.

Installez quelques dépendances supplémentaires nécessaires aux tests fonctionnels :

1
$ symfony composer require browser-kit --dev

Créez un test fonctionnel pour le contrôleur Conference :

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');
    }
}

Ce premier test vérifie que la page d’accueil renvoie une réponse HTTP 200.

La variable $client simule un navigateur. Au lieu de faire des appels HTTP au serveur, il appelle directement l’application Symfony. Cette stratégie présente plusieurs avantages : elle est beaucoup plus rapide que les allers-retours entre le client et le serveur, mais elle permet aussi aux tests d’analyser l’état des services après chaque requête HTTP.

Des assertions telles que assertResponseIsSuccessful sont ajoutées à PHPUnit pour faciliter votre travail. Plusieurs assertions de ce type sont définies par Symfony.

Astuce

Nous avons utilisé / pour l’URL au lieu de la générer avec le routeur. C’est volontaire, car tester les URLs telles qu’elles seront déployées fait partie de ce que nous voulons tester. Si vous la modifiez, les tests vont échouer pour vous rappeler que vous devriez probablement rediriger l’ancienne URL vers la nouvelle, pour être gentil avec les moteurs de recherche et les sites web qui renvoient vers le vôtre.

Note

Nous aurions pu générer le test grâce au Maker Bundle :

1
$ symfony console make:functional-test Controller\\ConferenceController

Les tests PHPUnit sont exécutés dans un environnement test dédié. Nous devons définir la chaîne secrète AKISMET_KEY de cet environnement :

1
$ APP_ENV=test symfony console secrets:set AKISMET_KEY

Exécutez les nouveaux tests uniquement en passant le chemin vers sa classe :

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Astuce

Lorsqu’un test échoue, il peut être utile d’analyser l’objet Response. Accédez-y grâce à $client->getResponse() et faites un echo pour voir à quoi il ressemble.

Définir des fixtures (données de test)

Pour pouvoir tester la liste des commentaires, la pagination et la soumission du formulaire, nous devons remplir la base de données avec quelques données. Nous voulons également que les données soient identiques entre les cycles de tests pour qu’ils réussissent. Les fixtures sont exactement ce dont nous avons besoin.

Installez le composant Doctrine Fixtures :

1
$ symfony composer req orm-fixtures --dev

Un nouveau répertoire src/DataFixtures/ a été créé lors de l’installation, avec une classe d’exemple prête à être personnalisée. Ajoutez deux conférences et un commentaire pour le moment :

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\Common\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();
     }

Lorsque nous chargerons les données de test, toutes les données présentes seront supprimées, y compris celles de l’admin. Pour éviter cela, modifions les fixtures :

 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\Common\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();
     }
 }

Astuce

Si vous ne vous souvenez pas quel service vous devez utiliser pour une tâche donnée, utilisez le debug:autowiring avec un mot-clé :

1
$ symfony console debug:autowiring encoder

Charger des données de test

Chargez les données de test dans la base de données. Prenez conscience que cette action supprimera toutes les données de la base de données (si vous ne le souhaitez pas, continuez à lire).

1
$ symfony console doctrine:fixtures:load

Parcourir un site web avec des tests fonctionnels

Comme nous l’avons vu, le client HTTP utilisé dans les tests simule un navigateur, afin que nous puissions parcourir le site comme si nous utilisions un navigateur.

Ajoutez un nouveau test qui clique sur une page de conférence depuis la page d’accueil :

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")');
+    }
 }

Décrivons ce qu’il se passe dans ce test :

  • Comme pour le premier test, nous allons sur la page d’accueil ;
  • La méthode request() retourne une instance de Crawler qui aide à trouver des éléments sur la page (comme des liens, des formulaires, ou tout ce que vous pouvez atteindre avec des sélecteurs CSS ou XPath) ;
  • Grâce à un sélecteur CSS, nous testons que nous avons bien deux conférences listées sur la page d’accueil ;
  • On clique ensuite sur le lien « View » (comme il n’est pas possible de cliquer sur plus d’un lien à la fois, Symfony choisit automatiquement le premier qu’il trouve) ;
  • Nous vérifions le titre de la page, la réponse et le <h2> de la page pour être sûr d’être sur la bonne page (nous aurions aussi pu vérifier la route correspondante) ;
  • Enfin, nous vérifions qu’il y a 1 commentaire sur la page. div:contains() n’est pas un sélecteur CSS valide, mais Symfony a quelques ajouts intéressants, empruntés à jQuery.

Au lieu de cliquer sur le texte (i.e. View), nous aurions également pu sélectionner le lien grâce à un sélecteur CSS :

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

Vérifiez que le nouveau test passe :

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Utiliser une base de données de test

Par défaut, les tests sont exécutés dans l’environnement Symfony de test tel que défini dans le fichier phpunit.xml.dist :

phpunit.xml.dist
1
2
3
4
5
<phpunit>
    <php>
        <server name="APP_ENV" value="test" force="true" />
    </php>
</phpunit>

Si vous voulez utiliser une base de données différente pour vos tests, remplacez la variable d’environnement DATABASE_URL dans le fichier .env.test :

1
2
3
4
5
6
7
8
--- a/.env.test
+++ b/.env.test
@@ -1,4 +1,5 @@
 # define your env variables for the test env here
+DATABASE_URL=postgres://main:[email protected]:32773/test?sslmode=disable&charset=utf8
 KERNEL_CLASS='App\Kernel'
 APP_SECRET='$ecretf0rt3st'
 SYMFONY_DEPRECATIONS_HELPER=999999

Chargez les données de test pour l’environnement/la base de données de test :

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

Pour le reste de cette étape, nous ne redéfinirons pas la variable d’environnement DATABASE_URL. Pour les tests, utiliser la même base de données que celle de l’environnement dev présente certains avantages, que nous aborderons dans la section suivante.

Soumettre un formulaire dans un test fonctionnel

Voulez-vous passer au niveau supérieur ? Essayez d’ajouter un nouveau commentaire avec une photo sur une conférence, à partir d’un test, en simulant une soumission de formulaire. Cela semble ambitieux, n’est-ce pas ? Regardez le code nécessaire : pas plus compliqué que ce que nous avons déjà écrit :

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")');
+    }
 }

Pour soumettre un formulaire via submitForm(), recherchez les noms de champs grâce aux outils de développement du navigateur ou via l’onglet Form du Symfony Profiler. Notez la réutilisation pratique de l’image en construction !

Relancez les tests pour vérifier que tout est bon :

1
$ symfony php bin/phpunit tests/Controller/ConferenceControllerTest.php

Un des avantages d’utiliser la base de données « dev » pour les tests est que vous pouvez vérifier le résultat dans un navigateur :

Recharger les données de test

Si vous effectuez les tests une deuxième fois, ils devraient échouer. Comme il y a maintenant plus de commentaires dans la base de données, l’assertion qui vérifie le nombre de commentaires est erronée. Nous devons réinitialiser l’état de la base de données entre chaque exécution, en rechargeant les données de test avant chacune d’elles :

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

Automatiser votre workflow avec un Makefile

Il est assez pénible d’avoir à se souvenir d’une séquence de commandes pour exécuter les tests. Cela devrait au moins être documenté, même si cette documentation ne devrait être consultée qu’en dernier recours. Et si on automatisait plutôt les opérations récurrentes ? Cela servirait aussi de documentation rapidement accessible aux autres, et rendrait le développement plus facile et plus productif.

L’utilisation d’un Makefile est une façon d’automatiser les commandes :

Makefile
1
2
3
4
5
6
SHELL := /bin/bash

tests:
    symfony console doctrine:fixtures:load -n
    symfony php bin/phpunit
.PHONY: tests

Notez l’option -n sur la commande Doctrine ; c’est une option standard sur les commandes Symfony qui les rend non interactives.

Chaque fois que vous voulez exécuter les tests, utilisez make tests :

1
$ make tests

Réinitialiser la base de données après chaque test

Réinitialiser la base de données après chaque test c’est bien, mais avoir des tests vraiment indépendants c’est encore mieux. Nous ne voulons pas qu’un test s’appuie sur les résultats des précédents. Le changement de l’ordre des tests ne devrait pas changer le résultat. Comme nous allons le découvrir maintenant, ce n’est pas le cas pour le moment.

Déplacez le test testConferencePage après 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
         $crawler = $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")');
+    }
 }

Les tests échouent maintenant.

Pour réinitialiser la base de données entre les tests, installez DoctrineTestBundle :

1
$ symfony composer require dama/doctrine-test-bundle --dev

Vous devrez confirmer l’application de la recette (car il ne s’agit pas d’un bundle « officiellement » supporté) :

 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

Activez le listener de 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>

Et voilà. Toute modification apportée pendant les tests est automatiquement annulée à la fin de chaque test.

Les tests devraient passer à nouveau :

1
$ make tests

Utiliser un vrai navigateur pour les tests fonctionnels

Les tests fonctionnels utilisent un navigateur spécial qui appelle directement la couche Symfony. Mais vous pouvez aussi utiliser un vrai navigateur et la vraie couche HTTP grâce à Symfony Panther :

Avertissement

Au moment où j’ai écrit ce paragraphe, il n’était pas possible d’installer Panther sur un projet Symfony 5 car une dépendance n’était pas encore compatible.

1
$ symfony composer req panther --dev

Vous pouvez ensuite écrire des tests qui utilisent un vrai navigateur Google Chrome avec les modifications suivantes :

 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_DEFAULT_ROUTE_URL']]);
         $client->request('GET', '/');

         $this->assertResponseIsSuccessful();

La variable d’environnement SYMFONY_DEFAULT_ROUTE_URL contient l’URL du serveur web local.

Exécuter des tests fonctionnels de boîte noire avec Blackfire

Une autre façon d’effectuer des tests fonctionnels est d’utiliser le lecteur Blackfire. En plus de ce que vous pouvez faire avec les tests fonctionnels, il peut également effectuer des tests de performance.

Reportez-vous à l’étape « Performance » pour en savoir plus.


  • « Previous Étape 16: Empêcher le spam avec une API
  • Next » Étape 18: Faire de l’asynchrone

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