Après quelques mois sans nouvelles, voici le cinquième volet de ce projet, si vous le découvrez à peine, je vous invite à commencer par la première étape, je pense que ce sera mieux pour vous plutôt que de prendre en cours de route.
Pour les fidèles, on retrouve encore et toujours notre road map habituelle :

Road map du projet

  1. Pré-requis et architecture
  2. Configuration de Silex
  3. Jouons avec Silex
  4. Ecriture de tests fonctionnels
  5. TDD pour le code métier
  6. Templating

Lors de la précédente étape, j'ai introduit les tests fonctionnels avec PHPunit et les composants de Symfony, j'espère que cela vous a plu bien que ça ne soit pas la partie la plus excitante du projet mais c'était passage obligatoire. Aujourd'hui, on va attaquer la partie vraiment passionnante du projet puisqu'on va développer des tests non pas avec PHPUnit mais avec atoum !
Et peut-être que pour la dernière étape, on fera du SimpleTest ?
Non.

Nous allons mettre de côté Silex aujourd'hui et faire du développement pur, même pas besoin de serveur Web cette fois-ci, la CLI sera notre hôte encore une fois.
Aller, on regarde un peu ce que nous allons faire dans cette étape :

5. TDD pour le code métier

  • Le code métier du projet
  • atoum
    • Présentation
    • Installation
    • Exemple de test
  • Tests unitaires
  • Récupération du code source

Le code métier du projet

Pour me forcer à séparer mes projets, structurer mon code source et rendre mes applications plus modulaires, j'ai décidé de complètement dissocier le code métier de mon portfolio de son projet. Le projet qui sera porteur de la plupart du code métier de mes applications reprend le nom d'un petit chargeur de template que j'avais intitulé Smak (ce qui fait actuellement tourner mon site Web).

Aujourd'hui, je reprends le dépôt et je créer une nouvelle branche pour y entreposer le nouveau code applicatif de mes projets utilisant PHP >= 5.3.3, les namespaces, etc. Et pour tester tout cela, j'ai décidé de donner ma chance à atoum, jeune et puissant framework de tests unitaires qui semble avoir un bel avenir devant lui.

Atoum

atoum-logo

Présentation

Atoum est un nouveau framework de tests unitaires pour PHP écrit par Frédéric Hardy en 2010 PHP 5.3. Le projet est encore assez jeune mais semble évoluer assez rapidement car assez actif et les retours de bugs sont très rapidement résolus. On attend encore son site Web et une documentation plus riche que son Wiki mais notez qu'il a déjà un logo (ci-contre la version pixelart exclusive !).

Entièrement développé (et testé par lui-même) en PHP 5.3.x, il ne nécessite pas d'installation à proprement parlé pour fonctionner, donc s’intègre très aisément à un projet sans devoir installer une nouvelle librairie puisqu'atoum se présente sous la forme d'une archive .phar.

La rédaction des TU se fait de manière assez naturelle une fois qu'on a compris comment atoum les évalue, on peut bien entendu mocker des classes, tester tous les types scalaires, les erreurs, les exceptions, etc. Gros point fort : chaque test à son propre processus PHP ce qui permet donc une isolation parfaite des TU et donc empêche les scénarios de dépendance qui peuvent causer des effets de bord... qui devraient être réservés aux tests fonctionnels.
Bien sûr, on y retrouve la fameuse couverture de code, les statistiques d'usage mémoire, les méthodes de préparation/nettoyage avant les classes et méthodes de test et il s'adapte parfaitement dans des système d'intégration continue comme Jekins ou encore Sismo.

Installation

L'installation d'atoum se fait le plus simplement possible puisqu'il s'agit d'une simple archive .phar. Voici un exemple d'architecture d'application basé sur Smak mettant en œuvre l'utilisation d'atoum :

curl https://downloads.atoum.org/nightly/mageekguy.atoum.phar -o tests/lib/atoum.phar
tree .
├── lib
│   └── Smak
│       └── Portfolio
├── tests
│   ├── bootstrap.php
│   ├── lib
│   │   └── atoum.phar
│   └── units
│       └── Smak
│           └── Portfolio
└── vendor

Dans le bootstrap, on y balance le code standard de bootstrapping :

<?php
use \Symfony\Component\ClassLoader\UniversalClassLoader;
 
require_once __DIR__ . '/../vendor/Symfony/src/Symfony/Component/ClassLoader/UniversalClassLoader.php';
require_once __DIR__ . '/lib/atoum.phar';
 
$loader = new UniversalClassLoader();
$loader->registerNamespaces(array(
    'Symfony'   => __DIR__ . '/../vendor/Symfony/src',
    'Smak'      => __DIR__ . '/../lib'
));
 
$loader->register();

Exemple de test

Tester une classe avec atoum est assez simple... Quand vous créez une classe dans votre application, vous devez créer la même dans votre dossier de tests :

touch lib/Smak/Portfolio/Application.php
touch tests/units/Smak/Portfolio/Application.php

Ensuite, on créer le squelette des classes :

<?php
// lib/Smak/Portfolio/Application.php
 
namespace Smak\Portfolio;
 
class Application
{
}
<?php
// tests/units/Smak/Portfolio/Application.php
 
namespace Smak\Portfolio\tests\units;
 
use mageekguy\atoum;
use Smak\Portfolio;
 
require_once __DIR__ . '/../../../bootstrap.php';
 
class Application extends atoum\test
{
}

Une fois les classes créées, on peut lancer atoum pour tester :

php tests/units/Smak/Portfolio/Application.php
> atoum version nightly-823-201111021458 by Frédéric Hardy (phar:///../Smak/tests/lib/atoum.phar)
> PHP path: /opt/local/bin/php
> PHP version:
=> PHP 5.3.8 (cli) (built: Oct 22 2011 22:30:22)
=> Copyright (c) 1997-2011 The PHP Group
=> Zend Engine v2.3.0, Copyright (c) 1998-2011 Zend Technologies
=>     with Xdebug v2.1.1, Copyright (c) 2002-2011, by Derick Rethans
> Total test duration: 0.00 second.
> Total test memory usage: 0.00 Mb.
> Running duration: 0.04 second.
Success (0 test, 0 method, 0 assertion, 0 error, 0 exception) !

Ok, il ne se passe rien puisque les 2 classes sont vides... Maintenant, nous allons balancer du code dans notre classe de test :

<?php
// tests/units/Smak/Portfolio/Application.php
 
namespace Smak\Portfolio\tests\units;
 
use mageekguy\atoum;
use Smak\Portfolio;
 
require_once __DIR__ . '/../../../bootstrap.php';
 
class Application extends atoum\test
{
    public function testImplementInterface()
    {
        $this->assert->class('\Smak\Portfolio\Application')
             ->hasInterface('\Countable');
    }
}

On relance le test et là, forcément, ça échoue :

php tests/units/Smak/Portfolio/Application.php        
> atoum version nightly-823-201111021458 by Frédéric Hardy (phar:///../Smak/tests/lib/atoum.phar)
> PHP path: /opt/local/bin/php
> PHP version:
=> PHP 5.3.8 (cli) (built: Oct 22 2011 22:30:22)
=> Copyright (c) 1997-2011 The PHP Group
=> Zend Engine v2.3.0, Copyright (c) 1998-2011 Zend Technologies
=>     with Xdebug v2.1.1, Copyright (c) 2002-2011, by Derick Rethans
> Smak\Portfolio\tests\units\Application...
[F___________________________________________________________][1/1]
=> Test duration: 0.00 second.
=> Memory usage: 0.00 Mb.
> Total test duration: 0.00 second.
> Total test memory usage: 0.00 Mb.
> Running duration: 0.13 second.
Failure (1 test, 1 method, 1 failure, 0 error, 0 exception) !
> There is 1 failure:
=> Smak\Portfolio\tests\units\Application::testImplementInterface():
In file /../Smak/tests/units/Smak/Portfolio/Application.php on line 15, mageekguy\atoum\asserters\phpClass::hasInterface() failed: Class Smak\Portfolio\Application does not implement interface \Countable
zsh: exit 1     php tests/units/Smak/Portfolio/Application.php

Remarque : les couleurs dans la console sont très représentatives du status des tests.

Il suffira juste de faire implémenter l'interface \Count à notre classe Application afin de faire passer le test et sans oublier de rajouter la méthode count().

Quoiqu'il en soit, à première vue, l'écriture de test unitaire ne semble pas beaucoup plus complexe qu'avec PHPUnit si ce n'est que l'enchaînement des méthodes paraît plus logique : on attend que la classe <mon classe> implémente <nom interface>.
La lecture des tests est bien plus naturelle et intuitive et mine de rien, c'est bien pratique surtout quand on se relit un test écrit plusieurs mois auparavant.

N'hésitez pas à visualiser l'aide d'atoum pour regarder un peu les options qu'il propose. Typiquement, pour lancer les tests sur tout un dossier :

php tests/lib/atoum.phar -d tests/units

Quant on lance constament les tests pendant le développement, ça peut être assez lourd de voir la couverture de code s'afficher dans la console et d'ailleurs, ce serait vraiment sympa qu'elle soit désactivée par défaut :

php tests/lib/atoum.phar -d tests/units -ncc

Retrouvez encore plus d'exemples sur le blog de Gérald Croës.

Tests unitaires

Bon, on ne va pas développer tous les tests unitaires mais je vais vous en montrer un à titre d'exemple afin que vous puissiez voir un cas concret : celui de mon projet. Le portfolio manipule des photos donc j'utilise beaucoup le composant Finder de Symfony et en conséquence le système de fichier ; avec PHPUnit, j'utilisais vfsStream mais ne répondant pas à mes besoins, j'ai rapidement développé une petite classe pour générer mes dossiers et fichiers dans mes tests : Fs.php.

Voici donc ma classe de test pour la classe Set.php dans laquelle j'ai conservé ici que les parties intéressantes du code :

<?php
 
namespace Smak\Portfolio\tests\units;
 
use mageekguy\atoum;
use Smak\Portfolio;
use tests\Fs;
 
require_once __DIR__ . '/../../../bootstrap.php';
 
class Set extends atoum\test
{
    const FS_REL = '/../../../fs';
 
    // Unit class setup (run once)
    public function setUp()
    {
        $fs = new Fs(__DIR__ . self::FS_REL, $this->_fsTreeProvider());
        $fs->setDiffTime(true);
        $fs->build();
        $this->assert->boolean($fs->isBuilt())
             ->isTrue();
    }
 
    // Unit method setup (run before every test method)
    public function beforeTestMethod($method)
    {
        $this->fs = new Fs(__DIR__ . self::FS_REL, $this->_fsTreeProvider());
        $set_root = new \SplFileInfo($this->fs->getRoot() . '/Travels/Chile');
        $this->instance = new \Smak\Portfolio\Set($set_root);
    }
 
    public function testPhotoExtensions()
    {
        $set = $this->instance;
 
        $this->assert->array($set->getPhotoExtensions())
             ->isEqualTo(array('.jpg', '.jpeg', '.jpf', '.png'));
 
        $this->assert->object($set->setPhotoExtensions(
            $new_ext = array('.tiff', '.gif')
        ))->isInstanceOf('\Smak\Portfolio\Set');
 
        $this->assert->array($set->getPhotoExtensions())
             ->isEqualTo($new_ext);
 
        $this->assert->exception(function() use ($set) {
            $set->setPhotoExtensions(array());
        })->isInstanceOf('\InvalidArgumentException');
    }
 
    public function testGetPhotoById()
    {
        $set = $this->instance;
        $tree = $this->fs->getTree();
        $tree = $tree['Travels']['Chile'];
        array_pop($tree);
        sort($tree);
 
        $this->assert->exception(function() use ($set) {
            $set->getPhotoById("foo");
        })->isInstanceOf('\InvalidArgumentException');
 
        $this->assert->string($set->getPhotoById(2)->getFileName())
             ->isEqualTo($tree[2]);
 
        $this->assert->exception(function() use ($set) {
            $set->getPhotoById(123);
        })->isInstanceOf('\OutOfRangeException');
    }
 
    public function testGetPhotoByName()
    {
        $set = $this->instance;
        $tree = $this->fs->getTree();
        $tree = $tree['Travels']['Chile'];
        array_pop($tree);
        sort($tree);
 
        $this->assert->exception(function() use ($set) {
            $set->getPhotoByName(23.34);
        })->isInstanceOf('\InvalidArgumentException');
 
        $this->assert->string($set->getPhotoByName('sample-4')->getFileName())
             ->isEqualTo($tree[3]);
 
        $this->assert->exception(function() use ($set) {
            $set->getPhotoByName('foobar');
        })->isInstanceOf('\UnexpectedValueException');
    }
 
    public function testGetPhotoByMTimeNewestFirst()
    {
        $tree = $this->fs->getTree();
        $expected = $tree['Travels']['Chile'];
        array_pop($expected);
        $results = array();
 
        foreach ($this->instance->sortByNewest()->getPhotos() as $file) {
            $results[] = $file->getFilename();
        }
 
        $this->assert->array(array_reverse($expected))
             ->isEqualTo($results);
    }
 
    // Unit class cleaner (run once)
    public function tearDown()
    {
        $fs = new Fs(__DIR__ . self::FS_REL, $this->_fsTreeProvider());
        $fs->clear();
    }
 
    // File system tree provider
    private function _fsTreeProvider()
    {
        return array('Travels'  => array(
            'Chile' => array(
                'sample-1.jpeg',
                'sample-3.jpG',
                'sample-2.jpg',
                'sample-4.png',
                'chile.twig'
        )));
    }
}

Note : n'étant pas un expert avec atoum, si vous avez déjà fait des tests bien complexes ou si vous avez des petites suggestions à apporter sur ma manière de structurer mes tests, je suis toujours disposé à accueillir vos remarques.

Si vous êtes curieux de voir le code source complet de la classe de test, vous pouvez la voir sur Github et quand au code permettant de valider ce test, il se trouve aussi sur mon dépôt : Set.php.

Récupération du code source

Si ça n'était pas encore le cas, allez dans le projet Portfolio et ajoutez le code source de Smak dans vendor pour prendre un peu d'avance sur la prochaine fois :

git submodule add git://github.com/eexit/Smak.git vendor
git submodule update --init

Et si vous l'aviez déjà, un simple pull vous permettra de récupérer le code source :

cd vendor/Smak
git checkout master
git pull

Ce sera tout pour aujourd'hui, je voulais vous présenter atoum et un bout de mon code métier que vous pouvez checkouter à tout moment. Je sais que l'absence d'avancement et de communication autour du projet n'est malheureusement pas volontaire puisque ma dernière année me demande pas mal d'organisation, surtout vis-à-vis du stage. Atoum est vraiment un plaisir à utiliser de par sa simplicité et sa légèreté donc n'hésitez pas à l'essayer car l'essayer, c'est l'adopter.
Le dernier volet de projet qui j'espère arrivera avant la fin de l'année se portera sur l'intégration avec Twig mais en attendant, n'oubliez pas de vous tenir informé via @JorisBerthelot!

Aller à l'étape suivante

‹ Aller à l'étape précédente


Joris Berthelot