Développement piloté par les tests avec PHPUnit

Date de publication : 12/11/2008 , Date de mise à jour : 01/12/2009


III. Projet de développement piloté par les tests
III-A. Organisation
III-B. La classe Personne
III-C. La classe Ascenseur
III-D. Les classes finales
III-E. La suite de tests


III. Projet de développement piloté par les tests


III-A. Organisation

Dans notre projet, nous disposerons de 2 dossiers, un regroupera les tests, l'autre les classes utiles.
Les noms des fichiers correspondent aux noms des classes, par convention. Les noms des classes de tests sont <Composant>Test

Le fichiers AllTests.php va permettre d'écrire une suite de tests. Une suite est un agrégat de tests qui sont tous lancés les uns à la suite des autres.
Ceci est très pratique pour automatiser ses procédures de tests et tester un ensemble de composants ou un package logique.


III-B. La classe Personne

Nous allons commencer par écrire les tests de notre classe Personne. Souvent le développement piloté par les tests est assimilé à du développement guidé par le comportement (Behavior Driven Developpement). Le test va décrire la manière dont doit se comporter la classe (et ses objets). Ceci doit être fait de manière très précise : plus les tests sont précis et nombreux, plus ils couvrent un ensemble de cas d'utilisations larges, et au plus le risque de bug est faible. Evidemment, nous commencerons doucement.

info Nous supposons l'outil PHPUnit installé, présent dans l'include_path, et la classe PHPUnit_Framework_TestCase chargée.
tests/PersonneTest.php
<?php
require_once '../library/Personne.php';

class PersonneTest extends PHPUnit_Framework_TestCase
{
    private $_personne;

    protected function setUp()
    {
    	$this->_personne = new Personne('julien');
    }

    public function assertPreCondition()
    {
        $this->assertEquals('julien',$this->_personne->getName());
    }

    public function testPersonneChangeEtage()
    {
        $this->_personne->setEtage(6);
        $this->assertEquals(6,$this->_personne->getEtage());
    }
}
Une classe de test doit hériter de PHPUnit_Framework_TestCase (en général).
Il est important de noter que chaque méthode de tests :

  • doit être publique
  • doit commencer par le mot 'test' ou posséder un commentaire /** @test */
  • doit décrire au mieux le test réalisé, même si son nom doit être très long
  • Il est conseillé d'écrire ses méthodes en camelCase afin que le générateur de documentation agile puisse fonctionner
Tous les tests sont effectués de manière totalement isolée des autres.
La méthode protégée setUp() est appelée avant chaque test, et PHPUnit va tester toutes les méthodes commençant par le mot 'test'. Également une méthode tearDown() peut être utilisée pour faire du nettoyage : elle est de son côté appelée à la fin de chaque méthode de tests.
La méthode assertPreConditions() est appelée juste après setUp(), et donc juste avant chaque méthode de test. Elle sert à vérifier le bon état des objets qui vont être testés.

Ici notre classe teste clairement un objet de la classe Personne. Elle s'en affecte une instance dans une propriété protégée $_personne, qui sera utilisée dans tous les tests. Je rappelle que si un des tests change l'objet Personne, celui-ci sera remis en état pour le test suivant (isolement des tests).
Tous les tests fonctionnent sur des assertions, donc des vérifications à true. La lecture du test doit permettre de comprendre ce que l'on attend de la classe (de l'objet) testée.
assertPreConditions() va s'assurer que l'objet Personne est correctement construit, on voit d'entrée que celui-ci doit posséder une méthode getName().
testPersonneChangeEtage() est tout aussi explicite : tester qu'une personne puisse changer d'étage de manière convenable.

Oups, il y a erreur... Un fichier ne peut être ouvert. Remédions donc à ce problème :
Personne.php
<?php
class Personne { }
Et relançons le test (on va faire ceci beaucoup de fois) :

La méthode getName() n'existe pas me dit-il ... Créons là, et tant qu'à faire, avec toutes ses copines :
Personne.php
<?php
class Personne
{
    public function setEtage() { }

    public function getEtage() { }

    public function getName()  { }
}
Après un relancement du test, nous sommes sûr que toutes nos méthodes existent. Il ne va donc pas nous embetter avec ceci mais :

Remarquez ici il y a deux 'F', qui signifient 'Fail'. J'ai donc écrit deux tests au total, et les deux ont échoué. Un détail (rendu disponible grâce à l'option de lancement --verbose) m'indique les problèmes.
Les messages sont clairs : Failed asserting that <string:julien> matches expected value <null>. ; le test attendait 'julien', il a reçu null. Idem pour le test suivant.
Corrigeons notre classe pour passer les tests :
Personne.php
<?php
class Personne
{
    private $_nom;
    private $_etage;

    public function __construct($nom)
    {
        $this->_nom = $nom;
        $this->_etage = 0;
    }

    public function setEtage($etage)
    {
        $this->_etage = $etage;
    }

    public function getEtage()
    {
        return $this->_etage;
    }

    public function getName()
    {
        return $this->_nom;
    }
}
Nos tests passent !
Les deux points '..' indiquent chacun un test, et un test passé. Ainsi notre classe répond aux exigences que nous nous sommes données dans les tests.

Maintenant améliorons la avec des tests un peu plus poussés :
tests/PersonneTest.php
<?php
require_once '../library/Personne.php';

class PersonneTest extends PHPUnit_Framework_TestCase
{
    private $personne;

    protected function setUp()
    {
        $this->personne = new Personne('julien');
    }
    
	public function assertPreConditions()
    {
        $this->assertEquals($this->personne->getName(), 'julien');
        $this->assertEquals($this->personne->getName(), (string) $this->personne);
        $this->assertEquals($this->personne->getEtage(), 0);
    }
    
	public function testPersonneChangeEtage()
    {
        $this->assertType('Personne',$this->personne->setEtage(6));
        $this->assertEquals($this->personne->getEtage(), 6);
    }
    
    public function testPersonneChangeEtageBizarement()
    {
        $this->personne->setEtage(-6.9);
        $this->assertEquals($this->personne->getEtage(), 6);
    }
}
$this->assertEquals($this->personne->getName(), (string) $this->personne); cette ligne teste une méthode __toString().
Ce test indique donc que l'on s'attend à ce que un echo $unePersonne affiche son nom.

info Rappel : la méthode magique __toString() d'un objet est appelée automatiquement dès que celui-ci est transformé en chaine.
Nous avons changé le test dans testPersonneChangeEtage(). Comme setEtage() ne retourne rien, vérifions qu'elle retourne un objet Personne, de cette manière nous pourrons utiliser une interface fluide et chainer la méthode à une autre.
Puis nous testons l'affectation d'un étage incorrect. Que se passe-t-il si on passe un étage négatif à la méthode setEtage() ?
Et un étage réel ( non entier, mais flotant ) ?
Ici, on s'attend à ce que ceci soit corrigé.
Testons ceci tout de suite :

Imméditament on peut remarquer : "EFF". E signifie Echec : le test a échoué car une erreur PHP l'a empêché de passer (il n'y a pas encore de méthode __toString())
F signifie que les deuxième et troisième tests ont echoué sur une assertion (on s'attendait à quelque chose, mais autre chose a été rencontré).
Il est ainsi necéssaire de corriger la classe Personne de manière à ce que celle-ci fasse passer les tests à nouveau :
classe Personne finalisée
<?php
class Personne
{
    private $_nom;
    private $_etage;

    public function __construct($nom)
    {
        $this->_nom = $nom;
        $this->_etage = 0;
    }

    public function setEtage($etage)
    {
        $this->_etage = abs((int)$etage);
        return $this;
    }

    public function getEtage()
    {
        return $this->_etage;
    }

    public function getName()
    {
        return $this->_nom;
    }
	
    public function __toString()
    {
        return $this->getName();
    }
}
Parfait !


III-C. La classe Ascenseur

Concernant notre ascenseur, nous allons définir ses spécifications à la volée, via des tests.
tests/AscenseurTest.php
<?php
require_once '../library/Ascenseur.php';

class AscenseurTest extends PHPUnit_Framework_TestCase
{
    private $ascenseur;

    protected function setUp()
    {
        $this->ascenseur = new Ascenseur('ascenseur 1', 3);
    }
    
    public function assertPreConditions()
    {
        $this->assertEquals($this->ascenseur->getName(), 'ascenseur 1'); // vérifions le nom de l'ascenseur
        $this->assertEquals(3, $this->ascenseur->getEtage()); // vérifions l'étage de l'ascenseur
        $this->assertTrue($this->ascenseur->isEmpty()); // vérifions que l'ascenseur est vide
    }
    
    public function testAscenseurBouge()
    {
        $this->ascenseur->go(8); // Faisons aller l'ascenseur à l'étage 8
        $this->assertEquals(8, $this->ascenseur->getEtage()); // Interrogeons l'ascenseur au sujet de son étage, la réponse doit être 8
        $this->ascenseur->go(-9.9); 
        $this->assertEquals(9, $this->ascenseur->getEtage());
        $this->ascenseur->go(2);
        $this->assertEquals(2, $this->ascenseur->getEtage());
    }

    public function testAscenseurMonteUnPeuTropHautEchoue()
    {
        $this->setExpectedException('AscenseurException'); // Nous nous attendons à une exception par la suite
        $this->ascenseur->go(200);  // Faisons aller l'ascenseur à l'étage 200, irréaliste
    }
}
Déjà, dès la méthode setUp(), on définit ce que va être le constructeur de notre classe Ascenseur. Il doit prendre deux paramètres, le premier sera son nom, et le deuxième l'étage auquel il se trouve dès sa construction.
assertPreConditions() va faire des vérifications de routine (cette méthode est lancée avant chaque test), nous nous attendons à récupérer son nom, et nous définissons déjà une méthode, isEmpty(), qui comme son nom l'indique si bien, nous retournera true si l'ascenseur est vide de personnes, false autrement.
Ensuite, pour faire évoluer notre ascenseur entre les étages, il dispose d'une seule méthode : go(). En effet, il aurait été idiot de séparer ceci en deux méthodes descend() et monte()...
La méthode go() doit valider le paramètre qu'on lui passe, comme un étage négatif, ou un étage flottant...
Enfin, nous introduisons le concept d'exceptions. setExpectedException() est une méthode de PHPUnit (test case) qui permet de vérifier qu'une exception de tel type est bien levée à la fin de la méthode de test.
testAscenseurMonteUnPeuTropHautEchoue() porte un nom des plus explicites. A la fin de cette méthode, une AscenseurException doit avoir été levée, sinon le test ne passera pas.

warning Il est impératif de typer ses exceptions. Ne renvoyez jamais une Exception, mais une 'quelquechose'Exception. déjà pour plus de clarté, et ensuite parce que setExpectedException() peut attraper les Exceptions de PHPUnit lui-même, si on utilise une exception non nommée.
Rappelez-vous que PHPUnit est lui-même écrit en PHP. Lorsqu'un test échoue, il utilise en interne des exceptions pour se le signaler. N'utilisez jamais la classe Exception de base.
Maintenant que nous avons défini les spécifications de notre future classe Ascenseur, il va falloir l'écrire pour faire en sorte que tous les tests passent. Voyons cela :
library/Ascenseur.php
<?php
require_once 'AscenseurException.php';

class Ascenseur
{
    const MAX_ETAGES  = 50;
	
    private $_nom;
    private $_etage;

    public function __construct($nom, $etage)
    {
        $this->_nom = $nom;
        $this->go($etage);
    }

    public function isEmpty()
    {
        return true;
    }

    public function go($a_l_etage)
    {
        $a_l_etage = abs((int)$a_l_etage);
        if ($a_l_etage > self::MAX_ETAGES || $a_l_etage < 0) {
           throw new AscenseurException('Etage invalide');
        }	    
        $this->_etage = $a_l_etage;
    }

    public function getName()
    {
        return $this->_nom;
    }

    public function getEtage()
    {
        return $this->_etage;
    }
}
Relativement simple, remarquez que nous utilisons notre propre méthode go(), dans le constructeur, afin de ne pas nous répéter.

Tout va bien. Vous aurez deviné la structure de notre classe AscenseurException, je ne la détaille donc pas.
Nous allons compliquer la classe maintenant, avec l'introduction des Personnes. C'est là que l'on va s'apercevoir à quel point il est pratique d'écrire des tests.

Comme d'habitude, on veut changer de spécifications, il suffit de les écrire sous forme de tests, mais sans modifier les tests actuels, sauf si c'est nécéssaire.
En général, il est préférable d'écrire d'autres tests, plutôt que de modifier les tests actuels. Un test, voire plus, par spécification à vérifier.
Ici nous allons donc écrire le comportement attendu de notre classe Ascenseur :
suite de tests/AscenseurTest.php
<?php
// suite du fichier précédent avec quelques modifications ...
    require_once '../library/Personne.php';
	
    private $personne;
    
    protected function setUp ()
    {
        $this->ascenseur = new Ascenseur2('ascenseur 1', 3);
        $this->personne  = new Personne('julien'); // Nous créons une personne
    }
    
    public function testChargeAscenseurAvecPersonnes ()
    {
        $this->assertTrue($this->ascenseur->isEmpty()); // l'ascenseur doit être vide
        $this->ascenseur->charger(new Personne('Elodie'));  // nous le chargeons avec la personne Elodie
        $this->ascenseur->charger(new Personne('Guillaume')); // puis avec la personne Elodie
        $this->assertEquals(count($this->ascenseur), 2); // il y a bien deux personnes dedans ?
        $this->assertFalse($this->ascenseur->isEmpty()); // il est bien non vide ?
    }
    
    public function testDechargeAscenseur ()
    {
        $this->ascenseur->charger($this->personne); // chargeons l'ascenseur avec notre personne
        $this->assertFalse($this->ascenseur->isEmpty()); // testons que l'ascenseur n'est pas vide
        $this->ascenseur->decharger($this->personne); // déchargeons l'ascenseur de notre personne
        $this->assertEquals(count($this->ascenseur), 0);  // testons que l'ascenseur possède bien zéro personne
        $this->assertTrue($this->ascenseur->isEmpty());  // testons que l'ascenseur est bien vide
    }
Nous introduisons donc notre classe précédemment testée Personne. Il va falloir écrire deux méthodes charger() et decharger() pour notre ascenseur.
Au passage on notera une agrégation d'objets : l'ascenseur contient des personnes, ceci est un grand classique en POO, y compris en PHP.
Aussi, voyez que l'on veut pouvoir compter le nombre de personnes dans l'ascenceur. Ceci via un count() PHP directement sur l'objet ascenseur : l'interface countable va nous y aider.
Inutile de lancer le test maintenant sachant qu'il va échouer. En revanche : complétons directement notre classe Ascenseur en lui faisant aggréger des objets Personnes. Ceci ne se fait pas en une étape, alors écrivez du code, puis testez. Notez l'echec, corrigez, et testez.
Jusqu'à obtenir quelque chose de naturel comme ceci :
library/Ascenseur.php, version 2
<?php
require_once 'Personne.php';
require_once 'AscenseurException.php';

class Ascenseur implements Countable 
{
    const MAX_ETAGES  = 50;
	
    private $_nom;
    private $_etage;
    private $_personnes;

    public function __construct($nom, $etage)
    {
        $this->_personnes = new SplObjectStorage;
        $this->_nom = $nom;
        $this->go($etage);
    }

    public function charger(Personne $p)
    {
        $this->_personnes->attach($p);
    }

    public function decharger(Personne $p)
    {
        $this->_personnes->detach($p);
    }
    
    public function isEmpty()
    {
        return count($this) == 0;
    }

    public function go($a_l_etage)
    {
        $a_l_etage = abs((int)$a_l_etage);
        if ($a_l_etage > self::MAX_ETAGES || $a_l_etage < 0) {
            throw new AscenseurException('Etage invalide');
        }	    
        $this->_etage = $a_l_etage;
    }

    public function getName()
    {
        return $this->_nom;
    }

    public function getEtage()
    {
        return $this->_etage;
    }
	
    public function count()
    {
        return count($this->_personnes);
    }
}
L'attribut $_personnes est devenu un objet SplObjectStorage. Faites un tour sur fr l'article de la SPL si vous ne connaissez pas cet objet très pratique.
Certes notre Ascenseur aurait pu hériter de cette classe, mais j'ai préféré ici faire une association.
L'interface Countable nous fait définir count() qui proxie vers le comptage Personnes (SplObjectStorage implémente countable aussi)
Puis nous typons les méthodes charger() et decharger() pour n'accepter que des Personnes en paramètres.

Remarquez que ceci n'est pas testé. Aucun de nos tests essaye de charger autre chose qu'une Personne dans l'ascenseur, ceci parce que je considère le typage objet comme acquis. Avec les tests, on peut aller très très loin, et vraiment tout tester.
Mais est-ce bien nécéssaire de tout tester ?
"Un objet Personne est-il une instance de Personne" est un cas de test idiot qui ne mène à rien. Lorsqu'on écrit des tests, il faut aller directement vers l'utile.
Et repérer l'utile, fait aussi parti des avantages d'écrire des tests : on se pose tout un tas de questions qui nous font prendre conscience de notre programme.

Attention : re-changement de spécifications : évolution. Voyons ceci, tout naturellement maintenant, via des tests ajoutés :
tests/AscenseurTest.php
<?php
// suite du fichier précédent
    public function testGetPersonne()
    {
        $this->ascenseur->charger($this->personne); // chargeons l'ascenseur avec notre personne
        $this->assertSame($this->personne, $this->ascenseur->getPersonne($this->personne));  // l'ascenseur nous ressert-il bien notre personne si on lui demande ?
        $this->setExpectedException('AscenseurException'); // une exception devrait maintenant se produire :
        $this->ascenseur->getPersonne(new Personne('personne')); // demandeons à l'ascenseur de nous donner une Personne qu'il ne possède pas
    }
    
    public function testChargeAscenseurAvecDeuxFoisLaMemePersonneEchoue()
    {
        $this->ascenseur->charger($this->personne); // chargeons l'ascenseur avec notre personne
        $this->setExpectedException('AscenseurException'); // une exception devrait maintenant se produire :
        $this->ascenseur->charger($this->personne); // chargeons l'ascenseur avec encore notre personne (personne n'est pas singleton)
    }
Il est judicieux de pouvoir récupérer une Personne de l'ascenseur via un getPersonne(). En fait ca ne sert pas à grand chose, si ce n'est de vérifier si la personne en question est présente dans l'ascenseur.
Cette notion est suivie avec une méthode qui teste qu'on ne puisse charger deux fois la même personne dans l'ascenseur (même si un singleton de Personnes aurait pu être utilisé, ça n'est pas le cas dans cet article).

Ecrivons donc la suite de notre classe Ascenseur. A ce stade, si l'on modifie un peu trop cette classe, les tests précédents vont échouer et donc pleinement jouer leur rôle de guides et d'assurance dans le développement piloté par les tests :
library/Ascenseur.php, refonte
<?php
// refonte de la classe Ascenseur
    public function charger(Personne $p)
    {
        if($this->_personnes->contains($p)) {
            throw new AscenseurException($p->getName() . ' existe déjà');           
        }        
        $this->_personnes->attach($p);
    }
    
    public function getPersonne(Personne $p)
    {
        if($this->_personnes->contains($p)) {
           return $p;
        }
        throw new AscenseurException('Cette personne n\'est pas dans l\'ascenseur');
    }
La classe passe les tests. Mais maintenant regardez la classe complète : elle est déjà refactorisable.
Le développement piloté par les tests possède une étape dite de refactorisation.

info La refactorisation est le fait de retoucher une classe, sans que son API ne change. Tous les objets utilisant des instances de cette classe n'ont aucune idée que son code interne a changé.
charger(), decharger() et getPersonne() utilisent toutes les trois un code commun : elles recherchent la Personne avant d'effectuer une action.
Nous pouvons donc factoriser cette action :
library/Ascenseur.php, refactorisation
<?php
// refactorisation de 3 méthodes de la classe Ascenseur
    protected function _searchPersonne(Personne $p)
    {
        return $this->_personnes->contains($p);
    }
    
    public function charger(Personne $p)
    {
        if($this->_searchPersonne($p)) {
            throw new AscenseurException($p->getName() . ' existe déjà');           
        }        
        $this->_personnes->attach($p);
    }

    public function decharger(Personne $p)
    {
        $this->_personnes->detach($p);
    }
    
    public function getPersonne(Personne $p)
    {
        if($this->_searchPersonne($p)) {
            return $p;
        }
        throw new AscenseurException('Cette personne n\'est pas dans l\'ascenseur');
    }
La refactorisation est minime, mais la méthode _searchPersonne() pourra, si le besoin se fait sentir, passer en public pour permettre à d'autres objets futurs de l'interroger.
Les tests passent toujours.

Nous pouvons dès lors introduire la notion de surcharge de l'ascenseur, ceci de manière très simple.
Nous pouvons aussi utiliser l'idée que l'ascenseur fait évoluer l'étage auquel se trouvent les personnes le composant :
tests/AscenseurTest.php
<?php
// suite du fichier précédent
    public function testAscenseurMonteAvecDesPersonnes()
    {
        $this->ascenseur->charger($this->personne); // chargeons l'ascenseur avec notre personne
        $this->ascenseur->go(8); // faisons aller l'ascenseur à l'étage 8
        $this->assertEquals(8, $this->ascenseur->getPersonne($this->personne)->getEtage()); // Demandeons maintenant à la personne son étage, est-ce bien 8?
    }
    
    public function testChargeAscenseurAvecUnePersonnePasAuMemeEtageEchoue()
    {
        $this->setExpectedException('AscenseurException'); // Une exception doit survenir à la suite de cette ligne 
        $this->ascenseur->charger($this->personne->setEtage(10)); // tentons de charger l'ascenseur, qui est à l'étage 0, avec une personne à l'étage 10
    }
    public function testSurchargeAscenseur()
    {
        $this->ascenseur->go(0); // ascenseur va à l'étage 0
        $this->ascenseur->charger(new Personne('Elodie')); // charger l'ascenseur
        $this->ascenseur->charger(new Personne('Guillaume')); // ...
        $this->ascenseur->charger($this->personne);
        $this->ascenseur->charger(new Personne('Sarah'));
        $this->ascenseur->charger(new Personne('Sophie'));
        $this->assertEquals(count($this->ascenseur), 5); // y-a-t-il bien 5 personnes dans l'ascenseur à ce stade ?
        $this->setExpectedException('AscenseurException'); // une exception doit maintenant être levée
        $this->ascenseur->charger(new Personne('Benoit')); // tentons de charger une sixième personne
    }
Evidemment ces 3 tests échouent, expliquons les :
testAscenseurMonteAvecDesPersonnes() : Nous chargeons l'ascenseur avec une personne, l'ascenseur va à l'étage 8 : il faut que lorsqu'on interroge la Personne en lui demandant son étage, elle réponde 8.
testChargeAscenseurAvecUnePersonnePasAuMemeEtageEchoue() : utilise brillament l'interface fluide de la classe Personne, précédemment créee. setEtage() sur une Personne, retourne la Personne. En une seule ligne nous pouvons donc écrire notre assertion.
testSurchargeAscenseur() : en lisant le code, on devine que la surcharge apparait à partir de six personnes dans l'ascenseur. Aussi, une Personne 'naissant' à l'étage 0, il faut bien prendre soin de faire descendre l'ascenseur à l'étage 0 avant de pouvoir le charger de Personnes fraichement crées. Ici sans le vouloir vraiment, nous testons donc plusieurs paramètres.

Il ne reste plus qu'à modifier la classe utile Ascenseur pour qu'elle réponde positivement aux tests, y compris à ceux déjà écrits que l'on ne touche aucunement (couverture par les tests) :
library/Ascenseur.php, version 3
<?php
// refonte de la classe Ascenseur
    const CHARGE_MAXI = 5;
    
    public function charger(Personne $p)
    {
        if($p->getEtage() != $this->getEtage()) { // la personne est-elle au même étage que nous, ascenseur ?
            throw new AscenseurException('La personne '.$p->getName().' n\'est pas à l\'étage '.$this->getEtage());
        }
        if($this->_searchPersonne($p)) {
            throw new AscenseurException($p->getName() . ' existe déjà');           
        }
        if (count($this) == self::CHARGE_MAXI) { // surcharge éventuelle ?
            throw new AscenseurException('Surcharge');
        }
        $this->_personnes->attach($p);
    }
    
    public function go($a_l_etage)
    {
        $a_l_etage = abs((int)$a_l_etage);
        if ($a_l_etage > self::MAX_ETAGES || $a_l_etage < 0) {
            throw new AscenseurException('Etage invalide');
        }
        foreach ($this->_personnes as $personne) { // toutes les personnes se déplacent avec l'ascenseur
            $personne->setEtage($a_l_etage);
        }    
        $this->_etage = $a_l_etage;
    }
// reste du code de la classe
Tout semble correct, et bien répondant aux tests. Que se passe-t-il si l'on joue les tests maintenant ?

Nous sommes informés que le premier des tests que nous avons rajouté (parmi les trois) a échoué. Mais aussi que les 3 tests situés avant celui-ci, ont échoué.
Je rappelle que je lis la ligne ....EEEE.. pour affirmer ceci.
En passant le problème des accents dans les messages d'erreurs (peu graves), nous remarquons que ce que nous avons écrit dans Ascenseur, entraine apparamment une régréssion. Mais en lisant mieux, on s'aperçoit que ce sont nos tests qui ne répondent plus correctement aux spécifications.
Dans notre scénario de test, la Personne ($this->personne), se trouve par défaut à l'étage zéro. Or notre ascenseur lui, se trouve à l'étage 3 par défaut. Il faut alors changer le test :
léger remaniement de tests/AscenseurTest.php
<?php
    protected function setUp ()
    {
        $this->ascenseur = new Ascenseur2('ascenseur 1', 3);
        $this->personne  = new Personne('julien');
        $this->personne->setEtage(3); // la Personne est maintenant à l'étage 3 pour tous les tests du scénario
    }
C'est mieux, mais cette fois le dernier test échoue, c'est normal :
légère modification de tests/AscenseurTest.php
<?php
// suite du fichier précédent avec quelques modifications ...
public function testSurchargeAscenseur ()
    {
        $this->ascenseur->go(0);
        $this->ascenseur->charger(new Personne('Elodie'));
        $this->ascenseur->charger(new Personne('Guillaume'));
        $this->ascenseur->charger($this->personne->setEtage(0)); // La personne aggrégée repasse à l'étage 0
        $this->ascenseur->charger(new Personne('Sarah'));
        $this->ascenseur->charger(new Personne('Sophie'));
        $this->assertEquals(count($this->ascenseur), 5);
        $this->setExpectedException('AscenseurException');
        $this->ascenseur->charger(new Personne('Benoit'));
    }
Voila un soulagement :-) Tous les tests passent à nouveau, et toute la logique de nos programmes est maintenant lisible de manière claire.

En dernier lieu, le grand classique : rendons itérative la classe Ascenseur, envers les Personnes qu'elle contient. Comment ?
Voyons ça très clairement via un dernier test :
dernier test rajouté à tests/AscenseurTest.php
<?php
    public function testIterator ()
    {
        $this->ascenseur->charger($this->personne); // chargeons l'ascenseur avec une personne
        $this->assertThat($this->ascenseur, new PHPUnit_Framework_Constraint_TraversableContains($this->personne)); // l'ascenseur doit aggréger des personnes
    }
Ceci va nous permettre d'itérer sur l'ascenseur pour en récupérer toutes les Personnes qu'il contient.
Remarquez la puissante méthode assertThat(), qui s'assure que son premier paramètre vérifie bien la condition de son deuxième paramètre, devant être un objet de type PHPUnit_Framework_Constraint, à peine croyable ...
Nous allons utiliser le SplObjectStorage, pour rendre l'ascenseur itératif. Je vous renvoie à cet article sur la SPL et les itérateurs si vous êtes génés.


III-D. Les classes finales

AscenseurTest finale
<?php
require_once '../library/Personne.php';
require_once '../library/Ascenseur.php';

class AscenseurTest extends PHPUnit_Framework_TestCase
{
    private $ascenseur;
    private $personne;

    protected function setUp()
    {
        $this->ascenseur = new Ascenseur('ascenseur 1', 3);
        $this->personne  = new Personne('julien');
        $this->personne->setEtage(3);
    }
    
    public function assertPreConditions()
    {
        $this->assertEquals($this->ascenseur->getName(), 'ascenseur 1');
        $this->assertEquals(3, $this->ascenseur->getEtage());
        $this->assertTrue($this->ascenseur->isEmpty());
    }
    
    public function testAscenseurBouge()
    {
        $this->ascenseur->go(8);
        $this->assertEquals(8, $this->ascenseur->getEtage());
        $this->ascenseur->go(- 9.9);
        $this->assertEquals(9, $this->ascenseur->getEtage());
        $this->ascenseur->go(2);
        $this->assertEquals(2, $this->ascenseur->getEtage());
    }

    public function testAscenseurMonteUnPeuTropHautEchoue()
    {
        $this->setExpectedException('AscenseurException');
        $this->ascenseur->go(200);
    }
    
    public function testChargeAscenseurAvecPersonnes()
    {
        $this->assertTrue($this->ascenseur->isEmpty());
        $this->ascenseur->go(0);
        $this->ascenseur->charger(new Personne('Elodie'));
        $this->ascenseur->charger(new Personne('Guillaume'));
        $this->assertEquals(count($this->ascenseur), 2);
        $this->assertFalse($this->ascenseur->isEmpty());
    }
    
    public function testDechargeAscenseur()
    {
        $this->ascenseur->charger($this->personne);
        $this->assertFalse($this->ascenseur->isEmpty());
        $this->ascenseur->decharger($this->personne);
        $this->assertEquals(count($this->ascenseur), 0);
        $this->assertTrue($this->ascenseur->isEmpty());
    }
    
    public function testGetPersonne()
    {
        $this->ascenseur->charger($this->personne);
        $this->assertSame($this->personne, $this->ascenseur->getPersonne($this->personne));
        $this->setExpectedException('AscenseurException');
        $this->ascenseur->getPersonne(new Personne('personne'));
    }
    
    public function testChargeAscenseurAvecDeuxFoisLaMemePersonneEchoue()
    {
        $this->ascenseur->charger($this->personne);
        $this->setExpectedException('AscenseurException');
        $this->ascenseur->charger($this->personne);
    }
    
    public function testAscenseurMonteAvecDesPersonnes()
    {
        $this->ascenseur->charger($this->personne);
        $this->ascenseur->go(8);
        $this->assertEquals(8, $this->ascenseur->getPersonne($this->personne)->getEtage());
    }
    
    public function testChargeAscenseurAvecUnePersonnePasAuMemeEtageEchoue()
    {
        $this->setExpectedException('AscenseurException');
        $this->ascenseur->charger($this->personne->setEtage(10));
    }
    public function testSurchargeAscenseur()
    {
        $this->ascenseur->go(0);
        $this->ascenseur->charger(new Personne('Elodie'));
        $this->ascenseur->charger(new Personne('Guillaume'));
        $this->ascenseur->charger($this->personne->setEtage(0));
        $this->ascenseur->charger(new Personne('Sarah'));
        $this->ascenseur->charger(new Personne('Sophie'));
        $this->assertEquals(count($this->ascenseur), 5);
        $this->setExpectedException('AscenseurException');
        $this->ascenseur->charger(new Personne('Benoit'));
    }
    
    public function testIterator()
    {
        $this->ascenseur->charger($this->personne);
        $this->assertThat($this->ascenseur,new PHPUnit_Framework_Constraint_TraversableContains($this->personne));
    }
}
Classe Ascenseur finale
<?php
require_once 'Personne.php';
require_once 'AscenseurException.php';

class Ascenseur implements Countable , IteratorAggregate 
{
    const MAX_ETAGES  = 50;
    const CHARGE_MAXI = 5;
	
    private $_nom;
    private $_etage;
    private $_personnes;

    public function __construct($nom, $etage)
    {
        $this->_nom       = $nom;
        $this->_personnes = new SplObjectStorage;
        $this->go($etage);
    }

    protected function _searchPersonne(Personne $p)
    {
        return $this->_personnes->contains($p);
    }
    
    public function charger(Personne $p)
    {
        if($p->getEtage() != $this->getEtage()) {
            throw new AscenseurException('La personne '.$p->getName().' n\'est pas à l\'étage '.$this->getEtage());
        }
        if($this->_searchPersonne($p)) {
            throw new AscenseurException($p->getName() . ' existe déjà');           
        }
        if (count($this) == self::CHARGE_MAXI) {
            throw new AscenseurException('Surcharge');
        }
        $this->_personnes->attach($p);
        return $this;
    }

    public function decharger(Personne $p)
    {
        $this->_personnes->detach($p);
        return $this;
    }
    
    public function getPersonne(Personne $p)
    {
        if($this->_searchPersonne($p)) {
            return $p;
        }
        throw new AscenseurException('Cette personne n\'est pas dans l\'ascenseur');
    }
    
    public function isEmpty()
    {
        return count($this) == 0;
    }

    public function go($a_l_etage)
    {
        $a_l_etage = abs((int)$a_l_etage);
        if ($a_l_etage > self::MAX_ETAGES || $a_l_etage < 0) {
            throw new AscenseurException('Etage invalide');
        }
        foreach ($this as $personne) {
            $personne->setEtage($a_l_etage);
        }    
        $this->_etage = $a_l_etage;
        return $this;
    }

    public function getName()
    {
        return $this->_nom;
    }

    public function getEtage()
    {
        return $this->_etage;
    }
	
    public function count()
    {
        return count($this->_personnes);
    }
    
    public function getIterator()
    {
        return $this->_personnes;
    }
}

III-E. La suite de tests

Qu'est ce qu'une suite de tests ? Très simple : une aggérgation de tests. Dans un projet, vous n'avez pas une, ni deux classes de tests, mais souvent au moins autant que de classes utiles, et ça peut vite chiffrer (projets à 500 classes de test, et 5000 à 6000 tests totaux).
Il est intéréssant de classer ses tests dans des suites. Une suite n'est rien d'autre qu'un lanceur de tests.
Voyons une suite qui va lancer nos deux scénarios de tests : PersonneTests et AscenseurTests :
tests/AllTests.php
<?php
require_once 'AscenseurTest.php';
require_once 'PersonneTest.php';

PHPUnit_Util_Filter::addFileToFilter(__FILE__);

class AllTests
{
    public static function main()
    {
        PHPUnit_TextUI_TestRunner::run(self::suite());
    }
    
    public static function suite()
    {
        $suite = new PHPUnit_Framework_TestSuite('Ma suite de tests');
        $suite->addTestSuite('AscenseurTest');
        $suite->addTestSuite('PersonneTest');
        return $suite;
    }
}

AllTests::main();
La méthode statique run() de la classe PHPUnit_TextUI_TestRunner prend en paramètre un objet de type PHPUnit_Framework_TestSuite.
Cet objet représente une suite de tests, chaque tests y est aggrégé avec la méthode addTestSuite(), qui prend en paramètre une chaine représentant la classe du scénario de tests. Tout cela est écrit dans une 'classe statique' (qui ne comporte que des méthodes statiques) appelée AllTests (qui n'étend rien du tout).

Il n'est pas obligatoire d'écrire cela dans une classe, mais sur un projet plus gros, des suites de tests peuvent lancer d'autres suites de tests. Il est ainsi recommandé de procéder comme cela de manière à pouvoir charger une classe de suite de tests, dans une autre classe plus globale. Il est aussi de coutûme d'appeler ses suites 'AllTests'.
Pour lancer une suite de tests avec PHPUnit ? C'est exactement le même procédé que pour lancer un scénario de tests :

Voyez que 'Ma suite de tests' a bien lancé AscenseurTest puis PersonneTest, avec le détail de chacun des tests. C'est très pratique pour tester un composant complet (ou 'package').
On peut donc à plus grande echelle lancer une suite de tests globale, de l'application, aggrégeant chacune des suites de chaque composant du projet.
Nous allons parler de la ligne PHPUnit_Util_Filter::addFileToFilter(__FILE__); dans le chapitre suivant.

 

Valid XHTML 1.0 TransitionalValid CSS!

Copyright © 2008 Julien Pauli. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à 3 ans de prison et jusqu'à 300 000 E de dommages et intérêts. Cette page est déposée à la SACD.

Vos questions techniques : forum d'entraide PHP - Publiez vos articles, tutoriels et cours
et rejoignez-nous dans l'équipe de rédaction du club d'entraide des développeurs francophones
Nous contacter - Hébergement - Participez - Copyright © 2000-2010 www.developpez.com - Legal informations.