Tests Unitaires : ma philosophie via un exemple
Je vois souvent des tests dit unitaires chez mes clients, mais qui se connectent à la base de données, font des requêtes réseaux...
Voici une petite présentation de ce que personnellement j'appelle tests unitaires, par l'exemple.
Qu'est ce qu'un test ?
Un test est un morceau de code écrit dans un langage quelconque (pas forcément celui du code de votre application, peut être le langage naturel, un document word...), qui :
- permet de vérifier que le comportement de l'application (ou d'un morceau de l'application) est conforme au comportement attendu
- permet de décrire une façon reproductible de réaliser une vérification précise sur l'application
Il existe plusieurs typologies de tests et outils associés en PHP :
- tests unitaires (ex: PHPUnit, SimpleTest, ...)
- tests fonctionnels (ex: GreenPepper, Fitnesse, ...)
- tests d'intégrations (ex: PHPUnit, ...)
- tests graphiques (ex: Selenium, ...)
- ...
Qu'est ce qu'un test unitaire ?
Définition générale
Un test unitaire est un morceau de code de préférence minimaliste qui a pour but de valider le bon fonctionnement de l'utilisation d'une routine / fonction / méthode dans un CAS PRECIS (et non dans tous les cas d'utilisation possible de la routine).
L'objectif d'un test unitaire étant de tester la logique d'une routine en particulier, il est nécessaire de ne pas être parasité par les éventuels bugs ou mauvaises utilisations de méthodes/fonctions/routines dont la méthode testées aurait besoin.
C'est pour cela qu'on met en oeuvre un mécanisme de bouchonnage des dépendances visant à ne pas déclencher tout code logique qui ne doit pas être testé dans ce test unitaire.
Implémentation commune en PHP
Un test unitaire PHP est un test unitaire écrit en PHP en utilisant dans la plupart des cas un framework de test unitaire de type xUnit.
Le plus populaire étant PHPUnit développé par Sebastian Bergmann depuis plusieurs années et écrit 100% en PHP.
Le principe est simple :
- on écrit une classe de test qui hérite d'une classe du framework
- on écrit autant de méthode à l'intérieur de cette classe que de tests unitaire à réaliser
- en général on écrit une classe de tests pour tester une classe (ou une fonction native) du code applicatif (relation 1<=>1)
- Exemple de fichier de test tests/phpunit/StrlenTest.php :
<?php
class StrlenTest extends PHPUnit_Framework_TestCase
{
public function testStrlenForEmptyStringReturn0()
{
$this->assertEquals(0,strlen(''));
}
public function testStrlenForNotEmptyStringReturnStringLength()
{
$this->assertEquals(3, strlen('php'));
$this->assertEquals(14,strlen('rasmus lerdorf'));
}
public function testStrlenForArrayReturn1()
{
$this->assertEquals(1,strlen(array());
}
}
Implémentation sur un projet exemple
Les tests unitaires dans le projet exemple, sont des tests unitaires dont l'objectif est de bouchonner un maximum les dépendances.
Que ne dois-je pas tester avec les tests unitaires ?
On pourrait rephraser le titre de ce paragraphe en : "Que ne dois-je pas tester/exécuter/déclencher/appeller avec les tests unitaires ?", la réponse serait donc :
- les connexions / requêtes sur la base de données depuis PHP
- les appels réseaux depuis PHP
- l'utilisation de fonctions / classes/méthodes natives depuis PHP (sauf à des fins didactiques)
- l'utilisation d'api / librairies / frameworks / code source non développées expressément dans la classe que je suis en train de tester
- le contenu du bootstrap ou de l'initialisation de votre application
Que dois-je tester avec les tests unitaires ?
Autrement dit quand vous faites un tests unitaires, vous n'avez le droit d'exécuter (au sens "passer par les lignes php") que :
- le code écrit dans la méthode de test
- le contenu (intégral) de la méthode en cours de test
- le contenu des bouchons nécessaires au fonctionnement de la méthode testée
c'est tout.
Aucun appel :
- système/disque
- réseau
- mémoire vive (autre que l'exécution des lignes de code listées ci-dessus)
n'est autorisé dans un test unitaire.
Comme toujours il existe des exceptions :
- vous avez le droit de faire un appel système/disque pour lire un fichier qui contient des données de tests, si ce fichier est utilisé par le bouchon
Comment détecter ce qu'il faut tester de ce qu'il faut bouchonner ?
Prenons un exemple, on souhaite développer une fonctionnalité de listing filtrée des accusés réception de bordereaux de dépôt de plis.
Notre application contacte un webservice pour récupérer la liste des bordereaux de dépôt de plis sur une date précise et tris cette liste par numéros de plis:
Voici notre démarche :
- nous choisissons le nom de notre service: library/Bordereaux/Service.php (classe Bordereaux_Service) :
- nous créons un fichier/classe de test pour notre nouveau service: tests/phpunit/Bordereaux/ServiceTest.php (classe Bordereaux_ServiceTest)
<?php
require_once dirname(__FILE__).'/../../../contexts/phpunit.php';
class Bordereaux_ServiceTest extends PHPUnit_Framework_TestCase
{
/**
* Service testé
*
* @var Bordereaux_Service
*/
private $f;
/**
* Adapter bouchon (mock)
*
* @var Bordereaux_Adapter_Mock
*/
private $m;
public function setUp()
{
$this->f = new Bordereaux_Service();
$this->m = new Bordereaux_Adapter_Mock();
// bouchonne le service testé
$this->f->setAdapter($this->m);
}
}
- nous créons la première méthode de test unitaire pour le cas en erreur (plantage du webservice)
<?php
...
public function testListSortedBordereauxForNetworkErrorThrowException()
{
$this->m->addExpectedResult('listBordereaux',new RuntimeException('This is my network exception'));
try {
$this->f->listSortedBordereaux('2009-08-03');
$this->fail('No network exception thrown');
} catch (RuntimeException $e) {
$this->assertEquals('This is my network exception',$e->getMessage());
}
}
...
* nous créons l'adapter "bouchon" (ou mock) dans le fichier library/Bordereaux/Adapter/Mock.php
<?php
class Bordereaux_Adapter_Mock implements Bordereaux_Adapter_Interface
{
/**
* List of results/exceptions to return/throw
*
* @var array
*/
private $methods;
public function addExpectedResult($method,$result)
{
if (!isset($this->methods[$method])) {
$this->methods[$method] = array();
}
$this->methods[$method][] = $result;
return $this;
}
protected function processMethod($method)
{
if (!isset($this->methods[$method])) {
throw new RuntimeException("No result set for method '$method'");
}
$r = array_shift($this->methods[$method]);
if (true === ($r instanceof Exception)) {
throw $r;
}
return $r;
}
public function listBordereaux($day)
{
return $this->processMethod(__FUNCTION__);
}
}
- nous créons/développons l'adapter par défault qui réalisera le travaille réel d'appeler le webservice dans le fichier library/Bordereaux/Adapter/Default.php
<?php
class Bordereaux_Adapter_Default implements Bordereaux_Adapter_Interface
{
/**
* à noter l'utilisation d'un service Rest_Service, à développer.
* Comme nous en avons besoin maintenant, nous devons arrêter temporairement
* le développement en cours, pour développer (avec la même pratique !)
* ce service. Une fois totalement testé et développé, nous pourrons revenir
* à ce développement.
*
* Il est important de noter ici, que dans la méthode ci-dessous :
* - il est INTERDIT de faire plus d'une ligne de code
* - il est INTERDIT de faire un try/catch ou de lever explicitement une exception
*/
public function listBordereaux($day)
{
return Rest_Service::factory()->get(str_replace(array('%{day}'),array($day),cfgGet('ws.bordereaux.url')));
}
}
- nous créons l'interface des adapter dans le fichier library/Bordereaux/Adapter/Interface.php :
<?php
interface Bordereaux_Adapter_Interface
{
/**
* List all bordereaux picked up from the webservice
*
* @return array of bordereaux
* @throws Exception if network error
*/
public function listBordereaux($day);
}
- nous créons le fichier et la classe (minimale) du service avec la méthode (vide) dans le fichier library/Bordereaux/Service.php
class Bordereaux_Service
{
/**
* Underlying adapter
*
* @var Bordereaux_Adapter_Interface
*/
private $adapter;
public function __construct()
{
$this->adapter = new Bordereaux_Adapter_Default();
}
public function listSortedBordereaux($day)
{
$bordereaux = $this->adapter->listBordereaux($day);
// les deux lignes qui suivent ne seront présentes qu'à partir du 2ième test unitaire
usort($bordereaux,array($this,'compareBordereauxByPliNumber'));
return $bordereaux;
}
/**
* Compare pli id and return -1, 0, 1 (helper method used by usort())
*
* @return integer
*/
public function compareBordereauxByPliNumber($bordereau1,$bordereau2)
{
return -1;
// à partir du deuxième test unitaire nous pourrons faire des tests sur cette méthode, qui contiendra alors (commentez alors la ligne ci-dessus) :
return $bordereau1['pliId'] < $bordereau2['pliId'] ? 1 : ($bordereau1['pliId'] > $bordereau2['pliId'] ? -1 : 0);
}
}
- nous pouvons maintenant faire nos tests unitaires pour les autres cas et notamment le cas nominal qui renvoie des bordereaux précis pour une date précise :
<?php
...
public function testListSortedBordereauxForDateContainingBordereauxReturnBordereauxListAsArray()
{
$expected = json_decode('[{"pliId":"903"},{"pliId":"1001"},{"pliId":"1354"}]',true);
$this->m->addExpectedResult('listBordereaux',json_decode('[{"pliId":"1001"},{"pliId":"903"},{"pliId:"1354"}]',true));
$result = $this->f->listSortedBordereaux('2009-08-03');
$this->assertEquals($expected,$result);
}
}
Dans ces tests unitaires le webservices n'est JAMAIS appelé ! Pourtant nous réalisons quand même des tests et notre application fonctionne correctement. Nous avons bouchonné l'appel réseau en interceptant la méthode listBordereaux() de l'adapter et en faisant en sorte qu'elle renvoie ce que nous souhaitons pour nos tests.
Bien entendu il manque un test (non unitaire cette fois-ci) qui nous permet de vérifier que l'intégration avec le webservices est fonctionnelle.
Et vous qu'est ce que l'expression "test unitaire" signifie pour vous ?
Commentaires
Plutot qu'écrire du code pour les Mock, PHPUnit a développé un système de bouchon assez puissant.
Voir la fonction getMock
Justement à propos de :
il manque un test (non unitaire cette fois-ci) qui nous permet de vérifier
Quelle serait sa forme idéale ? Implémentation, méthode, utilisation ?
@Bruno: un début de réponse dans mon post : http://blog.phppro.fr/?post/2009/08/14/Tests-d-integration-quezako
@jsh : tout à fait d'accord avec la présence de cette fonctionnalité intéressante de PHPUnit, pour ma part je préfère réaliser mes mocks moi même car sinon je ne peux pas utiliser de mock en dehors de mes tests unitaires, hors cela peut être intéressant d'utiliser un mock à certains moment du développement (au début du développement d'un webservices par exemple), ou bien dans un autre contexte d'appel que phpunit. D'autre part, je trouve le mécanisme de gestion de la pile d'appel assez verbeux à écrire pour le mock, et je préfère la notion de pile à la "addExpectedResult", plus brute mais plus simple. Mais la fonctionnalité reste intéressante ;)