Tests unitaires

en PHP/Symfony2

Sommaire

  • Pourquoi faire des tests
  • Les règles de tests
  • Qu'est-ce qui est testable?
  • Pros & Cons
  • Quand écrire des tests unitaires ?
  • Mise en place
  • Structure du fichier (dataProviders)
  • Un exemple de fonction intestable
  • Les principales assertions
  • et pleins d'autres choses amusantes...

Pourquoi faire des tests

Pourquoi faire des tests

  • Ça fait pro
  • Ça fait plaisir au client et au chef de projet
  • Ça fait bien sur un CV
  • En cas de bug, on ne peut pas nous accuser

Pourquoi faire des tests

  • Vérifier les non régressions

  • S'assurer que sa fonction marche dans un grand nombre de cas sans avoir à tester dans un navigateur en changeant les valeurs de son corps

  • Apprendre à découper sa fonction pour qu'elle soit la plus petite possible, sans dépendance, et que ses paramètres en entrée déterminent ce qu’on obtient en sortie

  • Rendre le code plus maintenable et robuste               => éviter, dans les fonctions, les variables type « boite noire » telle que                                     au sein d'une fonction, car non testable

$date = new date('Ymd');

Les règles de tests

Les règles de tests

 

  • Les suites de tests doivent s'exécuter aussi vite que possible

 

 

 

 

 

  • Les tests doivent être isolés les uns des autres
  • Les tests peuvent être répétés à l'infini
  • Les tests peuvent être auto-validés (ils sont ok ou ko)
  • Les tests doivent être faits en temps et en heure

Qu'est-ce qui est testable ?

Qu'est-ce qui est testable ?

  • Les services
  • Les filtres twig
  • Les fonctions liées aux entités
  • Les commandes Symfony2
  • Les exceptions

Pros & cons

Pros

+ avoir un code robuste

+ coder des fonctions petites, testables, sûres

+ trouver et éliminer les bugs

+ éviter régressions quand le code évolue

+ tester l'impact des cas extrêmes (valeurs limites, cas particuliers)

+ avoir confiance en son code

+ documentation du code

Cons

- maintien des tests si la fonction a un retour qui change

- temps pour développer les tests (même si, avec l'habitude, il devient négligeable)

 

- difficulté de savoir quoi tester

- tester implique d'être rigoureux

Quand écrire des tests unitaires?

Quand écrire des tests unitaires?


  • Avant d'implémenter le code (TDD)
  • Après avoir implémenté le code
  • A la découverte d'un bug ou dans un cas très particulier
  • Pour comprendre comment un bout de code fonctionne


Mise en place

Mise en place

Dans le composer.json, il suffit de rajouter la ligne 

                                     dans require-dev et de lancer un 

 

 

phpunit sera ensuite accessible via 

"phpunit/phpunit": "4.8.*"
composer update
bin/phpunit

Mise en place

 

  • Dans le répertoire Tests/Unit/TypeDeClasse

      Ex: 

  • Création d'un fichier qui a toujours la même structure et qu'on appelle, par convention, par le nom de l'entité, du service etc. suffixé de Test.

 

      Ex : DateTest si le service s'appelle Date

             AdminExtensionTest si le service s'appelle                        AdminExtension

Tests/Unit/Services, Tests/Unit/Twig

Structure du fichier

Structure du fichier

<?php

namespace AppBundle\Tests\Unit\Twig;

use AppBundle\Twig\I18nExtension;

class I18NExtensionTest extends \PHPUnit_Framework_TestCase
{
    private $extension = null;

    protected function setUp()
    {
        parent::setUp();
        $this->extension = new I18nExtension();
    }

    public function testFormatArrayDayPart()
    {
        $horaires = array(6 => '6', 7 => '7', 10 => '10');
        $this->assertEquals(
            $this->extension->formatArrayDayPart($horaires), array(
                '6'  => '06h',
                '7'  => '07h',
                '10' => '10h',
            )
        );
    }
}

Au sein de cette classe

 

  • Une fonction test au minimum dont le nom de la fonction est celle que l'on teste (ex : formatArrayDayPart), préfixée du mot clef test. Elle comporte des paramètres si on veut utiliser un data provider, ou aucun paramètre si l'on veut tester directement la fonction

 

  • Un data provider (optionnel) qui permet de lancer une floppée de tests avec des données différentes

 

Avec dataProvider

/**
* @dataProvider formatArrayDayPartProvider
*/
public function testFormatArrayDayPartWithDataProvider($input, $output) {
   $this->assertEquals($this->extension->formatArrayDayPart($input), $output);
}
  • Les paramètres de la fonction permettent d'indiquer le paramètre en entrée($input), ainsi que la valeur attendue en sortie ($output)
  • La PHPdoc permet d'indiquer le nom de la fonction qui fournit les données d'entrée et celles obtenues en sortie (formatArrayDayPartProvider)

 

Avec dataProvider

 

 

  • Un data provider fournit un jeu de données afin de lancer un seul test unitaire plusieurs fois avec différentes valeurs
  • Cette façon de préparer les données permet d'améliorer la visibilité du code

 

public function formatArrayDayPartProvider()
    {
        return array(
            array(valeur entrée 1, valeur attendue en sortie 1),
            array(valeur entrée 2, valeur attendue en sortie 2),
            array(valeur entrée 3, valeur attendue en sortie 3),
        );
    }

Avec dataProvider

Text

public function formatArrayDayPartProvider(){
        return array(
            array(
                array(6 => '6', 7 => '7', 10 => '10'), 
                array(6  => '06h', 7 => '07h', 10 => '10h')
            ),
            array(
                array(8 => '8', 9 => '9', 15 => '15'), 
                array(8 => '08h', 9 => '09h', 15 => '15h')
            ),
            array(
                array(6 => '6-7', 9 => '9-10', 10 => '10-11'), 
                array(6 => '06h-07h', 9  => '09h-10h', 10 => '10h-11h')
            ),
        );
    }

Exécution du test

L'option -c indique à PHPUnit de chercher un fichier de configuration dans le répertoire app/. Si vous êtes curieux de connaître les options de PHPUnit, consultez le fichier app/phpunit.xml.

Dans un projet PHP classique, il suffit de faire phpunit ficherTests.php et les tests s'exécutent.

Un exemple de fonction non testable

public function getPreviousMonths() {
    $format = 'Ymd' ;
    $todayDateTime = new \DateTime($format);
    $tempDate = clone $todayDateTime;
    $todayLessXMonthDateTime = $tempDate->modify('-6 month');
    $cpt  = 0;
    
    while ($todayLessXMonthDateTime <= $todayDateTime) {
         $dateTab['mois'][$cpt]['firstDay'] = $todayLessXMonthDateTime->format('Ym'.'01');
         $todayLessXMonthDateTime->add(new \DateInterval('P1M'));
         $cpt++;
     }
}
  • Aucun paramètre
  • Variables boite noire : $format, - 6 month, 'Ymd'

 

=> passer la date en paramètre, ainsi que le nombre de mois et le format de la date

=> si la date est nulle, la setter à la date du jour

 

=> cela permet de passer une date en paramètre et de vérifier que le premier jour de chaque mois est bien celui qui est six mois antérieur à la date passée en paramètre.

=> le nombre de mois en variable permet de rendre la fonction plus dynamique et souple

 

Les principales assertions

Les principales assertions

assertSame($a, $b) Vérifie que deux valeurs sont égales en type et valeur

 

assertEquals($a, $b) Vérifie que deux valeurs sont égales

 

assertTrue($value) Vérifie que la valeur testée retourne true

 

assertFalse($value) Vérifie que la valeur testée retourne false

 

assertNull($value) Vérifie que la valeur testée retourne null

 

assertContains($value, $array) Vérifie que la valeur testée est dans le tableau $array

 

assertRegex($regex, $string) Vérifie que le string match avec la regex

 

assertCount($count, $array) Vérifie que l’array contient bien le nombre d’éléments donnés par count

Tester les exceptions

Tester les exceptions

namespace AppBundle\Service;

use AppBundle\Entity\AccountInterface;
use AppBundle\AccountException;

class AccountService {
    private $account;

    public function __construct(AccountInterface $account){
        $this->account = $account;
    }

    public function withdraw($amount){
        $amountAfterWithDraw = $this->account->getBalance() - $amount;
        if ($amountAfterWithDraw < 0) {
            throw new AccountException();
        } else {
            $this->account->setBalance($amountAfterWithDraw);
        }
    }
}

Tester les exceptions

Class AccountTest extends \PHPUnit_Framework_TestCase
{
	public function testWithdrawNegativeAmount()
	{
			$this->setExpectedException('Viseo\Bundle
				\BankBundle\Business\AccountException');
			$account = new Account(500);
			$account->withdraw(-1000);
	}
}

Tester les services

Les dépendances des services

 

• Les services ont généralement des dépendances. Pour tester certaines méthodes, cela ne pose pas de problème, mais comment faire lorsque nous avons besoin que la dépendance soit appelée?

 

• Trois solutions:

1) La lourde, mais fonctionnelle

2) Une version optimisée simple : passer des valeurs de tests et instancier la classe du service

3) Une version optimisée complexe: les mocks et les stubs

 

1) La méthode lourde

require_once __DIR__.'/../../../../../../app/AppKernel.php';

class I18NExtensionTest extends \PHPUnit_Framework_TestCase {

    private $extension = null;
    protected $container;

    protected function setUp()
        {
            parent::setUp();
            $this->kernel = new \AppKernel('test', true);
            $this->kernel->boot();
            $this->container = $this->kernel->getContainer();
            $this->extension = new I18nExtension();
            $this->commandeApproService = $this->container->get('commandeAppro');
        }
    
    public function testFormatArrayDayPartHoraires()
        {
            $horaires      = array(6 => '6', 7 => '7', 10 => '10');
            $horairesArray = $this->commandeApproService->formatArrayHoraires($horaires);
            $this->assertEquals(
                $this->extension->formatArrayDayPart($horairesArray),
                array(
                '6' => '06h',
                '7' => '07h',
                '8' => '08h',
                )
            );
        }
}

2) La méthode simple: l'instanciation des services

use App/FrontBundle/Services/CommandeApproService;
use App/FrontBundle/Services/injectedService;

class I18NExtensionTest extends \PHPUnit_Framework_TestCase {
    private $extension;
    private $commandeApproService;

    protected function setUp()
        {
            parent::setUp();
            $this->extension = new I18nExtension();
    	    $this->commandeApproService = new CommandeApproService(new injectedService());
       }
    
    public function testFormatArrayDayPartHoraires()
        {
            $horaires      = array(6 => '6', 7 => '7', 10 => '10');
            $horairesArray = $this->commandeApproService->formatArrayHoraires($horaires);
            $this->assertEquals(
                $this->extension->formatArrayDayPart($horairesArray),
                array(
                '6' => '06h',
                '7' => '07h',
                '8' => '08h',
                )
            );
        }
}

Les stubs et les mocks

3) Une version optimisée complexe: les mocks et les stubs

Les stubs sont des objets qui implémentent les mêmes méthodes que l’objet réel. Ces méthodes ne font rien et sont configurées pour retourner une valeur spécifique:

$stub = $this->getMock('Viseo\[…]\ServiceName');

$stub ->method('nomDeLaMethodeAAppeler')
      ->will($this->returnValue('ValeurEnDur'))

Les stubs

namespace Viseo\BankBundle\Business;

class Account
{
    private $currencyExchange;

    public function setCurrencyExchange(CurrencyExchange $ce)
    {
        $this->currencyExchange = $ce;
    }

    public function withdraw($money, $currency = 'EUR')
    {
        $rate = $this->currencyExchange->getExchangeRate('EUR', $currency);
    
        $this->balance -= $money / $rate;
        
        return $this->balance;
    }
}

Imaginons que nous avons comme dépendance un service (CurrencyExchange) qui nous retourne le taux d'une monnaie par rapport à une autre devise.

Les stubs

class AccountTest extends \PHPUnit_Framework_TestCase
{
    //...
    public function testWithdrawForeignCurrencies()
    {
        $stub = $this->getMock('Viseo\[...]\CurrencyExchange');

        // Configure the stub
        $stub
            ->method('getExchangeRate')
            ->will($this->returnValue(1.20));

        //...
        $account = new Account(1000);
        $account->setCurrencyExchange($stub);
        $balance = $account->withdraw(300, 'USD')
        $this->assertSame(750, $balance);
    }
}

Testons la fonction withDraw en stubant la dépendance CurrencyExchange et en lui signifiant qu'à l'appel de la méthode getExchangeRate, elle doit nous retourner 1,20.

Les mocks

class AccountTest extends \PHPUnit_Framework_TestCase
{
    //...
    public function testWithdrawForeignCurrencies()
    {
        $mock = $this
                    ->getMockBuilder('Viseo\[..]\CurrencyExchange')
                    ->disableOriginalConstructor()
                    ->setMethods(array('getExchangeRate'))
                    ->getMock();

        // Configure the mock
        $mock
            ->expects($this->once())
            ->method('getExchangeRate')
            ->with($this->equalTo('EUR'), $this->equalTo('USD'))
            ->will($this->returnValue(1.20));

        //...
        $account = new Account(1000);
        $account->setCurrencyExchange($mock);
        $balance = $account->withdraw(300, 'USD')

        $this->assertSame(750, $balance);
    }
}
  • Les mocks sont des stubs capables de surcroît de tracer leurs appels (on spécifie uniquement les méthodes qui seront appelées) et de vérifier certaines conditions de ces appels (les exceptions par exemple).

Les retours des mocks

Les retours des mocks

La valeur de retour de la méthode pourra soit être fixée, soit être un des arguments d'appel, soit faire appel à une fonction ou une méthode via un callback, ou enfin lever une exception.

class AccountTest extends \PHPUnit_Framework_TestCase
{
    //...
    public function testWithdrawForeignCurrencies()
    {
        $mock = $this->getMock('Viseo\[...]\CurrencyExchange');

        // Configure the mock
        $mock
            ->expects($this->once())
            ->method('getExchangeRate')
            ->will($this->returnValue(1.20));

        //...
        ->will($this->returnArgument(0));
        ->will($this->returnCallback('FonctionDeCallback'));
        ->will($this->returnCallback(array('Classe', 'Méthode'));
        ->will($this->throwException(new Exception()));
    }
}

La méthode expects des mocks

Les mocks permettent aussi de demander au framework de test d'effectuer des contrôles sur l'utilisation d'un bouchon dans le cadre d'un test particulier. Si l'on veut s'assurer qu'une méthode est appelée trois fois, il suffit d'écrire :

class AccountTest extends \PHPUnit_Framework_TestCase
{
    //...
    public function testWithdrawForeignCurrencies()
    {
        $mock = $this->getMock('Viseo\[...]\CurrencyExchange');

        // Configure the stub
        $mock->expects($this->exactly(3))
             ->method('getExchangeRate')
             ->will($this->returnValue(1.20));
    }
}

La méthode expects des mocks

La méthode expect permet de définir un comportement différent selon les appels. Par exemple, si une méthode doit renvoyer vrai lors de son premier appel et faux pour les suivants :

class AccountTest extends \PHPUnit_Framework_TestCase
{
    //...
    public function testWithdrawForeignCurrencies()
    {
        $mock = $this->getMock('Viseo\[...]\CurrencyExchange');

        // Configure the mock
        $mock->expects($this->at(0))->method('methode1')->will($this->returnValue(true));
        $mock->expects($this->any())->method('methode1')->will($this->returnValue(false));
    }
}

La méthode expects des mocks

On peut utiliser expect avec les méthodes suivantes:

 

any();


never() pour s'assurer qu'une méthode n'est jamais appelée;


atLeastOnce()‏;


once()‏ : appelée une seule fois;


exactly($count)‏ : appelée un nombre exact de fois;


at($index) : comportement lors du nième appel;

Contrôle des paramètres des mocks

On peut également tester les paramètres d'appel. PHPUnit déclenchera une erreur si la méthode est appelée avec un premier paramètre différent de EUR, ou un deuxième qui n'est pas un tableau, ou un troisième qui est nul :

class AccountTest extends \PHPUnit_Framework_TestCase
{
    //...
    public function testWithdrawForeignCurrencies()
    {
        $mock = $this->getMock('Viseo\[...]\MyService');

        // Configure the mock
        $mock->expects($this->any())->method('methodeAAppeler')
              ->with(
                    $this->equalTo('EUR'),
                    $this->isType(PHPUnit_Framework_Constraint_IsType::TYPE_ARRAY),
                    $this->logicalNot($this->isNull())
         );
    }
}

La couverture de code

La couverture de code

Le taux de couverture du code nous donne le nombre de lignes du code qui sont couvertes par les tests unitaires. 

phpunit --coverage-html ./phpunit-report -c app/

Pratique de la TDD

Pratique de la TDD

Le cycle de développement préconisé par la TDD comporte cinq étapes:

  • Ecriture d'un premier test
  • Exécution du test et vérification qu'il échoue (car le code testé n'a pas encore été implémenté)
  • Ecriture de l'implémentation pour faire passer le test
  • Exécution des tests afin de contrôler qu'ils passent
  • Refactorisation du code afin d'en améliorer la qualité mais en conservant les mêmes fonctionnalités

Avantages de la TDD

  • Tests unitaires réellement écrits
  • Satisfaction du développeur d'avoir fait passer tous les tests
  • Clarification des détails de l'interface et du comportement
  • Vérification démontrable, répétable et automatisée
  • Non présence de régression si tests correctement faits

1) Ecriture d'un premier test

namespace AppBundle\Tests\Unit\Service;

use AppBundle\Service\JourneyService;

class JourneyServiceTest extends \PHPUnit_Framework_TestCase
{
    private $extension = null;

    protected function setUp()
    {
        parent::setUp();
    }

    public function testCanGetCreateJourneyService()
    {
        $this->extension = new JourneyService();
    }
}
phpunit -c app src/AppBundle/Tests/Unit/Service/JourneyServiceTest.php 


PHP Fatal error:  Class 'AppBundle\Service\JourneyService' not found in 
/var/www/unit-tests/src/AppBundle/Tests/Unit/Service/JourneyServiceTest.php on line 17

2) Vérification de son échec 

3) Ecriture du code pour faire passer le test 

namespace AppBundle\Service;

class JourneyService
{
    
}
phpunit -c app src/AppBundle/Tests/Unit/Service/JourneyServiceTest.php 

.

Time: 68 ms, Memory: 6.00Mb

OK (1 test, 0 assertions)

4) Vérification que le test passe 

1) Ecriture d'un second test

class JourneyServiceTest extends \PHPUnit_Framework_TestCase
{
    private $extension = null;

    protected function setUp()
    {
        parent::setUp();
        $this->extension = new JourneyService();
    }

    public function testGetEmptyStringIfCodeLineEmptyAndHeadSignEmpty()
    {
        $this->assertEquals($this->extension->getCodeLine(
            array(
                'code_line' => '',
                'headsign' => '',
            )
        ), '');
    }
}

2) Vérification de son échec

phpunit -c app src/AppBundle/Tests/Unit/Service/JourneyServiceTest.php 

PHP Fatal error:  Call to undefined method AppBundle\Service\JourneyService::getCodeLine() 
in /var/www/unit-tests/src/AppBundle/Tests/Unit/Service/JourneyServiceTest.php on line 18

3) Ecriture du code pour faire passer le test 

namespace AppBundle\Service;

class JourneyService
{
    public function getCodeLine(array $section)
    {
        $code_line = '';

        if ('' == $section['code_line'] && '' == $section['headsign']) {
            $code_line = '';
        }

        return $code_line;
    }
}

4) Vérification que le test passe 

phpunit -c app src/AppBundle/Tests/Unit/Service/JourneyServiceTest.php 

.

Time: 67 ms, Memory: 6.25Mb

OK (1 test, 1 assertion)

Et ainsi de suite, en rajoutant des elseif, else, et des tests au fur et à mesure, jusqu'à arriver à l'étape 5 de la refactorisation si nécessaire.

5) refactorisation du code

public function getCodeLine(array $section)
    {
        $code_line = '';

        if ('' == $section['code_line'] && '' == $section['headsign']) {
            $code_line = '';
        } elseif ('' !== $section['code_line'] && '' !== $section['headsign']) {
            $code_line = ' ' . $section['headsign'];
        } elseif ('' == $section['code_line'] && '' !== $section['headsign']) {
            $code_line = ' ' . $section['headsign'];
        } else {
            $code_line = ' ' . $section['code_line'];
        }
        return $code_line;
    }
public function getCodeLine(array $section)
    {
        $code_line = '';

        if ('' == $section['code_line'] && '' == $section['headsign']) {
            $code_line = '';
        } elseif (('' !== $section['code_line'] && '' !== $section['headsign'])
            || ('' == $section['code_line'] && '' !== $section['headsign'])) {
            $code_line = ' ' . $section['headsign'];
        } else {
            $code_line = ' ' . $section['code_line'];
        }
        return $code_line;
    }
    public function testGetheadSignIfCodeLineNotEmptyAndHeadSignNotEmpty()
    {
        $this->assertEquals($this->extension->getCodeLine(
            array(
                'code_line' => '140',
                'headsign' => 'LCA:1-51722',
            )
        ), ' ' . 'LCA:1-51722');
    }

    public function testGetHeadSignIfCodeLineEmptyAndHeadSignNotEmpty()
    {
        $this->assertEquals($this->extension->getCodeLine(
            array(
                'code_line' => '',
                'headsign' => 'LCA:1-51722',
            )
        ), ' ' . 'LCA:1-51722');
    }

    public function testGetCodeLineIfCodeLineNotEmptyAndHeadSignEmpty()
    {
        $this->assertEquals($this->extension->getCodeLine(
            array(
                'code_line' => '140',
                'headsign' => '',
            )
        ), ' ' . '140');
    }

Tests finaux :

Pour résumer

  • Tester est important !
  • Ca demande un peu de temps pendant le développement mais en fait gagner un considérable à sur le long terme
  • Ca augmente la satisfaction du développeur
  • C'est devenu une best practice de développement (TDD)
Made with Slides.com