Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save jprivet-dev/6d8e8f3a439936816726c5f36e146e4d to your computer and use it in GitHub Desktop.
Save jprivet-dev/6d8e8f3a439936816726c5f36e146e4d to your computer and use it in GitHub Desktop.
[Mémo] Forum PHP 2017 - Écrire des tests pour le long terme (Charles Desneuf) | https://youtu.be/Bjw6N7bjzf4

[Mémo] Forum PHP 2017 - Écrire des tests pour le long terme (Charles Desneuf)

Sommaire

1. Ressources

Écrire des tests pour le long terme - Charles Desneuf (@Selrahcd) - Forum PHP 2017 : https://youtu.be/Bjw6N7bjzf4

Un grand merci à Charles Desneuf pour sa relecture :)

📎
Retrouvez le listing de mes mémos, tips et autres sur https://github.com/jprivet-dev/memos

2. Livres de référence

  • Working Effectively with Legacy Code, de Michael Feathers

  • Practical Object-Oriented Design: An Agile Primer Using Ruby, de Sandi Metz

  • Growing Object-Oriented Software, Guided by Tests, de Steve Freeman & Nat Pryce

  • Understanding the Four Rules of Simple Design, de Corey Haines

  • Living Documentation by design, with Domain-Driven Design, de Cyrille Martraire

  • Test Driven Development: By Example, de Kent Beck

3. Pourquoi faire des tests ?

  • Découvrir des bugs : c’est le but premier

  • Permettre de réfactorer : on va pouvoir modifier le design de son application tout en s’assurant de ne pas modifier son comportement (si on modifie le comportement, ce n’est plus du refactoring)

  • Documenter : les tests sont un premier point d’entrée pour un développeur. Ils permettent de découvrir ce que réalise le système, à travers les exemples et le nom des tests.

  • Réfléchir à l’interface du système : les tests (qui sont idéalement les premiers clients de notre système) permettent de voir si notre système est facile d’utilisation

  • Détecter des problèmes de design : un test compliqué à mettre en place est assez indicatif d’un problème de design

4. Quelles doivent être les principales qualités des tests ?

  • Ils sont indépendants de l’implémentation : dans le but de pouvoir réfactorer. Si l’on ne modifie que le design de notre application, sans en modifier son comportement, alors les tests ne devraient pas échouer.

  • Ils décrivent correctement le comportement du système : on est ainsi en mesure de comprendre ce que fait le système (cela permet, à terme, de générer de la documentation)

  • Ils ont un résultat prédictible : on veut que les tests se déroulent toujours de la même manière, et éviter qu’ils échouent de façon alétoire (en particulier s’ils se basent sur une période donnée, sur un random, …​)

  • Ils sont extrêmement rapides : afin de les exécuter souvent

  • Ils sont indépendants des uns des autres : on évite que le résultat d’un premier test influe sur le résultat des tests suivants

  • Ils sont simples à mettre en place : on réduit ainsi le temps nécessaire à la création des tests

5. Frameworks utilisés pour la conférence

  • Framework de test : PHPUnit

  • Framework de mock : Prophecy (qui vient de PhpSpec)

6. Anatomie d’un test

class ServeurTest extends \PHPUnit_Framework_TestCase
{
    public function test_annonce_le_plat_du_jour() // (1)
    {
        // Arrange
        $cuisine = $this->prophesize(Cuisine::class);

        $cuisine
            ->quelEstLePlatDuJour()
            ->willReturn('Gencive de porc !');

        $serveur = new Serveur($cuisine->reveal());

        // Act
        $annonce = $serveur->annonceLePlatDuJour();

        // Assert
        $this->assertEquals(
            'Le chef vous propose aujourd\'hui des gencives de porc',
            $annonce
        );
    }
}
  1. Le nom de notre test : une spécification, une règle métier

6.1. Le nom de notre test : une spécification, une règle métier

Les différentes manières de nommer une méthode de test :

public function testAnnonceLePlatDuJour()

public function test_annonce_le_plat_du_jour()

/**
 * @test
 */
public function annonce_le_plat_du_jour()

/**
 * @test
 */
public function annonce le plat du jour()
📎
Tous les tests sont au présent, et non au conditionnel, car on voit ce que fait notre objet, et non ce qu’il devrait faire.

Il est possible de générer de la documentation à partir des tests, sous forme de texte ou de HTML :

$ recette phpunit --testdox tests/ServeurTest.php

PHPUnit 4.7.6 by Sebastian Bergmann and contributors.

Serveur

 [x] annonce le plat du jour
 [x] transmet une commande pour le plat du jour a la cuisine

Nous avons ainsi un document qui décrit notre comportement métier, qu’il est possible de transmettre aux personnes "business" de l’entreprise.

Elles peuvents ainsi vérifier les comportements implémentés et confirmer si c’est bien ce qu’elles attendent du système.

6.2. A l’intérieur de la méthode du test

📎
Si le nom de la méthode est notre spécification, notre règle métier, alors l’intérieur est un cas particulier qui illustre cette règle métier.

On doit retrouver dans nos tests le vocabulaire exprimé ailleurs : on va relier notre code au vocabulaire des tests, donc à la documentation, donc au vocabulaire métier :

class ServeurTest ... {
    function test_annonce_le_plat_du_jour() {
        ...
        $serveur = new Serveur(...);
        ... = $serveur->annonceLePlatDuJour();
        ...
    }
}

6.3. Le pattern Arrange Act Assert

L’objectif est d’avoir une structuration similaire, un format qui soit reconnaissable dans chacun de nos tests :

class MesTests ... {
    function test_pattern() {
        // 1 - Arrange
            // partie dans laquelle on set le monde dans lequel
            // va être exécuter le test (les mocks, les objets instanciés, ...)

        // 2 - Act
            // partie dans laquelle va démarrer notre action,
            // dont le résultat devra être vérifé

        // 3 - Assert
            // partie dans laquelle on va s'assurer
            // que tout s'est bien passé
    }
}

NOTE : On peut faire un parallèle assez fort entre les patterns Arrange Act Assert et Given When Then (en BDD).

7. Les mock & spy

7.1. Ex. : couplage avec le mock, dans la partie Arrange

// ServeurTest - Arrange d'origine
$cuisine
    ->quelEstLePlatDuJour()
    ->willReturn('Gencive de porc !');
// ServeurTest - Arrange couplé
$cuisine
    ->quelEstLePlatDuJour()
    ->willReturn('Gencive de porc !')
    ->souldBeCalledTimes(1);

Avec ce couplage, le test échouera dans les deux cas suivants, même si les implémentations sont 'correctes' :

class Serveur
{
    public function annonceLeMenu()
    {
        $plat = $this->cuisine->quelEstLePlatDuJour();

        // fait autre chose et oublie le menu...
        $plat = $this->cuisine->quelEstLePlatDuJour();

        return sprintf(
            'Le chef vous propose aujourd\'hui %s',
            $this->transformNomDuPlat($plat)
        );
    }
}
class Serveur
{
    public function annonceLeMenu()
    {
        $plat = 'Gencives de porc !'; // (disons que c'est récupéré en cache...)

        return sprintf(
            'Le chef vous propose aujourd\'hui %s',
            $this->transformNomDuPlat($plat)
        );
    }
}

7.2. Ex. : utilisation d’un spy dans la partie Assert

class ServeurTest extends \PHPUnit_Framework_TestCase
{
    public function test_transmet_une_commande_pour_le_plat_du_jour_a_la_cuisine()
    {
        // Arrange
        $cuisine = $this->prophesize(Cuisine::class);
        $serveur = new Serveur($cuisine->reveal());

        // Act
        $serveur->recoitUneCommandePourUnPlatDuJour();

        // Assert
        $cuisine
            ->ilFautUnPlatDuJour()
            ->shouldHaveBeenCalledTimes(1); // (1)
    }
}
  1. Prophesize fournit un object spy qui reçoit l’ensemble des appels effectués pendant le test.

Avec ce shouldHaveBeenCalledTimes(), les implémentations suivantes ne seront pas possibles :

class Serveur
{
    public function recoitUneCommandePourUnPlatDuJour()
    {
    }
}
class Serveur
{
    public function recoitUneCommandePourUnPlatDuJour()
    {
        for($i = 0; $i < 100; $i++) {
            $this->cuisine->ilFautUnPlatDuJour();
        }
    }
}

Ainsi ce test ne permet qu’une seule implémentation :

class Serveur
{
    public function recoitUneCommandePourUnPlatDuJour()
    {
        $this->cuisine->ilFautUnPlatDuJour();
    }
}
📎
On va principalement retrouver les mock dans la partie Arrange, et les spy dans la partie Assert.
💡
On vaut mieux éviter les vérifications sur les mock, et privilégier les spy pour cela.

8. Egalité des objets (Pizza Margherita)

8.1. Premier cas de figure : ce que l’on veut vérifier n’est pas vérifier

On veut vérifier la préparation d’une pizza margherita maison :

class PizzaioloTest extends \PHPUnit_Framework_TestCase
{
    public function test_prepare_une_pizza_margherita_maison() // (1)
    {
        // Arrange
        $pizzaiolo = new Pizzaiolo();

        // Act
        $pizza = $pizzaiolo->preparePizzaMargheritaMaison();

        // Assert
        $this->assertEquals(
            ['pate', 'sauce tomate', 'basilic', 'mozzarella'],
            $pizza->ingredients() // (2)
        );
    }
}
  1. On veut vérifier que le pizzaiolo prépare une pizza margherita maison…​

  2. Or on ne vérifie que les ingrédients, sans savoir si la préparation est bien maison

Ce test pourrait très bien passer avec la pizza surgelée suivante :

class PizzaSurgelee
{
    public function __construct($ingredients)
    {
        $this->ingredients = $ingredients;
    }

    public function ingredients()
    {
        return $this->ingredients;
    }
}
class Pizzaiolo
{
    public function preparePizzaMargheritaMaison()
    {
        $pizzaSurgelee = $this->acheteUnePizzaMargheritaSurgelee();
        return $this->rechauffeLaPizza($pizzaSurgelee);
    }
}

8.2. Cas de figure rectifié

💡
Au lieu de ne vérifier qu’un tableau d’ingrédients, on va instancier une PizzaMaison à comparer.

Le test précédent peut évoluer ainsi :

// PizzaioloTest - Assert d'origine
$this->assertEquals(
    ['pate', 'sauce tomate', 'basilic', 'mozzarella'],
    $pizza->ingredients()
);
// PizzaioloTest - Assert ajusté
$this->assertEquals(
    new PizzaMaison(['pate', 'sauce tomate', 'basilic', 'mozzarella']),
    $pizza
);
class PizzaMaison
{
    public function __construct($ingredients)
    {
        $this->ingredients = $ingredients;
    }

    //public function ingredients() // (1)
    //{
    //    return $this->ingredients;
    //}
}
  1. Le getter ingredients() n’est plus nécessaire car il n’est plus utilisé dans le test

Cet ajustement du test force le pizzaiolo à bien suivre tout le processus de préparation :

class Pizzaiolo
{
    public function preparePizzaMargheritaMaison()
    {
        // $pizzaSurgelee = $this->acheteUnePizzaMargheritaSurgelee();
        // return $this->rechauffeLaPizza($pizzaSurgelee);

        $pateAPizza = new PateAPizza();
        $this->etaleLaPate($pateAPizza);
        $this->enduitDeSauceTomate($pateAPizza);
        $this->disposeLaMozzarella($pateAPizza);
        $this->placeLeBasilic($pateAPizza);

        return $this->cuire($pateAPizza);
    }
}

9. Test Class Per Fixture (Endives cuites & crues)

9.1. Première approche

Nous voulons faire des tests sur des endives crues et cuites :

class EndiveTest extends \PHPUnit_Framework_TestCase
{
    public function test_une_endive_de_base_est_crue() // (1)
    {
        $endive = new Endive();
        $this->assertTrue($endive->crue());
    }

    public function test_une_endive_de_base_est_bonne() // (1)
    {
        $endive = new Endive();
        $this->assertTrue($endive->bonne());
    }

    public function test_une_endive_cuite_nest_pas_crue() // (2)
    {
        $endive = new Endive();
        $endive->cuire();
        $this->assertFalse($endive->crue());
    }

    public function test_une_endive_cuite_nest_pas_bonne() // (2)
    {
        $endive = new Endive();
        $endive->cuire();
        $this->assertFalse($endive->bonne());
    }
}
  1. Les tests des endives crues ont une structure identique

  2. Les tests des endives cuites ont une structure identique

9.2. Utilisation d’un setUp()

💡
On va utiliser la méthode setUp() fourni par PHPUnit, qui va nous aider à créer une instance d’endive commune à nos tests.

Nous pouvons scinder les tests précédents en 2 classes de tests :

class UneEndiveDeBaseTest extends \PHPUnit_Framework_TestCase
{
    public function setUp()
    {
        $this->endive = new Endive();
    }

    public function test_est_crue()
    {
        $this->assertTrue($this->endive->crue());
    }

    public function test_est_bonne()
    {
        $this->assertTrue($this->endive->bonne());
    }
}
class UneEndiveCuiteTest extends \PHPUnit_Framework_TestCase
{
    public function setUp()
    {
        $this->endive = new Endive();
        $this->endive->cuire();
    }

    public function test_nest_pas_crue()
    {
        $this->assertFalse($this->endive->crue());
    }

    public function test_nest_pas_bonne()
    {
        $this->assertFalse($this->endive->bonne());
    }
}

9.3. On revient à l’idée de documentation (une fois de plus)

Nous pouvons extraire facilement des tests les règles métiers pour la documentation :

class UneEndiveDeBase
{
    public function _est_crue()
    public function _est_bonne()
}
class UneEndiveCuite
{
    public function _nest_pas_crue()
    public function _nest_pas_bonne()
}

9.4. Constructeur nommé : ajuster un mismatch au niveau des noms donnés

📎
Nous pouvons voir ici comment les tests peuvent nous permettre d’avoir un retour sur notre implémentation, sur l’interface de notre système.

On peut noter que UneEndiveDeBase est un nom un peu curieux. On peut challenger le métier et demander un nommage plus pertinent. UneEndiveDeBase est au final une UneEndiveCrue :

class UneEndiveCrueTest extends \PHPUnit_Framework_TestCase // (1)
{
    public function setUp()
    {
        $this->endive = new Endive(); // (2)
    }
}
  1. On utilise UneEndiveCrue à la place de UneEndiveDeBase

  2. On peut noter un mismatch entre le nom de la classe et le setUp()

💡
On peut utiliser dans ce cas un constructeur nommé, avec une static factory. On va ainsi avoir un match entre le vocabulaire métier et le vocabulaire utilisé dans notre code.
class Endive
{
    public static function crue()
    {
       return new self();
    }

    public static function cuite()
    {
       $endive = static::crue();
       return $endive->cuire();
    }
}
class UneEndiveCrueTest extends \PHPUnit_Framework_TestCase
{
    public function setUp()
    {
        $this->endive = Endive::crue();
    }
}
class UneEndiveCuiteTest extends \PHPUnit_Framework_TestCase
{
    public function setUp()
    {
        $this->endive = Endive::cuite();
    }
}

10. Don’t mock what you don’t own (Gratin croustillant & Four)

⚠️
Charles Desneuf utilise dans sa vidéo Ikéa et la langue suédoise pour cibler un "language étrangé" à son application. Pour ma fiche mémo, j’ai préféré utiliser le français afin de cibler plus facilement les concepts exploités dans la bibliothèque extérieure et qui utilise son propre vocabulaire métier.

Nous voulons tester la cuisson d’un gratin :

use CNRS\CompartimentHauteTemperature;

class GratinTest extends \PHPUnit_Framework_TestCase
{
    public function test_cuit_au_four_donne_un_gratin_croustillant()
    {
        // Arrange
        $gratin = new Gratin();
        $compartimentHT = new CompartimentHauteTemperature();

        // Act
        $plat = $gratin->cuireAvec($compartimentHT, self::50_MINUTES); (1)

        // Assert
        $this->assertEqual(new GratinCroutillant(), $plat);
    }
}
  1. TIP: pour la lisibilité, utiliser plutôt la constante self::50_MINUTES à la place de 50 * 60

namespace CNRS;

class CompartimentHauteTemperature
{
    public function exposerALaChaleur($plat, $duree): PlatChaud
    {
        spleep($duree);
        return $this->faitUnPlatChaud(get_class($plat), $duree); (1)
    }
}
  1. La méthode faitUnPlatChaud() recherche dans son dictionnaire le plat final qui correspond au plat donné cuit pendant une certaine durée

⚠️
Dans ce contexte, le test va durer 50 minutes, or nous voulons des tests rapides !

10.1. Rendre des tests plus rapide avec des mocks

class GratinTest extends \PHPUnit_Framework_TestCase
{
    public function test_cuit_au_four_donne_un_gratin_croustillant()
    {
        // Arrange
        $gratin = new Gratin();
        //$compartimentHT = new CompartimentHauteTemperature();

        $plat = $this->prophesize(Plat::class);
        $plat
            ->donneLePlatChaud()
            ->willReturn(new GratinCroustillant());

        $compartimentHT = $this->prophesize(CompartimentHauteTemperature::class);
        $compartimentHT
            ->exposerALaChaleur($plat, self::50_MINUTES)
            ->willReturn(function() use($plat) {
                return $plat->reveal();
            });

        // Act
        //$plat = $gratin->cuireAvec($compartimentHT, self::50_MINUTES); (1)
        $plat = $gratin->cuireAvec($compartimentHT->reveal(), self::50_MINUTES);

        // Assert
        $this->assertEqual(new GratinCroutillant(), $plat);
    }
}

Le Arrange de ce test devient assez compliqué, avec un mock $compartimentHT qui retourne un mock $plat.

⚠️
Dans ce contexte, on mock un objet qui ne dépend pas de nous (CNRS) et qu’on ne connait pas !

Si la méthode CompartimentHauteTemperature::exposerALaChaleur() est modifiée entre temps, notre test GratinTest passera avec succès, alors qu’en réalité le plat sortira froid en prod :

namespace CNRS;

class CompartimentHauteTemperature
{
    public function exposerALaChaleur($plat, $duree): PlatChaud
    {
        spleep($duree);
        return $this->faitUnPlatFroid(get_class($plat), $duree); // (1)
    }
}
  1. La méthode est passée de faitUnPlatChaud() à faitUnPlatFroid()

En revanche, la première version du test, qui lui durait 50 minutes, aurait échoué !

10.2. Couper la dépendance à la bibliothèque extérieure

Nous allons, dans un premier temps, couper la dépendance avec la bibliothèque extérieure du CNRS, en créant une interface FourInterface, avec le vocabulaire qui nous intéresse (FourInterface à la place de CompartimentHauteTemperature) :

interface FourInterface {
    public function cuire($plat, $duree);
}

On simplifie ainsi la partie Arrange du test, dans lequel on retrouve notre vocabulaire :

class GratinTest extends \PHPUnit_Framework_TestCase
{
    public function test_cuit_au_four_donne_un_gratin_croustillant()
    {
        // Arrange
        $gratin = new Gratin();

        $four = $this->prophesize(FourInterface::class);
        $four
            ->cuire($gratin, self::50_MINUTES)
            ->willReturn(new GratinCroustillant());

        // Act
        $plat = $gratin->cuireAvec($four->reveal(), self::50_MINUTES);

        // Assert
        $this->assertEqual(new GratinCroutillant(), $plat);
    }
}

On peut faire évoluer notre Gratin avec notre vocabulaire métier :

// Ancienne version
class Gratin
{
    public function cuireAvec(CompartimentHauteTemperature $compartimentHT, $duree)
    {
        $platExposeALaChaleur = $compartimentHT->exposerALaChaleur($this, $duree);
        return $platExposeALaChaleur->donneLePlatChaud();
    }
}
// Nouvelle version
class Gratin
{
    public function cuireAvec(FourInterface $four, $duree)
    {
        return $four->cuire($this, $duree);
    }
}

D’un côté nous avons notre FourInterface qui nous permet de tester une cuisson hypothétique, mais nous devons faire un test en intégration d’une implémentation de four réel, avec un temps plus court :

class FourDeLaCuisine implements FourInterface
{
    public function __construct(CompartimentHauteTemperature $compartimentHT)
    {
        $this->compartimentHT = $compartimentHT;
    }

    public function cuire($plat, $duree)
    {
        $platExposeALaChaleur = $compartimentHT->exposerALaChaleur($plat, $duree);
        return $platExposeALaChaleur->donneLePlatChaud();
    }
}
class GratinTest extends \PHPUnit_Framework_TestCase
{
    public function test_cuit_un_fromage_de_chevre_en_chevre_chaud()
    {
        // Arrange
        $four = new FourDeLaCuisine(new CompartimentHauteTemperature());

        // Act
        $plat = $four->cuit(new FromageDeChevre(), self::10_MINUTES);

        // Assert
        $this->assertEqual(new ChevreChaud(), $plat);
    }
}

11. Ne pas dépendre du système quand on utilise les dates (Quiche)

11.1. Exemple de test avec une date

class Quiche
{
    public __construct(Datetime $dateCuisson)
    {
        $this->dateCuisson = $dateCuisson;
    }

    public function peutEtreConsommee()
    {
        return $this->dateCuisson->modify('+2 day') < new DateTime();
    }
}
class QuicheTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_consommable_2_jours_apres_avoir_ete_cuite()
    {
        $quiche = new Quiche(new DateTime('2017-10-06'));
        $this->assertTrue($quiche->peutEtreConsommee());
    }
}
⚠️
Dans ce contexte, le test passera bien du 2017-10-06 au 2017-10-08, mais échouera systématiquement à partir du 2017-10-09.

11.2. Coupure de la dépendance avec le temps

On va couper la dépendance au temps de notre système, en créant une Horloge, qu’on pourra injecter à Quiche :

class Horloge
{
    public function maintenant() {
        return new DateTime();
    }
}
class Quiche
{
    public __construct(Datetime $dateCuisson)
    {
        $this->dateCuisson = $dateCuisson;
    }

    public function peutEtreConsommee(Horloge $horloge)
    {
        return $this->dateCuisson->modify('+2 day') < $horloge->maintenant();
    }
}

On pourra ainsi mocker le temps :

class QuicheTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_consommable_2_jours_apres_avoir_ete_cuite()
    {
        $horloge = $this->prophesize(Horloge::class);
        $horloge
            ->maintenant()
            ->willReturn(new DateTime('2017-10-07'));

        $quiche = new Quiche(new DateTime('2017-10-06'));
        $this->assertTrue($quiche->peutEtreConsommee($horloge));
    }
}

11.3. Détourner le temps en modifiant le design

Il est possible de contourner le problème en proposant de savoir si la quiche est consommable à une date, au lieu de juste savoir si elle est consommable maintenant :

class Quiche
{
    public __construct(Datetime $dateCuisson)
    {
        $this->dateCuisson = $dateCuisson;
    }

    public function peutEtreConsommeeLe(DateTime $dateDeConsommation)
    {
        return $this->dateCuisson->modify('+2 day') < $dateDeConsommation;
    }
}

On peut ainsi injecter la date directement, éviter de mocker et permettre de faire un test plus simple :

class QuicheTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_consommable_2_jours_apres_avoir_ete_cuite()
    {
        $quiche = new Quiche(new DateTime('2017-10-06'));
        $quichePeutEtreConsommee = $quiche->peutEtreConsommeeLe(new DateTime('2017-10-07'));

        $this->assertTrue($quichePeutEtreConsommee);
    }
}

12. Object Mother (Plat de pâtes)

12.1. Utilisation d’un objet avec des paramètres fixes

class PlatDePatesTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_bon_quand_il_contient_de_la_viande_du_fromage_et_de_la_sauce()
    {
        $plateDePates = new PlatDePates([
            new Ingredient('fromage', 'parmesan'),
            new Ingredient('viande', 'lardons'),
            new Ingredient('sauce', 'creme fraiche'),
            new Ingredient('legume', 'oignon'),
        ]);

        $this->assertTrue($platDePates->estBon());
    }
}
class Ingredient
{
    public function __construct($type, $nom)
    {
        $this->type = $type;
        $this->nom = $nom;
    }
}

Pour le moment la classe Ingredient contient 2 paramètres, mais il sera possible d’ajouter d’autres paramètres, comme la date limite de consommation par exemple :

class Ingredient
{
    public function __construct($type, $nom, $DLC)
    {
        $this->type = $type;
        $this->nom = $nom;
        $this->DLC = $DLC; // Nouveau paramètre !!!
    }
}

Cela veut dire qu’il va falloir modifier tous les endroits où la classe Ingredient a été utilisée :

class PlatDePatesTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_bon_quand_il_contient_de_la_viande_du_fromage_et_de_la_sauce()
    {
        $plateDePates = new PlatDePates([
            new Ingredient('fromage', 'parmesan', new DateTime()),          // <-- !!!
            new Ingredient('viande', 'lardons', new DateTime()),            // <-- !!!
            new Ingredient('sauce', 'creme fraiche', new DateTime()),       // <-- !!!
            new Ingredient('legume', 'oignon', new DateTime()),             // <-- !!!
        ]);

        $this->assertTrue($platDePates->estBon());
    }
}

12.2. Utilisation d’une classe factory

Pour éviter de modifier la classe Ingredient dans tous nos tests, on va utiliser une factory pour centraliser les modifications nécessaires :

class IngredientMother {
    public static function fromage()
    {
        return new Ingredient('fromage', 'parmesan', new DateTime());
    }

    public static function viande()
    {
        return new Ingredient('viande', 'lardons', new DateTime());
    }

    public static function sauce()
    {
        return new Ingredient('sauce', 'creme fraiche', new DateTime());
    }

    public static function legume()
    {
        return new Ingredient('legume', 'oignon', new DateTime());
    }
}
class PlatDePatesTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_bon_quand_il_contient_de_la_viande_du_fromage_et_de_la_sauce()
    {
        $plateDePates = new PlatDePates([
            IngredientMother::fromage(),
            IngredientMother::viande(),
            IngredientMother::sauce(),
            IngredientMother::legume(),
        ]);

        $this->assertTrue($platDePates->estBon());
    }
}

12.3. Utilisation de fonctions autour de nos méthodes

Avec cette méthode, on peut aller un peu plus loin et rendre les choses extrêmement lisibles :

function unFromage()
{
    IngredientMother::fromage();
}
function uneViande()
{
    IngredientMother::viande();
}
function uneSauce()
{
    IngredientMother::sauce();
}
function unLegume()
{
    IngredientMother::legume();
}
class PlatDePatesTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_bon_quand_il_contient_de_la_viande_du_fromage_et_de_la_sauce()
    {
        $plateDePates = new PlatDePates([
            unFromage(),
            uneViande(),
            uneSauce(),
            unLegume(),
        ]);

        $this->assertTrue($platDePates->estBon());
    }
}
📎
On a ainsi un match avec notre vocabulaire et notre nom de méthode !

12.4. Utilisation des Builders

Une factory nous retourne toujours le même type d’objet. Les builders nous permettent d’avoir un peu plus de flexibilité dans la construction des objets.

Dans l’exemple suivant, on souhaite tester qu’un plat de pâtes n’est pas consommable si un des ingrédients à une date de limite de consommation dépassée :

class PlatDePatesTest extends \PHPUnit_Framework_TestCase
{
    public function test_nest_pas_consommable_quand_il_contient_un_ingredient_avec_une_DLC_depassee()
    {
        $plateDePates = new PlatDePates([
            new Ingredient('fromage', 'parmesan', new DateTime('2017-10-30')),
            new Ingredient('viande', 'lardons', new DateTime('2017-10-15')), // DLC dépassée !!!
        ]);

        $this->assertFalse(
            $platDePates->estConsommableLe(new DateTime('2017-10-20'))
        );
    }
}

Dans ce contexte on parle de fromage et de viande alors que c’est uniquement la DLC de l’ingrédient qui nous intéresse. Nous allons utiliser pour cela le design pattern Builder :

class IngredientBuilder
{
    private $nom;
    private $dlc;

    public function __construct()
    {
        $this->nom = 'pomme de terre';
        $this->dlc = new DateTime();
    }

    public function nomme($nom)
    {
        $this->nom;
        return $this;
    }

    public function avecUneDLCLe($dlc)
    {
        $this->dlc = $dlc;
        return $this;
    }

    public function build()
    {
        return new Ingredient($this->nom, $this->dlc);
    }
}
💡
on utilise pas de setNom() ou de setDLC(), on privilie un vocabulaire plus parlant pour faciliter la relecture.
💡
return $this permet d’avoir une interface fluent, de chaîner nos appels les uns après les autres, et de construire une phrase.

On peut créer une fonction, pour la lisibilité, qui encapsule notre builder :

function UnIngredient()
{
    return new IngredientBuilder();
}

On peut ainsi avoir quelque chose d’extrêmement lisible comme :

UnIngredient()
    ->nomme('poisson panné')
    ->avecUneDLCLe(new DateTime('2018-01-12'))
    ->build();

On a ainsi le test lisible découplé suivant :

class PlatDePatesTest extends \PHPUnit_Framework_TestCase
{
    public function test_nest_pas_consommable_quand_il_contient_un_ingredient_avec_une_DLC_depassee()
    {
        $plateDePates = new PlatDePates([
            UnIngredient()->avecUneDLCLe(new DateTime('2017-10-30'))->build(),
            UnIngredient()->avecUneDLCLe(new DateTime('2017-10-15'))->build(), // DLC dépassée !!!
        ]);

        $this->assertFalse(
            $platDePates->estConsommableLe(new DateTime('2017-10-20'))
        );
    }
}

13. Data Provider (Plats végétariens)

13.1. Classe de tests sans data provider

On souhaite tester qu’un plat est végétarien ou non :

class PlatTest extends \PHPUnit_Framework_TestCase
{
    public function test_est_vegetarien_sil_ne_contient_que_de_la_salade()
    {
        $plat = new Plat(['salade']);
        $this->assertTrue($plat->estVegetarien());
    }

    public function test_est_vegetarien_sil_ne_contient_que_du_fromage()
    {
        $plat = new Plat(['fromage']);
        $this->assertTrue($plat->estVegetarien());
    }

    public function test_nest_pas_vegetarien_sil_contient_de_la_viande()
    {
        $plat = new Plat(['salade', 'viande']);
        $this->assertFalse($plat->estVegetarien());
    }
}

Cette classe de tests présente plus des exemples que réellement des règles métiers. L’objectif est de revenir à ce que pourrait exprimer quelqu’un du métier.

On peut voir que chacun de nos tests est setter de la même manière.

13.2. Classe de tests avec data provider

On va pouvoir utiliser un data provider, un tableau de tableaux :

class PlatTest extends \PHPUnit_Framework_TestCase
{
    const EST_VEGETARIEN = true;
    const NEST_PAS_VEGETARIEN = false;

    /**
     * @dataProvider plats
     */
    public function test_est_vegetarien_sil_ne_contient_que_des_ingredients_vegetariens($plat, $estVegetarien)
    {
        $this->assertEquals($estVegetarien, $plat->estVegetarien();)
    }

    public function plats()
    {
        return [
            'Un plat qui ne contient que de la salade est vegetarien' => [
                new Plat(['salade']), self::EST_VEGETARIEN
            ],
            'Un plat qui ne contient que du fromage est vegetarien' => [
                new Plat(['fromage']), self::EST_VEGETARIEN
            ],
            'Un plat qui contient de la viande n\'est pas vegetarien' => [
                new Plat(['viande']), self::NEST_PAS_VEGETARIEN
            ]
        ];
    }
}
💡
Dans PlatTest::plats(), on décrit dans la clé du tableau l’exemple qu’on veut présenter. En cas d’échec, PHPUnit retournera la clé explicite de l’exemple sur lequel le test a échoué.

On passe ainsi d’une classe de tests qui présentait plutôt des exemples à une classe ayant une régle métier, qui permettra de générer de la documentation.

14. En conclusion

  1. Assurez vous que vous testez ce que vous voulez tester :

    • Exprimez clairement ce que doit faire votre test

    • Vérifiez que cela match bien avec ce que vous voulez tester

  2. Exprimez les régles métiers dans les noms des tests :

    • Permet de générer de la documentation

    • Permet de vérifier le comportement du système auprès des personnes du métier

  3. Utilisez le même vocabulaire dans le nom du test et dans l’exemple :

    • Cela permet de relier votre code au vocabulaire métier

  4. Ne couplez pas le test à l’implémentation :

    • Vous devez pouvoir modifier le design de votre code sans que les tests échouent

  5. Ecoutez les tests pour guider l’implémentation

14.1. Documentation, tests & design : tous liés

  • La documentation permet d’améliorer les tests

  • Les tests peuvent fournir de la documentation

  • Les tests vont vous aider à déceler les defauts de votre design

  • Votre design va vous indiquer si vos tests sont compliqués ou non

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment