• 12 heures
  • Moyenne

Ce cours est visible gratuitement en ligne.

Ce cours est en vidéo.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

J'ai tout compris !

Mis à jour le 18/03/2019

TP : tester une classe contenant de nombreuses dépendances

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Vous avez manipulé des doublures (mocks) dans le chapitre précédent : il est temps de vous confronter à une classe contenant du code complexe. Ne vous inquiétez pas, nous allons nous exercer ensemble ! ^^

Contexte

Pour ce chapitre, vous allez tester une application dans laquelle j'ai implémenté une authentification avec Github. Eh oui, rassurez-vous, je vous fournis l'application fonctionnelle. Le but est de tester le code après tout !

Je vous invite à la télécharger ici : télécharger l'application à tester.

Tester une méthode de classe complexe

Voici la classe que vous allez tester. Vous pouvez déjà repérer une méthode dont la logique métier demande de nombreuses dépendances :

<?php

namespace AppBundle\Security;

use AppBundle\Entity\User;
use GuzzleHttp\Client;
use JMS\Serializer\Serializer;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class GithubUserProvider implements UserProviderInterface
{
    private $client;
    private $serializer;

    public function __construct(Client $client, Serializer $serializer)
    {
        $this->client = $client;
        $this->serializer = $serializer;
    }

    public function loadUserByUsername($username)
    {
        $response = $this->client->get('https://api.github.com/user?access_token='.$username);
        $result = $response->getBody()->getContents();

        $userData = $this->serializer->deserialize($result, 'array', 'json');

        if (!$userData) {
            throw new \LogicException('Did not managed to get your user info from Github.');
        }

        $user = new User(
            $userData['login'],
            $userData['name'],
            $userData['email'],
            $userData['avatar_url'],
            $userData['html_url']
        );

        return $user;
    }

    // …
}

Première étape : Déterminer ce qu'il faut tester

Avec ce code, vous êtes dans une situation courante : vous arrivez sur un projet et lisez du code que vous n'avez pas écrit vous-même. Il va donc vous falloir parcourir le code et identifier les éléments à tester.

Déterminer les dépendances

Vous voyez une classe avec deux dépendances :

  • une instance de  GuzzleHttp\Client ;

  • et une instance de   JMS\Serializer\Serializer.

Comprendre ce que la méthode à tester doit retourner

Rappelez-vous, pour déterminer le nombre de tests à écrire, l'un des indicateurs est de détecter les sorties avec les  return ou encore les exceptions éventuellement levées (throw).

Dans le code de la méthode  loadUserByUsername ci-dessus, il y a deux issues possibles :

  • soit retourner un objet  $user de type  AppBundle\Entity\User ;

  • soit lancer une exception de type  LogicException si la variable  $userData est vide.

Vous aurez donc deux tests à écrire.

Deuxième étape : Initier le test en exécutant le code à tester retournant un objet User

Je vous propose de commencer par le test permettant de s'assurer qu'un objet $user de type AppBundle\Entity\User est bien retourné.

Commencez par créer une classe de test GithubUserProviderTest dans le dossier tests/AppBundle/Security (rappelez-vous, c'est la préconisation des bonnes pratiques) :

<?php

namespace Tests\AppBundle\Security;

use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    public function testLoadUserByUsernameReturningAUser()
    {

    }
}

Dans un premier temps, il faut instancier la classe GithubUserProvider. Nous avons vu que le constructeur demande deux dépendances. Vous avez le choix entre faire appel aux constructeurs de ces classes ou faire appel à PHPUnit pour créer des doublures.

Doublure ou pas doublure pour la dépendance GuzzleHttp\Client ?

La dépendance GuzzleHttp\Client est utilisée dans le code pour envoyer une requête à Github. Cette requête va récupérer des informations de l'utilisateur qui possède l'access token passé en paramètre de la méthode loadUserByUsername (via la variable $username).

Si vous vous souvenez de ce que je vous expliquais au chapitre précédent, vous connaissez la méthode à adopter. Il est préférable de ne pas dépendre de Github lors du lancement de nos tests unitaires, donc il faut doubler cet objet afin de maîtriser ce qu'il nous retournera lors de la requête HTTP GET.

Doublure ou pas doublure pour la dépendance JMS\Serializer\Serializer ?

Lorsque l'on lit le constructeur de la classe Serializer, on peut constater qu'il va être bien difficile de l'instancier nous-mêmes. o_O

La meilleure solution, c'est donc que vous le doubliez aussi ! À l'appel de la méthode deserialize, il faudra vous assurer que vous récupérez bien un tableau avec toutes les informations d'un utilisateur.

Exécuter le code à tester

Il est temps de créer les doublures des dépendances nécessaires à l'instanciation de la classe GithubUserProvider, puis d'appeler la méthode à tester :

<?php

namespace Tests\AppBundle\Security;

use AppBundle\Entity\User;
use AppBundle\Security\GithubUserProvider;
use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    public function testLoadUserByUsernameReturningAUser()
    {
        $client = $this->getMockBuilder('GuzzleHttp\Client')
            ->disableOriginalConstructor()
            ->getMock();

        $serializer = $this
            ->getMockBuilder('JMS\Serializer\Serializer')
            ->disableOriginalConstructor()
            ->getMock();

        $githubUserProvider = new GithubUserProvider($client, $serializer);
        $user = $githubUserProvider->loadUserByUsername('an-access-token');
    }
}

Les doublures permettent de remplir le contrat nécessaire à la création de votre GithubUserProvider.

Quelques remarques quant au code ci-dessus :

  • Via la méthode disableOriginalConstructor(), vous vous assurez que PHPUnit ne fait pas appel au constructeur des classes que vous cherchez à doubler.

  • Vous passez les doublures au constructeur de la classe que vous allez utiliser pour l'exécution du code à tester.

  • Peu importe ce que vous passez comme paramètre à la méthode loadUserByUsername (il s'agit de l'access token communiqué à Github). Étant donné que vous ne ferez pas appel à Github, cela n'a pas d'incidence.

  • Vous récupérez le résultat de la méthode loadUserByUsername. En effet, c'est sur le contenu de la variable $user que vous ferez vos assertions.

Lancez le test pour voir ce qu'il en est :

Lancement du test
Lancement du test

Et… comme vous pouvez le voir, il y a une erreur. Pas de panique ! ^^ Nous allons la lire ensemble :

1) Tests\AppBundle\Security\GithubUserProviderTest::testLoadUserByUsernameWithUserAlreadyInDatabase
Error: Call to a member function getBody() on null

PHPUnit vous indique que la méthode getBody est appelée sur un élément null. Vous allez devoir vous référer au code original pour comprendre à quel moment la méthode getBody est appelée :

<?php

namespace AppBundle\Security;

// …

class GithubUserProvider implements UserProviderInterface
{
    // …

    public function loadUserByUsername($username)
    {
        $response = $this->client->get('https://api.github.com/user?access_token='.$username);
        $result = $response->getBody()->getContents();

        // …
    }

    // …
}

La méthode getBody est appelée sur la variable $response à la ligne 14 du code. Le contenu de cette dernière est obtenu grâce à l'appel de la méthode get sur l'objet $client. Rappelez-vous, l'objet $client est une doublure.

Vous devez donc indiquer ce que la méthode get devra retourner pour que la variable ne soit pas nulle. Il faut doubler l'objet que nous sommes censés récupérer grâce à la méthode get de l'objet $client. Pour connaître le type que nous devons doubler, il vous suffit de lire le code de la classe GuzzleHttp\Client.

<?php

namespace Tests\AppBundle\Security;

use AppBundle\Entity\User;
use AppBundle\Security\GithubUserProvider;
use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    public function testLoadUserByUsernameReturningAUser()
    {
        $client = $this->getMockBuilder('GuzzleHttp\Client')
            ->disableOriginalConstructor()
            ->setMethods(['get'])  // Nous indiquons qu'une méthode va être redéfinie.
            ->getMock();

        $serializer = $this
            ->getMockBuilder('JMS\Serializer\Serializer')
            ->disableOriginalConstructor()
            ->getMock();
            
        $response = $this
            ->getMockBuilder('Psr\Http\Message\ResponseInterface')
            ->getMock();
        $client->method('get')->willReturn($response);

        $githubUserProvider = new GithubUserProvider($client, $serializer);
        $user = $githubUserProvider->loadUserByUsername('an-access-token');
    }
}

Le but de code est de créer la doublure pour l'objet $response (lignes 21 à 23) et indiquer que la méthode get doit retourner l'objet $response (ligne 24).

Exécutez le test à nouveau :

Lancement du test
Lancement du test

Encore une erreur. :( Ne perdez pas patience, on avance ! L'erreur est différente :

1) Tests\AppBundle\Security\GithubUserProviderTest::testLoadUserByUsernameReturningAUser
Error: Call to a member function getContents() on null

Cette fois-ci, PHPUnit vous indique que la méthode getContents est appelée sur un élément null. En effet, nous n'avons pas indiqué le comportement que devrait avoir la méthode getBody.

D'après la documentation, la méthode getBody retourne un objet de type Psr\Http\Message\StreamInterface.

Allez, c'est parti pour compléter nos tests !

<?php

namespace Tests\AppBundle\Security;

use AppBundle\Entity\User;
use AppBundle\Security\GithubUserProvider;
use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    public function testLoadUserByUsernameReturningAUser()
    {
        // …
        
        $streamedResponse = $this
            ->getMockBuilder('Psr\Http\Message\StreamInterface')
            ->getMock();
        $response->method('getBody')->willReturn($streamedResponse);

        $githubUserProvider = new GithubUserProvider($client, $serializer);
        $user = $githubUserProvider->loadUserByUsername('an-access-token');
    }
}

Il vous suffit d'ajouter un double ayant pour type  Psr\Http\Message\StreamInterface et d'indiquer à PHPUnit que lorsque la méthode getBody sera exécutée, il faut que ce soit cette doublure qui soit renvoyée.

Lancez à nouveau le test pour vérifier :

Lancement du test
Lancement du test

Vous y êtes presque !

Cette fois-ci, vous êtes dans le cas où aucune information n'est contenue dans la variable $userData après désérialisation.

La solution est de compléter le comportement de la doublure du serializer afin qu'il retourne un tableau avec des informations factices. En avant !

<?php

namespace Tests\AppBundle\Security;

use AppBundle\Entity\User;
use AppBundle\Security\GithubUserProvider;
use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    public function testLoadUserByUsernameReturningAUser()
    {
        // …

        $userData = ['login' => 'a login', 'name' => 'user name', 'email' => 'adress@mail.com', 'avatar_url' => 'url to the avatar', 'html_url' => 'url to profile'];
        $serializer->method('deserialize')->willReturn($userData);

        $githubUserProvider = new GithubUserProvider($client, $serializer);
        $user = $githubUserProvider->loadUserByUsername('an-access-token');
    }
}

Lancez à nouveau le test…

Plus d'erreur dans le test
Plus d'erreur dans le test

Ça y est ! :soleil:

Néanmoins, pour l'instant, vous ne testez rien. Pour y remédier, vous pouvez commencer par ajouter des attentes (expectations) pour chacun des stubs créés :

<?php

namespace Tests\AppBundle\Security;

use AppBundle\Entity\User;
use AppBundle\Security\GithubUserProvider;
use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    public function testLoadUserByUsernameReturningAUser()
    {
        $client = $this->getMockBuilder('GuzzleHttp\Client')
            ->disableOriginalConstructor()
            ->setMethods(['get'])
            ->getMock();

        $serializer = $this
            ->getMockBuilder('JMS\Serializer\Serializer')
            ->disableOriginalConstructor()
            ->getMock();

        $response = $this
            ->getMockBuilder('Psr\Http\Message\ResponseInterface')
            ->getMock();
        $client
            ->expects($this->once()) // Nous nous attendons à ce que la méthode get soit appelée une fois
            ->method('get')
            ->willReturn($response)
            ;

        $streamedResponse = $this
            ->getMockBuilder('Psr\Http\Message\StreamInterface')
            ->getMock();
        $response
            ->expects($this->once()) // Nous nous attendons à ce que la méthode getBody soit appelée une fois
            ->method('getBody')
            ->willReturn($streamedResponse);

        $userData = ['login' => 'a login', 'name' => 'user name', 'email' => 'adress@mail.com', 'avatar_url' => 'url to the avatar', 'html_url' => 'url to profile'];
        $serializer
            ->expects($this->once()) // Nous nous attendons à ce que la méthode deserialize soit appelée une fois
            ->method('deserialize')
            ->willReturn($userData);

        $githubUserProvider = new GithubUserProvider($client, $serializer);
        $user = $githubUserProvider->loadUserByUsername('an-access-token');
    }
}

En lançant le test, vous aurez logiquement trois nouvelles assertions :

Trois nouvelles assertions avec les attentes (expectations)
Trois nouvelles assertions avec les attentes (expectations)

Assurez-vous maintenant que l'objet retourné par la méthode testée contient bien toutes les informations attendues :

<?php

namespace Tests\AppBundle\Security;

use AppBundle\Entity\User;
use AppBundle\Security\GithubUserProvider;
use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    public function testLoadUserByUsernameReturningAUser()
    {
        // …
        
        $githubUserProvider = new GithubUserProvider($client, $serializer);
        $user = $githubUserProvider->loadUserByUsername('an-access-token');


        $expectedUser = new User($userData['login'], $userData['name'], $userData['email'], $userData['avatar_url'], $userData['html_url']);
        
        $this->assertEquals($expectedUser, $user);
        $this->assertEquals('AppBundle\Entity\User', get_class($user));
    }
}

Les deux assertions s'assurent que les informations contenues dans l'objet retournées par la méthode loadUserByUsername sont correctes et que le type de l'objet est bien AppBundle\Entity\User.

Troisième étape : Écrire le test couvrant le cas où aucune information ne serait récupérée après la requête http à Github

Il vous reste encore un cas à couvrir dans la méthode loadUserByUsername. Souvenez-vous, dans le cas où $userData ne contient pas d'informations, une exception est levée.

Vous avez toutes les clés pour y arriver ! Essayez d'y arriver seuls avant de regarder la correction. :)

Voici la correction :

<?php

namespace Tests\AppBundle\Security;

// …
use AppBundle\Security\GithubUserProvider;

class GithubUserProviderTest extends TestCase
{
    // …
    public function testLoadUserByUsernameThrowingException()
    {
        $client = $this->getMockBuilder('GuzzleHttp\Client')
            ->disableOriginalConstructor()
            ->setMethods(['get'])
            ->getMock();

        $serializer = $this
            ->getMockBuilder('JMS\Serializer\Serializer')
            ->disableOriginalConstructor()
            ->getMock();

        $response = $this
            ->getMockBuilder('Psr\Http\Message\ResponseInterface')
            ->getMock();
        $client
            ->expects($this->once())
            ->method('get')
            ->willReturn($response)
        ;

        $streamedResponse = $this
            ->getMockBuilder('Psr\Http\Message\StreamInterface')
            ->getMock();
        $response
            ->expects($this->once())
            ->method('getBody')
            ->willReturn($streamedResponse);

        $serializer
            ->expects($this->once())
            ->method('deserialize')
            ->willReturn([]);

        $this->expectException('LogicException');

        $githubUserProvider = new GithubUserProvider($client, $serializer);
        $githubUserProvider->loadUserByUsername('an-access-token');
    }
}

Il suffit de faire en sorte que le double (stub) du serializer retourne un tableau vide et le tour est joué !

Quatrième étape : Factoriser le code

Vous avez remarqué que bon nombre des doublures sont utilisées dans les deux tests unitaires. Il est possible de les initialiser à chaque début de test grâce à la méthode setUp.

SetUp

La méthode setUp est une méthode provenant de la classe PHPUnit_Framework_TestCase, que l'on peut surcharger pour exécuter des instructions avant chaque test de la classe.

Vous allez donc faire en sorte que toutes les doublures utilisées dans les tests unitaires soient initialisées à chaque début de test (méthode de test) :

<?php

namespace Tests\AppBundle\Security;

use AppBundle\Entity\User;
use AppBundle\Security\GithubUserProvider;
use PHPUnit\Framework\TestCase;

class GithubUserProviderTest extends TestCase
{
    private $client;
    private $serializer;
    private $streamedResponse;
    private $response;

    public function setUp()
    {
        $this->client = $this->getMockBuilder('GuzzleHttp\Client')
            ->disableOriginalConstructor()
            ->setMethods(['get'])
            ->getMock();

        $this->serializer = $this
            ->getMockBuilder('JMS\Serializer\Serializer')
            ->disableOriginalConstructor()
            ->getMock();

        $this->streamedResponse = $this
            ->getMockBuilder('Psr\Http\Message\StreamInterface')
            ->getMock();

        $this->response = $this
            ->getMockBuilder('Psr\Http\Message\ResponseInterface')
            ->getMock();
    }

    public function testLoadUserByUsernameReturningAUser()
    {
        $this->client
            ->expects($this->once())
            ->method('get')
            ->willReturn($this->response)
            ;

        $this->response
            ->expects($this->once())
            ->method('getBody')
            ->willReturn($this->streamedResponse);

        $userData = ['login' => 'a login', 'name' => 'user name', 'email' => 'adress@mail.com', 'avatar_url' => 'url to the avatar', 'html_url' => 'url to profile'];
        $this->serializer
            ->expects($this->once())
            ->method('deserialize')
            ->willReturn($userData);

        $githubUserProvider = new GithubUserProvider($this->client, $this->serializer);
        $user = $githubUserProvider->loadUserByUsername('an-access-token');


        $expectedUser = new User($userData['login'], $userData['name'], $userData['email'], $userData['avatar_url'], $userData['html_url']);
        $this->assertEquals($expectedUser, $user);
        $this->assertEquals('AppBundle\Entity\User', get_class($user));
    }

    public function testLoadUserByUsernameThrowingException()
    {
        $this->client
            ->expects($this->once())
            ->method('get')
            ->willReturn($this->response)
        ;

        $this->response
            ->expects($this->once())
            ->method('getBody')
            ->willReturn($this->streamedResponse);

        $this->serializer
            ->expects($this->once())
            ->method('deserialize')
            ->willReturn([]);

        $this->expectException('LogicException');

        $githubUserProvider = new GithubUserProvider($this->client, $this->serializer);
        $githubUserProvider->loadUserByUsername('an-access-token');
    }
}

tearDown

Il est également possible d'intervenir à chaque fin de test (après chaque méthode de test de la classe) grâce à la méthode tearDown.

Pour cela, il vous faut mettre les propriétés à null à nouveau :

<?php

namespace Tests\AppBundle\Security;

// …

class GithubUserProviderTest extends TestCase
{
    // …
    public function tearDown()
    {
        $this->client = null;
        $this->serializer = null;
        $this->streamedResponse = null;
        $this->response = null;
    }
}

Ce qu'il faut retenir

Il faut faire attention à ce que vous doublez : le danger est de ne plus tester le code écrit en maîtrisant tout ce qu'il se passe.

Vous avez pu voir qu'écrire un test unitaire peut demander beaucoup de travail. Ne vous découragez pas au moindre obstacle ! :) Prenez le temps de comprendre le code exécuté, c'est un excellent moyen de découvrir une application que vous n'avez pas écrite vous-même, de vous replonger dans du code que vous avez écrit il y a longtemps, ou encore de lire et comprendre le code des librairies tierces que vous utilisez.

Nous en avons fini avec cette première partie ! Après le quiz, rendez-vous au premier chapitre de la partie 2, je vous y donnerai quelques pistes sur les questions que vous devez vous poser lorsque vous écrivez des tests unitaires.

Exemple de certificat de réussite
Exemple de certificat de réussite