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)
Tests unitaires
By Jean-Pierre Saulnier
Tests unitaires
- 3,716