en PHP/Symfony2
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');
+ 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
- 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
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
Ex:
Ex : DateTest si le service s'appelle Date
AdminExtensionTest si le service s'appelle AdminExtension
Tests/Unit/Services, Tests/Unit/Twig
<?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',
)
);
}
}
/**
* @dataProvider formatArrayDayPartProvider
*/
public function testFormatArrayDayPartWithDataProvider($input, $output) {
$this->assertEquals($this->extension->formatArrayDayPart($input), $output);
}
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),
);
}
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')
),
);
}
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.
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++;
}
}
=> 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
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
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);
}
}
}
Class AccountTest extends \PHPUnit_Framework_TestCase
{
public function testWithdrawNegativeAmount()
{
$this->setExpectedException('Viseo\Bundle
\BankBundle\Business\AccountException');
$account = new Account(500);
$account->withdraw(-1000);
}
}
• 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
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',
)
);
}
}
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 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'))
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.
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.
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);
}
}
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()));
}
}
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 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));
}
}
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;
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())
);
}
}
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/
Le cycle de développement préconisé par la TDD comporte cinq étapes:
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
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)
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' => '',
)
), '');
}
}
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
namespace AppBundle\Service;
class JourneyService
{
public function getCodeLine(array $section)
{
$code_line = '';
if ('' == $section['code_line'] && '' == $section['headsign']) {
$code_line = '';
}
return $code_line;
}
}
phpunit -c app src/AppBundle/Tests/Unit/Service/JourneyServiceTest.php
.
Time: 67 ms, Memory: 6.25Mb
OK (1 test, 1 assertion)
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');
}