Tester unitairement les méthodes renvoyant des exceptions avec PHPUnit

De plus en plus, en php, on renvoit des exceptions lorsque une erreur survient plutôt que true ou false.
Ce système est très facile à utiliser dans le code en encadrant la méthode dans un try/catch mais c'est plus délicat de faire des tests unitaires.
En effet, le test unitaire doit couvrir à la fois les cas où tout se déroule très bien, comme ceux où il y a un soucis. Dans ces cas d'erreurs, on veut vérifier qu'une exception est bien levée, que c'est bien celle attendue, et dans les autres cas, au contraire on veut être sûr qu'aucune exception n'est levée.

Présentation de PHPUnit

Le but de cet article n'est ni de plaider pour la cause des tests unitaires, ni de faire une présentation en détails de comment les faire.

Personnellement, pour mes tests unitaires, j'utilise PHPUnit. Si vous ne connaissez pas bien cette librairie, je vous conseille de lire sa documentation qui est très complète, et qui existe en version traduite en français: comment écrire des tests unitaire avec phpunit

Pour résumer, une classe de Test correspond généralement à une classe réelle, son nom correspondant au nom de la classe que l'on veut tester suivi du mot "Test". Elle étend la classe PHPUnit_Framework_TestCase. A l'intérieur, les méthodes de tests commencent par le mot clé "test" et correspondent généralement à un méthode de la classe testée.
Petite remarque, on ne peut tester directement que les méthodes publiques.
Chaque test regroupe un ensemble d'assertion, une assertion étant une vérification, par exemple si le résultalt d'une méthode est bien la valeur attendue, si l'attribut d'une classe a la bonne valeur.... La liste des assertions est assez complète et impressionnante: assertions.

Pour mes exemples, voici la classe que je veux tester:

 
class Order
{
    protected $articles;
 
    public function __construct()
    {
        $this->articles = array();
    }
 
    /**
     * Ajoute un article à la commande
     * @param string $reference
     * @param int $quantity
     */    
    public function addArticle($reference, $quantity = 1)
    {
        $this->articles[$reference] = $quantity;
    }
 
    /**
     * Modifie la quantité d'une référence
     * @param string $reference
     * @param int $quantity
     * @throws InvalidArgumentException
     */
    public function updateQuantity($reference, $quantity)
    {
        if (!array_key_exists($reference, $this->articles)) {
            throw new InvalidArgumentException('reference not ordered');
        }        
        $this->articles[$reference] = $quantity;
    }
 
    public function getArticles()
    {
        return $this->articles;
    }
}

Voici le début de la class de test:

 
class OrderTest extends PHPUnit_Framework_TestCase
{
    protected $order;
 
    //cette méthode est executé avant chaque test, son homologue tearDown, est executé après chaque test
    public function setUp()
    {
         if (null == $this->order) {
              $this->order = new Order();
         }
    }
 
    /**
     * Test constructeur
     */
    public function testConstruct()
    {
        //on teste que l'attribut articles a bien été instancié comme un tableau vide
        $this->assertAttributeInternalType('array', 'articles', $this->order);
        $this->assertAttributeCount(0, 'articles', $this->order);
    }
}

Résultalt:

 
PHPUnit 3.7.1 by Sebastian Bergmann.
 
.
 
Time: 1 second, Memory: 3.00Mb
 
OK (1 test, 2 assertions)


Tester avec un jeu de données

PHPUnit possède plusieurs annotations spécifiques. La plus utile est @dataProvider, elle permet de jouer un test avec plusieurs jeux de données. On déclare une méthode qui renvoit un tableau, chaque ligne du tableau étant un jeu de test qui sera passé en paramêtre à la méthode de test.
Voici un exemple d'uilisation avec le test de la méthode addArticle:

 
        /**
	 * Test fonction addArticle
         *
	 * @dataProvider providerAdd  <= utilisation de l'annotation
         *
	 * @param string $reference
	 * @param int $quantity
	 */
	public function testAddArticle($reference, $quantity)
        {
            $this->order->addArticle($reference, $quantity);
 
            //on verifie l'enregistrement de l'article
            $this->assertAttributeContains($quantity, 'articles', $this->order);
 
            //je n'est pas trouve de moyen de tester les clés de l'attribut, je passe donc par le getter
            $articles = $this->order->getArticles();
            $this->assertInternalType('array', $articles);
            $this->assertArrayHasKey($reference, $articles);
            $this->assertEquals($quantity, $articles[$reference]);
        }
 
	/**
	 * Données de tests pour addArticle
	 */
	public function providerAdd()
	{
		return array(
			array('a', 1),
			array('b', 2)
		);
	}

Resultat:

 
PHPUnit 3.7.1 by Sebastian Bergmann.
 
...
 
Time: 1 second, Memory: 3.25Mb
 
OK (3 tests, 10 assertions)
 
 
 

Tester avec une exception

Le test de la méthode updateQuantity est plus compliqué car elle peut renvoyer une exception. On pourrait utiliser l'annotation @expectedException de PHPUnit mais elle ne permet d'utiliser un jeu de donnée contenant des cas qui échouent et des cas qui réussisent.
Tou d'abord je ne trouves pas très propres de devoir faire deux méthodes de tests pour tester une méthode, et surtout on ne peut pas tester qu'une exception n'est pas levée.

La méthode à laquelle j'ai pensée, est d'ajouter un paramètre dans le jeu de données qui est le type d'exception attendue, ce paramètre vaut "null" si on en n'attend pas.
On teste donc que ce paramère est vide si la méthode ne renvoit pas d'exception.

 
        /**
	 * Test fonction updateQuantity
	 * @dataProvider providerUpdate
	 * @param string $reference
	 * @param int $quantity
	 * @param string $exception
	 */
	public function testupdateQuantity($reference, $quantity, $exception)
	{
		$this->order->addArticle('a', 1);
		$this->order->addArticle('b', 2);
		try {
			$this->order->updateQuantity($reference, $quantity);
			$this->assertEmpty($exception, 'expected exception not raised');
			$articles = $this->order->getArticles();
			$this->assertInternalType('array', $articles);
			$this->assertArrayHasKey($reference, $articles);
			$this->assertEquals($quantity, $articles[$reference]);
		} catch (Exception $e) {
			if ($exception) {
				$this->assertInstanceOf($exception, $e);
			} else {				
				$this->fail('exception not expected');
			}
		}
	}
 
	/**
	 * Données de tests pour updateQuantity
	 */
	public function providerUpdate()
	{
		return array(
				array('a', 2, null),
				array('b', 1, null),
				array('c', 1, 'InvalidArgumentException')
		);
	}

Resultat:

 
PHPUnit 3.7.1 by Sebastian Bergmann.
 
......
 
 
Time: 0 seconds, Memory: 3.50Mb
 
OK (6 tests, 19 assertions)
 
 

Bonus: tester les erreurs PHP

Remarque: PHPUnit renvoie des exceptions (PHPUnit_Framework_Error, PHPUnit_Framework_Error_Notice ou PHPUnit_Framework_Warning selon le cas) lorsqu'une erreur PHP est rencontrée.
C'est une bonne habitude de provoquer des erreurs dans les tests (en cours de developpement), pour vérifier le comportement. Pour cela, ajoutons des cas avec de mauvaises données pour le test addArticle:

 
        /**
	 * Données de tests pour addArticle
	 */
	public function providerAdd()
	{
		return array(
			array('a', 1),
			array('b', 2),
			array(null, 1),
			array('c', null),
			array('c', 0),
		);
	}

Et relançons les tests:

 
PHPUnit 3.7.1 by Sebastian Bergmann.
 
...E.....
 
Time: 0 seconds, Memory: 3.50Mb
 
There was 1 error:
 
1) OrderTest::testAddArticle with data set #2 (NULL, 1)
PHPUnit_Framework_Exception: Argument #1 of PHPUnit_Framework_Assert::assertArrayHasKey() must be a integer or string
 
/home/muriel/test.php:78
 
FAILURES!
Tests: 9, Assertions: 29, Errors: 1.

Et là... Ce n'est pas le résultat attendu! Sur les 3 cas que je pensais en erreur, seul un l'est et pas pour les bonnes raisons.
Ce problème m'est vraiment arrivé en écrivant cet article. J'ai décidé de le laisser car cela montre un avantage des tests unitaires: découvrir les limites de son code!
Dans mon cas, rien n'empêche de rentrer une quantité pour un article avec une reference nulle ou vide (la clé de mon tableau article devient ""), ni de mettre une quantité nulle. Les tests unitaires permettent également d'avoir une regard différent sur notre code et souvant de l'améliorer.

Il n'y aucun commentaire