7. Système de mocks

Les mocks(bouchons) sont des classes virtuels créer à la volée. Ils sont utilisé pour isolé les tests du comportements des autres classes. atoum a un système de mock simple et puissant, permettant de générer des mocks depuis une classe ou une interface qui existe ou est virtuel, ou encore est abstraite.

Grâce à ces bouchons, vous pourrez simuler des comportements en redéfinissant les méthodes publiques de vos classes. Pour les méthodes private et protected, vous pouvez utiliser l’extension de visibilité.

Avertissement

La plupart de méthode qui configurent le mock, s’appliquent uniquement pour la prochaine génération de ceux-ci!

7.1. Générer un bouchon

Il y a plusieurs manières de créer un bouchon à partir d’une interface ou d’une classe. Le plus simple est de créer un objet avec le nom absolu préfixé de mock :

<?php
// création d'un bouchon de l'interface \Countable
$countableMock = new \mock\Countable;

// création d'un bouchon de la classe abstraite
// \Vendor\Project\AbstractClass
$vendorAppMock = new \mock\Vendor\Project\AbstractClass;

// creation of mock of the \StdClass class
$stdObject     = new \mock\StdClass;

// création d'un bouchon à partir d'une classe inexistante
$anonymousMock = new \mock\My\Unknown\Claass;

7.1.1. Générer un mock avec newMockInstance

Si vous préférez il existe une méthode newMockInstance() qui permet la génération d’un mock.

<?php
// création d'un bouchon de l'interface \Countable
$countableMock = new \mock\Countable;

// est équivalent à
$this->newMockInstance('Countable');

Note

Comme le générateur de mock, vous pouvez fournir des paramètres en plus : $this->newMockInstance('class name', 'mock namespace', 'mock class name', ['constructor args']);

7.2. Le générateur de bouchon

atoum s’appuie sur un composant spécialisé pour générer les bouchons : le mockGenerator. Vous avez accès à ce dernier dans vos tests afin de modifier la procédure de génération des mocks.

Par défaut, le mock sera généré dans le namespace « mock » et fonctionnera exactement de la même manière que l’instance de la classe originale (le mock hérite directement de la classe d’origine).

7.2.1. Changer le nom de la classe

Si vous désirez changer le nom de la classe ou son espace de nom, vous devez utiliser le mockGenerator.

La méthode generate prend trois paramètres :

  • le nom de l’interface ou de la classe à bouchonner ;
  • le nouvel espace de nom, optionnel ;
  • le nouveau nom de la classe, optionnel.
<?php
// création d'un bouchon de l'interface \Countable vers \MyMock\Countable
// on ne change que l'espace de nom
$this->mockGenerator->generate('\Countable', '\MyMock');

// création d'un bouchon de la classe abstraite
// \Vendor\Project\AbstractClass to \MyMock\AClass
// on change l'espace de nom et le nom de la classe
$this->mockGenerator->generate('\Vendor\Project\AbstractClass', '\MyMock', 'AClass');

// création d'un bouchon de la classe \StdClass vers \mock\OneClass
// on ne change que le nom de la classe
$this->mockGenerator->generate('\StdClass', null, 'OneClass');

// on peut maintenant instancier ces mocks
$vendorAppMock = new \myMock\AClass;
$countableMock = new \myMock\Countable;
$stdObject     = new \mock\OneClass;

Note

Si vous n’utilisez que le premier argument et ne changez pas le namespace ou le nom de la classe, alors la première solution est équivalente, plus simple à lire et recommandée.

Vous pouvez accéder au code généré pour la classe par le générateur de mock en appelant $this->mockGenerator->getMockedClassCode(), pour débuguer par exemple. Cette méthode prend les mêmes arguments que la méthode generate.

<?php
$countableMock = new \mock\Countable;

// est équivalent à:

$this->mockGenerator->generate('\Countable');   // inutile
$countableMock = new \mock\Countable;

Note

Tout ce qui est décrit ici avec le générateur de mock peut être utilisé avec newMockInstance

7.2.2. Shunter les appels aux méthodes parentes

7.2.2.1. shuntParentClassCalls & unShuntParentClassCalls

Un bouchon hérite directement de la classe à partir de laquelle il a été généré, ses méthodes se comportent donc exactement de la même manière.

Dans certains cas, il peut être utile de shunter les appels aux méthodes parentes afin que leur code ne soit plus exécuté. Le mockGenerator met à votre disposition plusieurs méthodes pour y parvenir :

<?php
// le bouchon ne fera pas appel à la classe parente
$this->mockGenerator->shuntParentClassCalls();

$mock = new \mock\OneClass;

// le bouchon fera à nouveau appel à la classe parente
$this->mockGenerator->unshuntParentClassCalls();

Ici, toutes les méthodes du bouchon se comporteront comme si elles n’avaient pas d’implémentation par contre elles conserveront la signature des méthodes originales.

Note

shuntParentClassCalls va seulement être appliqué à la prochaine génération de mock. Mais si vous créer deux mock de la même classe, les deux auront leurs méthodes parente shunté.

7.2.2.2. shunt

Vous pouvez également préciser les méthodes que vous souhaitez shunter :

<?php
// le bouchon ne fera pas appel à la classe parente pour la méthode firstMethod…...
$this->mockGenerator->shunt('firstMethod');
// ... ni pour la méthode secondMethod
$this->mockGenerator->shunt('secondMethod');

$countableMock = new \mock\OneClass;

Une méthode shuntée aura un corps de méthode vide mais comme pour shuntParentClassCalls la signature de la méthode sera la même que celle bouchonée.

7.2.3. Rendre une méthode orpheline

Il peut parfois être intéressant de rendre une méthode orpheline, c’est-à-dire, lui donner une signature et une implémentation vide. Cela peut être particulièrement utile pour générer des bouchons sans avoir à instancier toutes leurs dépendances. Tous les paramètres de la méthode seront également définis avec comme valeur par défaut null. C’est donc la même chose que shunter une méthode mais avec tout les paramètres a null.

<?php
class FirstClass {
    protected $dep;

    public function __construct(SecondClass $dep) {
        $this->dep = $dep;
    }
}

class SecondClass {
    protected $deps;

    public function __construct(ThirdClass $a, FourthClass $b) {
        $this->deps = array($a, $b);
    }
}

$this->mockGenerator->orphanize('__construct');
$this->mockGenerator->shuntParentClassCalls();

// Nous pouvons instancier le bouchon sans injecter ses dépendances
$mock = new \mock\SecondClass();

$object = new FirstClass($mock);

Note

orphanize va seulement être appliqué à la prochaine génération de mock.

7.3. Modifier le comportement d’un bouchon

Une fois le bouchon créé et instancié, il est souvent utile de pouvoir modifier le comportement de ses méthodes. Pour cela, il faut passer par son contrôleur en utilisant l’une des méthodes suivantes :

  • $yourMock->getMockController()->yourMethod
  • $this->calling($yourMock)->yourMethod
<?php
$mockDbClient = new \mock\Database\Client();

$mockDbClient->getMockController()->connect = function() {};
// Equivalent to
$this->calling($mockDbClient)->connect = function() {};

Le mockController vous permet de redéfinir uniquement les méthodes publiques et abstraites protégées et met à votre disposition plusieurs méthodes :

<?php
$mockDbClient = new \mock\Database\Client();

// redéfinit la méthode connect : elle retournera toujours true
$this->calling($mockDbClient)->connect = true;

// redéfinit la méthode select : elle exécutera la fonction anonyme passée
$this->calling($mockDbClient)->select = function() {
    return array();
};

// redéfinit la méthode query avec des arguments
$result = array();
$this->calling($mockDbClient)->query = function(Query $query) use($result) {
    switch($query->type) {
        case Query::SELECT:
            return $result;

        default;
            return null;
    }
};

// la méthode connect lèvera une exception
$this->calling($mockDbClient)->connect->throw = new \Database\Client\Exception();

Note

La syntaxe utilise les fonctions anonymes (aussi appelées fermetures ou closures) introduites en PHP 5.3. Reportez-vous au manuel de PHP pour avoir plus d’informations sur le sujet.

Comme vous pouvez le voir, il est possible d’utiliser plusieurs méthodes afin d’obtenir le comportement souhaité :

  • Utiliser une valeur statique qui sera retournée par la méthode
  • Utiliser une implémentation courte grâce aux fonctions anonymes de PHP
  • Utiliser le mot-clef throw pour lever une exception

7.3.1. Changement de comportement du mock sur plusieurs appels

Vous pouvez également spécifier plusieurs valeurs en fonction de l’ordre d’appel :

<?php
// default
$this->calling($mockDbClient)->count = rand(0, 10);
// équivalent à
$this->calling($mockDbClient)->count[0] = rand(0, 10);

// 1er appel
$this->calling($mockDbClient)->count[1] = 13;

// 3ème appel
$this->calling($mockDbClient)->count[3] = 42;
  • Le premier appel retournera 13.
  • Le second aura le comportement par défaut, c’est-à-dire un nombre aléatoire.
  • Le troisième appel retournera 42.
  • Tous les appels suivants auront le comportement par défaut, c’est à dire des nombres aléatoires.

Si vous souhaitez que plusieurs méthodes du bouchon aient le même comportement, vous pouvez utiliser les méthodes methods ou methodsMatching.

7.3.2. methods

methods vous permet, grâce à la fonction anonyme passée en argument, de définir pour quelles méthodes le comportement doit être modifié :

<?php
// si la méthode a tel ou tel nom,
// on redéfinit son comportement
$this
    ->calling($mock)
        ->methods(
            function($method) {
                return in_array(
                    $method,
                    array(
                        'getOneThing',
                        'getAnOtherThing'
                    )
                );
            }
        )
            ->return = uniqid()
;

// on redéfinit le comportement de toutes les méthodes
$this
    ->calling($mock)
        ->methods()
            ->return = null
;

// si la méthode commence par "get",
// on redéfinit son comportement
$this
    ->calling($mock)
        ->methods(
            function($method) {
                return substr($method, 0, 3) == 'get';
            }
        )
            ->return = uniqid()
;

Dans le cas du dernier exemple, vous devriez plutôt utiliser methodsMatching.

Note

La syntaxe utilise les fonctions anonymes (aussi appelées fermetures ou closures) introduites en PHP 5.3. Reportez-vous au manuel de PHP pour avoir plus d’informations sur le sujet.

7.3.3. methodsMatching

methodsMatching vous permet de définir les méthodes où le comportement doit être modifié grâce à l’expression rationnelle passée en argument :

<?php
// si la méthode commence par "is",
// on redéfinit son comportement
$this
    ->calling($mock)
        ->methodsMatching('/^is/')
            ->return = true
;

// si la méthode commence par "get" (insensible à la casse),
// on redéfinit son comportement
$this
    ->calling($mock)
        ->methodsMatching('/^get/i')
            ->throw = new \exception
;

Note

methodsMatching utilise preg_match et les expressions rationnelles. Reportez-vous au manuel de PHP pour avoir plus d’informations sur le sujet.

7.3.4. isFluent && returnThis

Défini une méthode fluent (chaînable), ainsi la méthode appelée retourne l’instance de la classe.

<?php
        $foo = new \mock\foo();
        $this->calling($foo)->bar = $foo;

        // est identique à
        $this->calling($foo)->bar->isFluent;
        // ou a celui-ci
        $this->calling($foo)->bar->returnThis;

7.3.5. doesNothing && doesSomething

Changer le comportement du mock avec doesNothing, la méthode retournera simple null.

<?php
        class foo {
                public function bar() {
                        return 'baz';
                }
        }

        //
        // in your test
        $foo = new \mock\foo();
        $this->calling($foo)->bar = null;

        // est identique à
        $this->calling($foo)->bar->doesNothing;
        $this->variable($foo->bar())->isNull;

        // restaure le comportement
        $this->calling($foo)->bar->doesSomething;
        $this->string($foo->bar())->isEqualTo('baz');

Comme on le voix dans l’exemple, si pour une raison quelconque, vous souhaitez rétablir le comportement de la méthode, utilisez doesSomething.

7.3.6. Cas particulier du constructeur

Pour mocker le constructeur de la classe, vous avez besoin de :

  • créer une instance de la classe atoummockcontroller avant d’appeler le constructeur du bouchon ;
  • définir via ce contrôleur le comportement du constructeur du bouchon à l’aide d’une fonction anonyme ;
  • injecter le contrôleur lors de l’instanciation du bouchon en dernier argument.
<?php
$controller = new \atoum\mock\controller();
$controller->__construct = function($args)
{
     // faire quelque chose avec les arguments
};

$mockDbClient = new \mock\Database\Client(DB_HOST, DB_USER, DB_PASS, $controller);

Pour les cas simple, vous pouvez utiliser orphanize(“__constructor”) ou shunt(“__constructor”).

7.4. Tester un bouchon

atoum vous permet de vérifier qu’un bouchon a été utilisé correctement.

<?php
$mockDbClient = new \mock\Database\Client();
$mockDbClient->getMockController()->connect = function() {};
$mockDbClient->getMockController()->query   = array();

$bankAccount = new \Vendor\Project\Bank\Account();
$this
    // utilisation du bouchon via un autre objet
    ->array($bankAccount->getOperations($mockDbClient))
        ->isEmpty()

    // test du bouchon
    ->mock($mockDbClient)
        ->call('query')
            ->once() // vérifie que la méthode query
                            // n'a été appelé qu'une seule fois
;

Note

Reportez-vous à la documentation sur l’assertion mock pour obtenir plus d’informations sur les tests des bouchons.

7.5. Le bouchonnage (mock) des fonctions natives de PHP

atoum permet de très facilement simuler le comportement des fonctions natives de PHP.

<?php

$this
   ->assert('the file exist')
      ->given($this->newTestedInstance())
      ->if($this->function->file_exists = true)
      ->then
      ->object($this->testedInstance->loadConfigFile())
         ->isTestedInstance()
         ->function('file_exists')->wasCalled()->once()

   ->assert('le fichier does not exist')
      ->given($this->newTestedInstance())
      ->if($this->function->file_exists = false )
      ->then
      ->exception(function() { $this->testedInstance->loadConfigFile(); })
;

Important

On ne peut pas mettre de \ devant les fonctions à simuler, car atoum s’appuie sur le mécanisme de résolution des espaces de nom de PHP.

Important

Pour la même raison, si une fonction native a déjà été appelée, son bouchonnage sera sans effet.

<?php

$this
   ->given($this->newTestedInstance())
   ->exception(function() { $this->testedInstance->loadConfigFile(); }) //la fonction file_exists est appelée avant son bouchonnage

   ->if($this->function->file_exists = true ) // le bouchonnage ne pourra pas prendre la place de la fonction native file_exists
   ->object($this->testedInstance->loadConfigFile())
      ->isTestedInstance()
;

Note

Plus d’information via isTestedInstance().

7.6. Les bouchons de constantes

Les constantes PHP peuvent être déclarées avec defined, cependant avec atoum vous pouvez les bouchonner de cette manière :

<?php
$this->constant->PHP_VERSION_ID = '606060'; // troll \o/

$this
    ->given($this->newTestedInstance())
    ->then
        ->variable($this->testedInstance->hello())->isEqualTo(PHP_VERSION_ID)
    ->if($this->constant->PHP_VERSION_ID = uniqid())
    ->then
        ->variable($this->testedInstance->hello())->isEqualTo(PHP_VERSION_ID)
;

Attention, due à la nature des constantes en PHP, suivant l’engine utilisé vous pouvez rencontrer différents problèmes. En voici un exemple :

<?php

namespace foo {
    class foo {
        public function hello()
        {
            return PHP_VERSION_ID;
        }
    }
}

namespace tests\units\foo {
    use atoum;

    /**
     * @engine inline
     */
    class foo extends atoum
    {
        public function testFoo()
        {
            $this
                ->given($this->newTestedInstance())
                ->then
                    ->variable($this->testedInstance->hello())->isEqualTo(PHP_VERSION_ID)
                ->if($this->constant->PHP_VERSION_ID = uniqid())
                ->then
                    ->variable($this->testedInstance->hello())->isEqualTo(PHP_VERSION_ID)
            ;
        }

        public function testBar()
        {
            $this
                ->given($this->newTestedInstance())
                ->if($this->constant->PHP_VERSION_ID = $mockVersionId = uniqid()) // inline engine will fail here
                ->then
                    ->variable($this->testedInstance->hello())->isEqualTo($mockVersionId)
                ->if($this->constant->PHP_VERSION_ID = $mockVersionId = uniqid()) // isolate/concurrent engines will fail here
                ->then
                    ->variable($this->testedInstance->hello())->isEqualTo($mockVersionId)
            ;
        }
    }
}