Tests fonctionnels Symfony : exposer les exceptions de pages Symfony dans le rapport de test PHPUnit

Peut-être avez-vous déjà vécu cette situation où un ou plusieurs de vos tests fonctionnels échouent et le rapport d’erreur de PHPUnit n’est pas assez précis pour que vous sachiez ce qui se passe d’un seul coup d’oeil. Vous devez alors aller voir ce que fait votre test, reproduire le scénario depuis un navigateur pour enfin arriver au détail de l’exception, ou alors fouiller dans vos fichiers de logs. Cela peut vite devenir une perte de temps quand les erreurs se répètent.

Dans cet article, nous verrons comment, progressivement, nous pouvons arriver à une solution qui passe tout simplement par la capture du contenu de la page d’exception au sein de votre test et l’affichage de son contenu dans la sortie de PHPUnit.

Prérequis

Avant de continuer, assurez-vous d’installer les éléments suivants :
- PHP
- L’installeur Symfony
- PHPUnit
- Git (Ok c’est pas vraiment obligatoire ici mais ça peut vous aider)

Création du projet

Nous allons créer un nouveau projet Symfony qui nous servira de base :

symfony new my_tdd_project

Lancez le serveur Symfony :

cd my_tdd_project
php bin/console server:run

Vous devriez avoir la sortie suivante :

[OK] Server running on http://127.0.0.1:8000

Lancez le navigateur à cette adresse pour constater que l’application est fonctionnelle.

browser_after_install Nous allons ensuite exécuter les tests. L’application créée contient déjà un test pour le contrôleur par défaut. Vous pouvez le constater en lançant la commande phpunit depuis la racine de votre projet.

PHPUnit X.X.X by Sebastian Bergmann.

Configuration read from /home/user/Documents/projects/my_tdd_project/phpunit.xml.dist

.

Time: 175 ms, Memory: 19.00Mb
OK (1 test, 2 assertions)

Super, puisque tout fonctionne (puisque vous n’avez encore rien cassé mais j’ai foi en vous), nous allons en profiter pour faire un commit :

git init
git add .
git commit -m "First commit"

Même si l’usage de Git n’est pas le sujet ici, c’est toujours bon de rappeler un peu les bonnes pratiques n’est-ce pas ? 🙂

Annonce de la problématique

Nous allons maintenant commencer notre sabotage en supprimant purement et simplement le contrôleur par défaut src/AppBundle/Controller/DefaultController.php. Rechargez la page dans votre navigateur et vous verrez une belle erreur “No route found for “GET /”“.

browser_no_routePuisque nous avons un test associé à ce contrôleur, PHPUnit devrait maintenant nous indiquer ce qui ne va pas.

PHPUnit X.X.X by Sebastian Bergmann.

Configuration read from /home/user/Documents/projects/my_tdd_project/phpunit.xml.dist

F

Time: 1.63 seconds, Memory: 82.50Mb

There was 1 failure:

1) Tests\AppBundle\Controller\DefaultControllerTest::testIndex
Failed asserting that 404 matches expected 200.

/home/user/Documents/projects/my_tdd_project/tests/AppBundle/Controller/DefaultControllerTest.php:15

FAILURES!                            
Tests: 1, Assertions: 1, Failures: 1.

Comme vous pouvez le constater, PHPUnit indique “Failed asserting that 404 matches expected 200.”. On peut en déduire qu’il s’agit des codes de réponse HTTP puisqu’il s’agit d’un test de contrôleur et que notre navigateur nous a fait part d’un problème de route tout à l’heure. Jetons un œil au test en question.

<?php

namespace Tests\AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class DefaultControllerTest extends WebTestCase
{
    public function testIndex()
    {
        $client = static::createClient();

        $crawler = $client->request('GET', '/');

        $this->assertEquals(200, $client->getResponse()->getStatusCode());
        $this->assertContains('Welcome to Symfony', $crawler->filter('#container h1')->text());
    }
}

Effectivement, on vérifie le code HTTP de la réponse pour connaître le statut de la requête. Le message “Failed asserting that 404 matches expected 200.” reste donc encore relativement compréhensible. Maintenant nous allons introduire une erreur plus vicieuse et voir comment elle est restituée. Restaurez le contrôleur supprimé précédemment (en faisant un git reset --hard par exemple). On lance PHPUnit et tout est vert.

Modifiez le fichier app/Resources/views/base.html.twig pour y introduire une jolie erreur de syntaxe (je vous avais dit que j’avais foi en vous) :

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>{% block title %}Welcome!{% endblock %}</title>
        {% block stylesheets %}{% endblock %}
        <link rel="icon" type="image/x-icon" href="{{ asset('favicon.ico') }}" />
    </head>
    <body>
        {% block body %}{% endblock %}
        {% block javascripts %}{% endblock %}
        {% block oups }{% endblock %}
    </body>
</html>

Nous avons introduit le block “oups” auquel il manque un “%” avant l’accolade de fermeture. Lancez PHPUnit.

PHPUnit X.X.X by Sebastian Bergmann.

Configuration read from /home/user/Documents/projects/my_tdd_project/phpunit.xml.dist

F

Time: 701 ms, Memory: 68.25Mb

There was 1 failure:

1) Tests\AppBundle\Controller\DefaultControllerTest::testIndex
Failed asserting that 500 matches expected 200.

/home/user/Documents/projects/my_tdd_project/tests/AppBundle/Controller/DefaultControllerTest.php:15

FAILURES!                            
Tests: 1, Assertions: 1, Failures: 1.

Hum, vous sentez l’obscurité approcher là ou pas ? On voit bien qu’il s’agit d’une erreur 500 qui correspond à une erreur interne du serveur, mais là ça ne nous avance pas à grand chose. Nous sommes donc obligés d’ouvrir notre navigateur pour aller voir l’erreur en question (ou fouiller dans les logs).

browser_error_500Nous voyons donc ceci :

Unexpected "}".
500 Internal Server Error - Twig_Error_Syntax 

Ah bon !!!! Là c’est déjà plus clair. Nous savons que nous avons fait une erreur dans un fichier twig. Puisque nous venons justement de modifier un fichier twig, il nous est facile de savoir d’où vient l’erreur. Vous allez me dire, ça a pris 1 seconde pour ouvrir un navigateur. Oui, dans ce cas précis effectivement. Mais en général, débuguer ce genre d’erreur est nettement plus long. Il peut s’agir d’une étape dans un parcours d’achat ou d’une opération effectuée en mode connecté, ce qui nécessite nettement plus de clics et de saisies et peut vite devenir rébarbatif.

Si vous effectuez des développements pilotés par les tests, vous préféreriez sans doute que les erreurs rapportées par PHPUnit dans le cadre de ce genre de tests fonctionnels soient plus précises afin de pouvoir les corriger plus tôt.

Converger vers la solution

Réfléchissons un instant à comment nous pourrions améliorer notre rapport d’erreur. Nous savons qu’elle est affichée sur la page, nous utilisons le gestionnaire d’exception par défaut de Symfony qui nous présente joliment les exceptions quand elles se produisent. Il doit y avoir un moyen simple de les récupérer depuis la page affichée.

Le test utilise le crawler (\Symfony\Component\DomCrawler\Crawler) pour vérifier que le texte “Welcome to Symfony” est bien présent. Nous pouvons très bien l’utiliser pour extraire les informations liées à l’exception affichée. En analysant le code source de la page, nous constatons que l’exception est rendue comme ceci :

<div class="text-exception">
    <div class="open-quote">“</div>

    <h1>Unexpected "}".</h1>

    <div>
        <strong>500</strong> Internal Server Error - <abbr title="Twig_Error_Syntax">Twig_Error_Syntax</abbr>
    </div>


    <div class="close-quote">”</div>
</div>

Nous allons donc capturer l’élément “div.text-exception” pour en extraire le texte. Mais avant tout, il nous faut savoir dans notre test qu’une erreur a eu lieu. Comment le savoir ? En étudiant le code source de PHPUnit on se rend compte que toutes les assertions lèvent des exceptions qui héritent de PHPUnit_Framework_Exception. Nous allons commencer par attraper cette exception dans notre test pour tenter d’afficher notre rapport amélioré :

<?php

namespace Tests\AppBundle\Controller;

use PHPUnit_Framework_Exception;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class DefaultControllerTest extends WebTestCase
{
    public function testIndex()
    {
        $client = static::createClient();

        $crawler = $client->request('GET', '/');

        try {
            $this->assertEquals(200, $client->getResponse()->getStatusCode());
            $this->assertContains('Welcome to Symfony', $crawler->filter('#container h1')->text());
        } catch (PHPUnit_Framework_Exception $e) {
            throw new PHPUnit_Framework_Exception($this->client->getCrawler()->filter('.text-exception')->text());
        }
    }
}

Ce qui nous donne :

PHPUnit X.X.X by Sebastian Bergmann.

Configuration read from /home/user/Documents/projects/my_tdd_project/phpunit.xml.dist

E

Time: 632 ms, Memory: 68.50Mb

There was 1 error:

1) Tests\AppBundle\Controller\DefaultControllerTest::testIndex
PHPUnit_Framework_Exception: 
            “

            Unexpected "}".


                500 Internal Server Error - Twig_Error_Syntax



            ”


/home/user/Documents/projects/my_tdd_project/tests/AppBundle/Controller/DefaultControllerTest.php:20

FAILURES!                          
Tests: 1, Assertions: 1, Errors: 1.

Il y a de l’idée mais ça fait quand même beaucoup d’espaces tout ça. Supprimons les espaces pour arriver à une sortie plus propre.

$message = preg_replace('#\s{2,}#', '', $client->getCrawler()->filter('.text-exception')->text());
throw new PHPUnit_Framework_Exception($message);

Nous supprimons les espaces qui se suivent pour n’en laisser qu’un, puisque nous souhaitons conserver la séparation des mots. Voyons ce que ça donne :

PHPUnit X.X.X by Sebastian Bergmann.

Configuration read from /home/user/Documents/projects/my_tdd_project/phpunit.xml.dist

E

Time: 647 ms, Memory: 68.50Mb

There was 1 error:

1) Tests\AppBundle\Controller\DefaultControllerTest::testIndex
PHPUnit_Framework_Exception: “Unexpected "}".500 Internal Server Error - Twig_Error_Syntax”

/home/user/Documents/projects/my_tdd_project/tests/AppBundle/Controller/DefaultControllerTest.php:21

FAILURES!                          
Tests: 1, Assertions: 1, Errors: 1.

Voilà qui est nettement mieux. Il reste cependant quelques petits couacs. Premièrement, nous avons perdu le message de l’assertion qui a mené à l’échec du test et nous ne levons plus la même exception. Deuxièmement, notre test est devenu moins clair et devoir entourer nos assertions de blocs try/catch est très embêtant. Il nous faut une solution plus élégante, qui n’affecte pas l’écriture de nos tests. Et bien il se trouve que PHPUnit possède une méthode onNotSuccessfulTest() qui est appelée systématiquement quand un test échoue. Celle-ci prend en paramètre toute exception qui aurait été levée durant l’exécution du test. C’est exactement ce qu’il nous faut. Redéfinissons cette méthode dans notre cas de test :

<?php

namespace Tests\AppBundle\Controller;

use Exception;
use PHPUnit_Framework_Exception;
use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class DefaultControllerTest extends WebTestCase
{
    /**
     * @var Client
     */
    protected $client;

    protected function setUp()
    {
        $this->client = static::createClient();
    }

    public function testIndex()
    {
        $crawler = $this->client->request('GET', '/');

        $this->assertEquals(200, $this->client->getResponse()->getStatusCode());
        $this->assertContains('Welcome to Symfony', $crawler->filter('#container h1')->text());
    }

    /**
     * {@inheritdoc}
     */
    protected function onNotSuccessfulTest(Exception $exception)
    {
        $message = preg_replace('#\s{2,}#', '', $this->client->getCrawler()->filter('.text-exception')->text());
        if ($message) {
            throw new PHPUnit_Framework_Exception($message);
        }
        throw $exception;
    }
}

Vous remarquerez que nous avons également redéfini la méthode setUp() afin d’initialiser systématiquement la variable $client au début de chaque test. Si vous lancez PHPUnit, vous aurez la même sortie qu’avant. Le test est devenu plus clair mais il nous manque toujours encore le message de l’exception originale. Nous allons utiliser la réflexion pour lever la même exception que l’originale, enrichie du nouveau message.

/**
* {@inheritdoc}
*/
protected function onNotSuccessfulTest(Exception $exception)
{
   $message = preg_replace('#\s{2,}#', '', $this->client->getCrawler()->filter('.text-exception')->text());
   if ($message) {
       $exceptionClass = get_class($exception);
       throw new $exceptionClass($exception->getMessage() . ' | ' . $message);
   }
   throw $exception;
}

Ainsi, nous sommes sûr de lever l’exception originale (dans notre cas il s’agit actuellement d’une PHPUnit_Framework_ExpectationFailedException) et nous avons ajouté au message, l’erreur se trouvant sur la page. PHPUnit devrait vous rapporter ceci :

PHPUnit X.X.X by Sebastian Bergmann.

Configuration read from /home/user/Documents/projects/my_tdd_project/phpunit.xml.dist

F

Time: 661 ms, Memory: 68.25Mb

There was 1 failure:

1) Tests\AppBundle\Controller\DefaultControllerTest::testIndex
Failed asserting that 500 matches expected 200. | “Unexpected "}".500 Internal Server Error - Twig_Error_Syntax”

/home/user/Documents/projects/my_tdd_project/tests/AppBundle/Controller/DefaultControllerTest.php:38

FAILURES!                            
Tests: 1, Assertions: 1, Failures: 1.

C’est nettement mieux. La sortie est enfin complète. Il ne nous reste plus qu’à effectuer un petit refactoring pour que nous n’ayons pas à réécrire onNotSuccessfulTest() pour chacun de nos cas de tests. Nous allons la placer dans une classe dont hériterons tous nos cas de tests afin d’enrichir notre framework de test. Créez un fichier tests/AppBundle/Framework/WebTestCase.php et placez-y le contenu suivant :

<?php

namespace Tests\AppBundle\Framework;

use Exception;
use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase as TestCase;

class WebTestCase extends TestCase
{
    /**
     * @var Client
     */
    protected $client;

    protected function setUp()
    {
        $this->client = static::createClient();
    }

    /**
     * {@inheritdoc}
     */
    protected function onNotSuccessfulTest(Exception $exception)
    {
        $message = null;
        if ($this->client->getCrawler()) {
            $message = preg_replace('#\s{2,}#', '', $this->client->getCrawler()->filter('.text-exception')->text());
        }
        if ($message) {
            $exceptionClass = get_class($exception);
            throw new $exceptionClass($exception->getMessage() . ' | ' . $message);
        }
        throw $exception;
    }
}

Cette classe se chargera d’automatiquement créer un client web et d’enrichir le message des exceptions levées par les tests. Modifiez maintenant le cas de test DefaultControllerTest pour en hériter :

<?php

namespace Tests\AppBundle\Controller;

use Tests\AppBundle\Framework\WebTestCase;

class DefaultControllerTest extends WebTestCase
{
    public function testIndex()
    {
        $crawler = $this->client->request('GET', '/');

        $this->assertEquals(200, $this->client->getResponse()->getStatusCode());
        $this->assertContains('Welcome to Symfony', $crawler->filter('#container h1')->text());
    }
}

Et voilà, notre test ne contient plus que notre code de test. Lancez PHPunit pour vérifier que tout fonctionne et commitez votre travail avant de continuer 😉

Conclusion

Nous avons vu avec un exemple simple, qu’il est possible d’obtenir rapidement des rapports de tests fonctionnels plus clairs qui facilitent le débugage en cas d’échec. Dans notre cas nous avons utilisé Symfony et PHPUnit, mais des techniques similaires peuvent tout à fait s’appliquer à d’autres outils.

N’oubliez pas un élément essentiel dans votre démarche TDD : améliorez constamment votre framework de test. C’est lui le compagnon qui facilite votre vie de développeur. Traitez vos tests comme votre application et vous gagnerez en sérénité.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *