Introduction au test logiciel/Tests unitaires/PHPUnit

PHPUnit est utilisé dans un certain nombre de frameworks connus pour réaliser des tests unitaires. Sa documentation en anglais est disponible au format PDF[1].

Installation

modifier

Via composer

modifier
 composer require --dev phpunit/phpunit ^8

Via wget

modifier

Une fois le .phar téléchargé depuis le site officiel[2], le copier dans le dossier où il sera toujours exécuté. Exemple :

Unix-like

modifier
wget https://phar.phpunit.de/phpunit-8.phar
mv phpunit.phar /usr/local/bin/phpunit
chmod +x phpunit.phar

Windows

modifier
  1. Ajouter à la variable d'environnement PATH, le dossier où se trouve le fichier (ex : ;C:\bin).
  2. Créer un fichier exécutable à côté (ex : C:\bin\phpunit.cmd) contenant le code : @php "%~dp0phpunit.phar" %*.

Par ailleurs, le code source de cet exécutable est sur GitHub[3].

Test de l'installation :

phpunit --version

Utilisation

modifier

Il faut indiquer au programme les dossiers contenant des tests dans le fichier phpunit.xml.dist. Exemple sur Symfony[4] :

<?xml version="1.0" encoding="UTF-8"?>

<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/9.0/phpunit.xsd"
         backupGlobals="false"
         colors="true"
         bootstrap="vendor/autoload.php"
>
    <php>
        <ini name="error_reporting" value="-1" />
        <server name="KERNEL_CLASS" value="AppKernel" />
        <env name="SYMFONY_DEPRECATIONS_HELPER" value="weak" />
    </php>

    <testsuites>
        <testsuite name="Project Test Suite">
            <directory suffix=".php">./tests</directory>
            <exclude>tests/FunctionalTests/*</exclude>
        </testsuite>
    </testsuites>

    <listeners>
        <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
    </listeners>

</phpunit>

Si on a plusieurs dossiers à exclure, mieux vaut sélectionner plutôt ceux à traiter :

            <directory suffix=".php">tests/UnitTests</directory>
            <directory suffix=".php">tests/FunctionalTests/QuickTests</directory>

Ensuite, pour tester tous les fichiers du dossier /test et ignorer ceux de /src, il suffit de lancer :

./bin/phpunit

 

Pour exclure un seul fichier ou une seule méthode des tests, lui mettre $this->markTestIncomplete('This test has to be fixed.');

Options

modifier

Ce .xml donne des options par défaut qui peuvent être modifiées dans les commandes. Par exemple stopOnFailure="true" dans la balise <phpunit> peut être par défaut, et phpunit --stop-on-failure seulement pour ce lancement.

Choisir les tests à lancer

modifier

Si les tests sont longs et qu'on ne travaille que sur un seul fichier, une seule classe ou une seule méthode, on peut demander à ne tester qu'elle en précisant son nom (ce qui évite d'afficher des dumps que l'on ne souhaite pas voir lors des autres tests) :

bin/phpunit tests/MaClasseTest.php
bin/phpunit --filter=MaClasseTest
bin/phpunit --filter=MaMethodeTest

Si une méthode dépend d'une autre, on ne n'appeler que ces deux-là (peu importe l'ordre) :

bin/phpunit --filter='test1|test2'

Détails de chaque test

modifier

Pour afficher les noms des tests et le temps qu'ils prennent, utiliser : --testdox

Rapports

modifier

Outre les résultats des tests, on peut avoir besoin de mesurer et suivre leur complétude, via le taux de couverture de code. PhpUnit permet d'afficher ce taux en installant Xdebug et en activant son option xdebug.mode = coverage.

Le calcul du taux de couverture peut ensuite être obtenu avec :

bin/phpunit --coverage-text

 

Certains fichiers ne peuvent en aucun cas être testés, et doivent donc être exclus du calcul du taux de couverture dans phpunit.xml.dist. Par exemple pour les migrations et fixtures :

    <exclude>
      <directory suffix=".php">src/Migrations/</directory>
      <file>src/DataFixtures/AppFixtures.php</file>
    </exclude>
Dans un fichier
modifier

Le résultat des tests peut être sauvegardé dans un fichier de rapport XML avec l'option --log-junit phpunit.logfile.xml.

L'ajout de l'option --coverage-html reports/ générera un rapport du taux de couverture des tests en HTML (mais d'autres formats sont disponibles tels que l'XML ou le PHP), dans le dossier "reports" (créé automatiquement).

Exemple récupérable par l'outil d'analyse de code SonarQube :

phpunit --coverage-clover phpunit.coverage.xml --log-junit phpunit.logfile.xml

Écriture des tests

modifier
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class SuiteDeTests1 extends TestCase
{
    /** @var MockObject */
    private $monMock1;

    protected function setUp(): void
    {
        // Création des mocks et instanciation de la classe à tester...
        $this->monMock1 = $this->getMockBuilder(maMockInterface::class)->getMock();
    }

    protected function tearDown(): void
    {
        // Libération des ressources après les tests...
    }

    public static function setUpBeforeClass(): void
    {
        // Pour réinitialiser une connexion déclarée dans setUp()
    }

    public static function tearDownAfterClass(): void
    {
        // Pour fermer une connexion déclarée dans setUp()
    }

    protected function test1()
    {
        // Lancement du premier test...
        $this->assertTrue($condition);
    }
}

La classe de test PHPUnit propose des dizaines d'assertions différentes.

$this->fail()

modifier

PhpUnit distingue pour chaque test, les erreurs (ex : division par zéro) des échecs (assertion fausse). Dans le cas où on on souhaiterait transformer les erreurs en échecs, on peut utiliser $this->fail() :

        try {
            $response = $this->MonTestEnErreur();
        } catch (\Throwable $e) {
            $this->fail($e->getMessage());
        }

MockObject

modifier

Les mocks permettent de simuler des résultats de classes existantes[5].

willReturn()

modifier

Par exemple, pour simuler le résultat de deux classes imbriquées (en appelant la méthode d'une méthode), on leur crée une méthode de test chacune :

    public function mainTest()
    {
        $this->monMock1
            ->expects($this->once())
            ->method('MaMéthode1')
            ->willReturn($this->mockProvider())
        ;

        $this->assertEquals(null, $this->monMock1->MaMéthode1()->MaMéthode2());
    }

    private function mockProvider()
    {
        $monMock = $this
            ->getMockBuilder('MaClasse1')
            ->getMock()
        ;
        $monMock->method('MaMéthode2')
            ->willReturn('MonRésultat1')
        ;

        return $monMock;
    }

 

Pour qu'une méthode de mock réalise un "set" quand elle est appelée, il ne faut pas le faire directement dans le willReturn, auquel cas il s'effectue lors de sa définition, mais dans un callback. Ex :

        $monMock->method('MaMéthode3')
            ->will($this->returnCallback(function($item) use ($quantity) {
                return $item->setQuantity($quantity);
            }))
        ;

willReturnArgument()

modifier

Renvoie l'argument dont le numéro est en paramètre.

willThrowException()

modifier

Pour qu'un mock simule une erreur. Ex :

        $monMock->method('MaMéthode3')->willThrowException(new Exception());

expects()

modifier

Dans l'exemple précédent, expects() est un espion qui compte le nombre de passage dans la méthode, et le test échoue si ce résultat n'est pas 1. Ses valeurs possibles sont :

  • $this->never() : 0.
  • $this->once() : 1.
  • $this->exactly(x) : x.
  • $this->any().

De plus, on trouve $this->at() pour définir un comportement dépendant du passage.

onConsecutiveCalls

modifier

Si la valeur retournée par le mock doit changer à chaque appel, il faut remplacer willReturn() par onConsecutiveCalls().

Exemple :

    $this->enumProvider->method('getEnumFromVariable')
        ->will($this->onConsecutiveCalls(
            ProductStatusEnum::ON_LINE,
            OrderStatusEnum::VALIDATED
        ));
    ;

Cette méthode permet de définir les paramètres avec lesquels doit être lancé une méthode mock. Ex :

    $this->enumProvider->method('getEnumFromVariable')
        ->with($this->equalTo('variable 1'))

disableOriginalConstructor()

modifier

Cette méthode s'emploie quand il est inutile de passer par le constructeur du mock.

expectException()

modifier

S'utilise quand le test unitaire doit provoquer une exception dans le code testé (ex : s'il contient un throw).

        $this->expectException(Exception::class);
        $monObjetTesté->method('MaMéthodeQuiPète');

Si au contraire on veut vérifier que le code testé ne renvoie pas d'exception, on peut le lancer suivi d'une incrémentation des assertions :

        $monObjetTesté->method('MaMéthodeSansErreur');
        $this->addToAssertionCount(1);

Attributs

modifier

PHPUnit depuis sa version 10 offre plusieurs attributs pour influencer les tests. Exemples :

  • #[DataProvider()] : indique un tableau d'entrées et de sorties attendues lors d'un test[6].
  • #[Depends()] : spécifie qu'une méthode récupère le résultat d'une autre (son return) dans ses arguments.

Annotations

modifier

PHPUnit offre plusieurs annotations pour influencer les tests[7]. Exemples :

  • @covers : renseigne la méthode testée par une méthode de test afin de calculer le taux de couverture du programme par les tests.
  • @uses : indique les classes instanciées par le test.
  • @dataProvider : indique un tableau d'entrées et de sorties attendues lors d'un test[8].
  • @depends : spécifie qu'une méthode récupère le résultat d'une autre (son return) dans ses arguments. Si elle appartient à un autre fichier, il faut renseigner son namespace : @depends App\Tests\FirstTest::testOne. Et comme PhpUnit exécute les tests dans l'ordre alphabétique des fichiers, il faut que le test se trouve après celui dont il dépend.

JavaScript

modifier

En PHP, Selenium peut s'interfacer avec PHPUnit[9] pour tester du JavaScript.

Avec Symfony, il existe aussi Panther[10].

Symfony

modifier

Pour récupérer une variable d'environnement ou un service dans un test unitaire Symfony, il faut passer par setUpBeforeClass() pour booter le kernel du framework :

    /** @var string */
    private static $maVariableYaml;
    /** @var Translator */
    private static $translator;

    public static function setUpBeforeClass(): void
    {
        $kernel = static::createKernel();
        $kernel->boot();

        self::$maVariableYaml = $kernel->getContainer()->getParameter('ma_variable');
        self::$translator = $kernel->getContainer()->get('translator');
    }

 

Seuls les services publics seront accessibles. Mais il est possible de créer des alias publics des services accessibles uniquement en environnement de test grâce au fichier de config services_test.yaml.

Test fonctionnel :

    public function testPost(): void
    {
        $route = '/api/test';
        $body = [
            'data' =>
                ['post_parameter_1' => 'value 1'],
        ];

        static::$client->request('POST', $route, [], [], [], json_encode($body));
        $response = static::$client->getResponse();
        $this->assertInstanceOf(JsonResponse::class, $response);
        $this->assertTrue($response->isSuccessful(), $response);

        $content = json_decode($response->getContent(), true);
        $this->assertNotEmpty($content['message'], json_encode($content));
    }

Références

modifier