Pages

Date 23 octobre 2012

Bonus Jobeet ZF2 - Jour 4 - Tests unitaires

Aujourd'hui, un petit bonus (toujours en rapport avec mon tutoriel sur Zend Framework 2). Je vais vous parler des tests unitaires: qu'est-ce que c'est ? à quoi ça sert ? est-ce vraiment utile ?

Les tests unitaires font parti des bonnes pratiques à mettre en place lorsque vous démarrez un nouveau projet.
Peu importe la taille de votre projet, les tests unitaires sont un vrai plus pour la maintenance de votre code.


Les tests unitaires: qu'est-ce donc ?

Il s'agit d'un procédé qui va permettre de tester tout ou une partie d'un module. Le test unitaire permet de vérifier que l'implémentation d'un module (ou d'une fraction d'un module) respecte les spécifications fonctionnelles et qu'il fonctionne en toute circonstance. Le test statue sur le succès ou l'échec de la vérification. Ces tests sont considérés comme essentiels, surtout si l'application est critique.
Dans le cas d'une application non critique, peu de développeurs considèrent les tests unitaires comme important, et les relèguent au second plans: "si on a le temps, on les fera plus tard". Au final, ils ne sont jamais fait. La donne a changé depuis l'apparition de la méthode Extrem Programming (XP), qui remet les test unitaires (appelés "tests du programmeur") au centre de l'activité de développement.
La méthode XP préconise l'écriture des tests en même temps que l'application, voire même avant d'écrire la fonction à tester (Test Driven Development)

Les principes du TDD

Le cycle de développement préconisé par le Test-Driven Development se compose de 5 étapes:
  • écriture d'un test
  • vérifier qu'il échoue (car le code à tester n'existe pas encore): cela teste la validité du test
  • écrire le code suffisant (le plus simple possible) pour passer le test
  • vérifier que le test passe (succès)
  • refactoriser le code: améliorer le code en gardant les mêmes fonctionnalités (utilisation de design pattern,

Les tests unitaires sont-il vraiment utiles?

Quand on parle de tests unitaires à des développeurs connaissant le principe (et n'en ayant jamais écrit) ou à des décideurs (ou des clients), les mêmes remarques reviennent souvent:
  • ça prend du temps...
  • il faut écrire le code plusieurs fois...
  • ça coûte cher à mettre en place...
  • je teste mon application en ligne au fur et à mesure des développements, c'est suffisant...

Mais chacun des ses arguments n'est pas forcément vrai:
  • ça prend du temps: c'est vrai, on perd un peu de temps à écrire les tests et le code de l'application. Mais ce temps sera largement récupéré lors de la maintenance. De plus, les bugs sont détectés au plus tôt et la correction en amont permet de livrer du code de qualité
  • il faut écrire le code plusieurs fois: il ne s'agit pas d'écrire le code plusieurs fois, mais de l'améliorer: amélioration de la conception objet, meilleur cohésion, faible couplage des objets ...
  • ça coûte cher: c'est vrai, ça prend un peu plus de temps lors des développements, mais ce coût est amorti lors de la phase de maintenance ou d'évolution: on voit rapidement s'il y a des régression, on identifie plus rapidement un bug
  • je teste mon application en ligne au fur et à mesure des développements, c'est suffisant: c'est faux, car le développeur teste "involontairement" ce qu'il sait fonctionnel...

Nous ne ferons pas de Test-Driven Development pour ce tutoriel Jobeet pour ZF2. mais nous allons voir comment tester quelques éléments (modèles, contrôleurs). Libre à vous, plus tard, de suivre la méthodologie TDD

Installation de PHPUnit

PHPUnit est un framework de tests unitaires open source, dédié au langage de programmation PHP.

Installons maintenant PHPUnit
pear channel-discover pear.phpunit.de
pear channel-discover pear.symfony.com
pear install phpunit/phpunit

Vérifiez que PHPUnit est correctement installé (la commande suivante devrait vous retourner la version de PHPUnit installé:
phpunit --version

Modification de l'arborescence du projet

A la racine du projet, créez un répertoire tests et recréez, dans ce répertoire, la même arborescence que votre module (comme sur l'image ci-dessous)
Arborescence de notre repertoire tests

Créez un fichier phpunit.xml dans le répertoire tests que vous venez de créer.
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="Bootstrap.php" colors="true">
    <testsuites>
        <testsuite name="jobeet">
            <directory>./</directory>
        </testsuite>
    </testsuites>
</phpunit>

Créons maintenant un fichier Bootstrap.php (sera utilisé par phpunit, on lui a indiqué dans le fichier xml précédent)
chdir(dirname(__DIR__));
include __DIR__ . '/../init_autoloader.php';
Zend\Mvc\Application::init(include 'config/application.config.php');
Nous indiquons dans ce fichier que nous avons besoin du fichier init_autoloader.php (ligne 2) et nous initialisons une instance d'Application avec le fichier de configuration config/application.config.php (ligne 3)

Ceci étant fait, nous pouvons écrire notre premier test.

Notre premier test unitaire

Le premier test unitaire que nous allons écrire est relativement simple. Nous allons tester notre classe Category (dans le répertoire module/Front/src/Front/Model):
use Front\Model\Category;

class CategoryTest extends \PHPUnit_Framework_TestCase
{
    protected $category;
    
    protected function setUp()
    {
        $this->category = new Category();
    }
    
    public function testCategoryInitialState()
    {
        $this->assertNull($this->category->idCategory, '"idCategory" should initially be null');
        $this->assertNull($this->category->name, '"name" should initially be null');
    }
    
    public function testExchangeArraySetsPropertiesCorrectly()
    {
     $data  = array(
            'id_category'     => 123,
            'name'  => 'some title');
    
        $this->category->exchangeArray($data);

        $this->assertSame($data['id_category'], $this->category->idCategory, '"idCategory" was not set correctly');
        $this->assertSame($data['name'], $this->category->name, '"name" was not set correctly');
    }
    
    public function testExchangeArraySetsPropertiesToNullIfKeysAreNotPresent()
    {
        $this->category->exchangeArray(
            array(
                'id_category'     => 1,
                'name'  => 'My Name'
            )
        );
        $this->category->exchangeArray(array());
        $this->assertNull($this->category->idCategory, '"idCategory" should have defaulted to null');
        $this->assertNull($this->category->name, '"name" should have defaulted to null');
    }

    protected function tearDown()
    {
    }
}
Quelques explications:
Le nom de notre classe de test doit être du type {Nom}Test, où {Nom} correspond au nom de la classe à tester (ici, nous testons la classe Category donc le nom de la classe de test est CategoryTest). Cette classe doit étendre \PHPUnit_Framework_TestCase.

La méthode setUp() est exécutée avant toutes les autres méthodes testXXXX(). Elle sert à initialiser les objets dont nous auront besoin dans chaque méthode de tests (ex: pour initialiser une connexion réseau, ...)
La méthode tearDown() permet de réinitialisé les objets initialisés dans setUp().
Lorsque nous lancerons nos tests (en ligne de commande), la méthode setUp() sera appelé avant chaque méthode de test et la méthode tearDown() sera exécuté après chaque méthode de test.

Nos méthode de tests sont:
  • testCategoryInitialState(): vérifie que les attributs de la classe Category sont bien initialisé à null
  • testExchangeArraySetsPropertiesCorrectly(): vérifie que la méthode exchangeArray() initalise bien les attributs de la classe avec les données du tableau
  • testExchangeArraySetsPropertiesToNullIfKeysAreNotPresent(): teste que les attributs sont bien remis à null si la clé du tableau n'existe pas.
Dans la console, allez dans le répertoire test que nous avons créé.
phpunit

Si le test fonctionne, vous devriez obtenir quelque chose comme ça:
Résultat: OK (Avec 3 tests correspondant à nos 3 méthodes, et 6 assertions)

Cette première classe de tests est assez simple. Nous allons maintenant voir un cas plus compliqué: nous allons écrire les tests pour la classe CategoryTable. Cependant, en commençant l'écriture des tests pour cette classe, je me suis rapidement retrouvé bloqué. Et il m'a donc fallu revoir un peu le code de la classe CategoryTable:
namespace Front\Model;

use Zend\Db\Adapter\Adapter;
use Zend\Db\ResultSet\ResultSet;
use Zend\Db\TableGateway\TableGateway;

class CategoryTable
{
    protected $tableGateway;

    public function __construct(TableGateway $tableGateway)
    {
        $this->tableGateway = $tableGateway;
    }

    public function fetchAll()
    {
        $resultSet = $this->tableGateway->select();
        return $resultSet;
    }

    public function getCategory($id)
    {
        $id  = (int)$id;
        $rowset = $this->tableGateway->select(array('id_category' => $id));
        $row = $rowset->current();

        if (!$row) {
            throw new \Exception("Could not find row $id");
        }

        return $row;
    }

    public function saveCategory(Category $category)
    {
        $data = array(
            'id_category' => $category->idCategory,
            'name'  => $category->name
        );

        $id = (int)$category->idCategory;

        if ($id == 0) {
            $this->tableGateway->insert($data);
        } elseif ($this->getCategory($id)) {
            $this->tableGateway->update($data, array('id_category' => $id));
        } else {
            throw new \Exception('Form id does not exist');
        }
    }

    public function deleteCategory($id)
    {
        $this->tableGateway->delete(array('id' => $id));
    }
}
La classe n'étend plus la classe AbstractTableGateway. Le constructeur ne reçoit plus un objet de type Adapter, mais de type TableGateway (implémentant l'interface TableGatewayInterface)

Nous devons aussi modifier le fichier Module.php:
class Module
{
    [...]
    // Nous modifions la méthode getServiceConfig()
    public function getServiceConfig()
    {
        return array(
            'factories' => array(
                'Front\Model\CategoryTable' =>  function($sm) {
                    $tableGateway = $sm->get('CategoryTableGateway');
                    $table = new CategoryTable($tableGateway);
                    return $table;
                },
                'CategoryTableGateway' => function ($sm) {
                 $dbAdapter = $sm->get('Zend\Db\Adapter\Adapter');
                 $resultSetPrototype = new ResultSet();
                 $resultSetPrototype->setArrayObjectPrototype(new Category());
                 return new TableGateway('category', $dbAdapter, null, $resultSetPrototype);
                },
                'Front\Model\JobTable' =>  function($sm) {
                 $tableGateway = $sm->get('JobTableGateway');
                 $table = new JobTable($tableGateway);
                 return $table;
                },
                'JobTableGateway' => function ($sm) {
                 $dbAdapter = $sm->get('Zend\Db\Adapter\Adapter');
                 $resultSetPrototype = new ResultSet();
                 $resultSetPrototype->setArrayObjectPrototype(new Job());
                 return new TableGateway('job', $dbAdapter, null, $resultSetPrototype);
                },
            ),
        );
    }
}
Cette ré-écriture de Module.php et de CategoryTable.php était nécessaire pour pouvoir tester unitairement la classe CategoryTable.

Créez un fichier CategoryTableTest.php dans le répertoire Model de test (là où vous avez créé précédemment la classe CategoryTest)
use Front\Model\Category;
use Zend\Db\ResultSet\ResultSet;
use Front\Model\CategoryTable;

class CategoryTableTest extends \PHPUnit_Framework_TestCase
{
    public function setUp()
    {
    }

    public function testFetchAllReturnsAllCategories()
    {
        $resultSet        = new ResultSet();
        $mockTableGateway = $this->getMock(
                              'Zend\Db\TableGateway\TableGateway',
                              array('select'), array(), '', false
                            );
        
        $mockTableGateway->expects($this->once())
                         ->method('select')
                         ->with()
                         ->will($this->returnValue($resultSet));
    
        $categoryTable = new CategoryTable($mockTableGateway);
        $this->assertSame($resultSet, $categoryTable->fetchAll());
    }
}
La méthode testFetchAllReturnsAllCategories() va nous permettre de tester la méthode fetchAll() de notre class CategoryTable.
Dans ce test, j'introduit la notion de Mock. L'explication sur l'objet Mock dépasse largement le cadre de ce tutoriel, mais il s'agit essentiellement d'un objet qui va prendre la place d'un autre objet et qui va se comporter d'une manière prédéfini. Nous testons ici la classe CategoryTable et non TableGateway (déjà testé par les développeurs du framework), nous voulons juste nous assurer que notre classe CategoryTable interagit correctement avec la classe TableGateway (simulé par notre objet Mock). Nous verifions dans ce test que la méthode fetchAll() de la classe CategoryTable retourne bien un ResultSet, comme la méthode select() de la classe TableGateway.

Ce test devrait fonctionner, nous pouvons écrire les autres tests:
public function testCanRetrieveACategoryByItsId()
    {
        $category = new Category();
        $category->exchangeArray(
            array(
                'id_category' => 125,
                'title'  => 'WEB DESIGNER'
            )
        );
    
        $resultSet = new ResultSet();
        $resultSet->setArrayObjectPrototype(new Category());
        $resultSet->initialize(array($category));
    
        $mockTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('select'), array(), '', false);
        $mockTableGateway->expects($this->once())
                         ->method('select')
                         ->with(array('id_category' => 125))
                         ->will($this->returnValue($resultSet));
    
        $categoryTable = new CategoryTable($mockTableGateway);
        $this->assertSame($category, $categoryTable->getCategory(125));
    }
    
    public function testCanDeleteACategoryByItsId()
    {
        $mockTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('delete'), array(), '', false);
        $mockTableGateway->expects($this->once())
                         ->method('delete')
                         ->with(array('id' => 125));
    
        $categoryTable = new CategoryTable($mockTableGateway);
        $categoryTable->deleteCategory(125);
    }
    
    public function testSaveCategoryWillInsertNewCategoryIfTheyDontAlreadyHaveAnId()
    {
        $categoryData = array('id_category' => null, 'name' => 'GRAPHISTE');
        $category     = new Category();
        $category->exchangeArray($categoryData);
    
        $mockTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('insert'), array(), '', false);
        $mockTableGateway->expects($this->once())
                         ->method('insert')
                         ->with($categoryData);
    
        $categoryTable = new CategoryTable($mockTableGateway);
        $categoryTable->saveCategory($category);
    }
    
    public function testSaveCategoryWillUpdateExistingCategoryIfTheyAlreadyHaveAnId()
    {
        $categoryData = array('id_category' => 123, 'name' => 'Project Manager');
        
        $category     = new Category();
        $category->exchangeArray($categoryData);
    
        $resultSet = new ResultSet();
        $resultSet->setArrayObjectPrototype(new Category());
        $resultSet->initialize(array($category));
    
        $mockTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('select', 'update'), array(), '', false);
        $mockTableGateway->expects($this->once())
                         ->method('select')
                         ->with(array('id_category' => 123))
                         ->will($this->returnValue($resultSet));
        
        $mockTableGateway->expects($this->once())
                         ->method('update')
                         ->with(array('id_category'=>123, 'name' => 'Project Manager'), array('id_category' => 123));
    
        $categoryTable = new CategoryTable($mockTableGateway);
        $categoryTable->saveCategory($category);
    }
    
    public function testExceptionIsThrownWhenGettingNonexistentCategory()
    {
        $resultSet = new ResultSet();
        $resultSet->setArrayObjectPrototype(new Category());
        $resultSet->initialize(array());
    
        $mockTableGateway = $this->getMock('Zend\Db\TableGateway\TableGateway', array('select'), array(), '', false);
        $mockTableGateway->expects($this->once())
                         ->method('select')
                         ->with(array('id_category' => 123))
                         ->will($this->returnValue($resultSet));
    
        $categoryTable = new CategoryTable($mockTableGateway);
    
        try
        {
            $categoryTable->getCategory(123);
        }
        catch (\Exception $e)
        {
            $this->assertSame('Could not find row 123', $e->getMessage());
            return;
        }
    
        $this->fail('Expected exception was not thrown');
    }
Revoyons nos tests, nous avons testé:
  1. Nous pouvons récupérer une catégorie par son ID
  2. Nous pouvons supprimer une catégorie
  3. Nous pouvons enregistrer une nouvelle categorie
  4. Nous pouvons mettre à jour une categorie existante
  5. Nous avons une exception si nous tenter de recuperer une categorie qui n'existe pas
Parfait, notre classe CategoryTable est testée complètement. Pour vous faire la main, écrivez les tests pour les classes Job et JobTable. Passons à la suite, avec un test sur notre contrôleur Index!

Tests du contrôleur Index

Commncez par créer un nouveau fichier IndexControlluerTest.php dans tests/module/Front/src/Front/Controller/

Nous allons devoir fournir quelques informations à l'initialisation du tests (dans la méthode setUp()) pour pouvoir tester notre contrôleur.

Regardez le code ci dessous:
namespace Front\Controller;

use Front\Controller\IndexController;
use Zend\Http\Request;
use Zend\Http\Response;
use Zend\Mvc\MvcEvent;
use Zend\Mvc\Router\RouteMatch;
use PHPUnit_Framework_TestCase;

class IndexControllerTest extends PHPUnit_Framework_TestCase
{
    protected $controller;
    protected $request;
    protected $response;
    protected $routeMatch;
    protected $event;
    
    protected function setUp()
    {
        $bootstrap        = \Zend\Mvc\Application::init(include 'config/application.config.php');
        $this->controller = new IndexController();
        $this->request    = new Request();
        $this->routeMatch = new RouteMatch(array('controller' => 'index'));
        $this->event      = $bootstrap->getMvcEvent();
        $this->event->setRouteMatch($this->routeMatch);
        $this->controller->setEvent($this->event);
        $this->controller->setEventManager($bootstrap->getEventManager());
        $this->controller->setServiceLocator($bootstrap->getServiceManager());
    }
}

Nous commençons par indiquer par initialiser une Application et nous précisons quel contrôleur tester (new IndexController()).
Nous avons aussi besoin d'un objet Zend\Http\Request (sera passé utilisé dans nos tests d'action): il sera passé en paramètre de la méthode dispatch() de notre contrôleur.
L'objet RouteMatch permet de vérifier que notre route est bonne (on peut accéder à notre contrôleur, à notre action, ...)
Enfin, nous récupérons l'EventManager et le ServiceManager de notre application et les indiquons au contrôleur.

Pour le contrôleur, nous allons écrire uniquement 2 tests. Notre contrôleur n'a pour le moment qu'un seule action (index) que nous allons devoir tester. Mais il faudra nous assurer que nous ne pouvons pas appeler une action inexistante et que nous obtenons bien un code d'erreur 404. ajouter donc ces 2 méthodes dans notre class IndexControllerTest:
public function testIndexActionCanBeAccessed()
    {
        $this->routeMatch->setParam('action', 'index');
        $result   = $this->controller->dispatch($this->request);
        $response = $this->controller->getResponse();
        $this->assertEquals(200, $response->getStatusCode());
        $this->assertInstanceOf('Zend\View\Model\ViewModel', $result);
    }
    
    public function test404WhenActionDoesNotExist()
    {
        $this->routeMatch->setParam('action', 'action-qui-nexiste-pas');
        $result   = $this->controller->dispatch($this->request);
        $response = $this->controller->getResponse();
        $this->assertEquals(404, $response->getStatusCode());

Dans le premier test (testIndexActionCanBeAccessed), nous vérifions que l'action est bien accessible (code 200) et que la valeur de retour du contrôleur est bien de type Zend\View\Model\ViewModel

Dans le second test (test404WhenActionDoesNotExist), nous tentons d'accéder à une action (action-qui-nexiste-pas) qui n'existe pas dans notre contrôleur Index (nous devons donc obtenir un code d'erreur 404)

Lancez phpunit: vous devriez obtenir un écran identique:
Tests unitaires passés avec succès
succès des tests

Voilà, nous en avons fini avec ce bonus sur les tests unitaires! Les tests que nous avons vu sont relativement simples. En même temps, l'application Jobeet pour ZF2 est très peu avancé pour le moment: il y a donc peu de tests à écrire.

Comme d'habitude, vous pouvez récupérer les sources sur mon compte Github

4 commentaires:
  1. Félicitation pour cette introduction aux TUs, c'est trop rarement mis en évidence et comme tu le dis si bien pourtant très utile sur le long terme.

    RépondreSupprimer
    Réponses
    1. J'ai pensé que ça serait intéressant à voir avec ZF2.
      ZF2 suit le principe SOLID. Du coup, la mise en place des tests unitaires est un peu simplifié.

      Supprimer
  2. Oui c'est ce que j'ai cru comprendre

    RépondreSupprimer
  3. Vraiment génial ces Tuto ca permet de se plonger comme il faut dans ZF2.
    Je voulais juste dire 2 3 trucs :)
    Il y a une erreur dans le nom du fichier pour tester l'index, c'est IndexControllerTest.php et non IndexControlluerTest.php. Il y a un U en trop.

    ET pour le Module j'ai modifié comme il faut le code mais j'ai du rajouter ceci:
    use Front\Model\Job;
    use Front\Model\Category;
    use Zend\Db\ResultSet\ResultSet;
    use Zend\Db\TableGateway\TableGateway;
    Sinon j'avais des erreurs.

    Et enfin pour la partie JobTableTest va t'il y avoir une correction car j'ai une erreur sur la partie insert et update. :(

    Voila Voila :)

    RépondreSupprimer