Astuce Mocker des fonctions natives PHP

PHPUnit fournit des outils pour mocker des classes et leurs méthodes. Ex :

$valueINeed = 'my_value';

$myMockedObject = $this->createMock(MyClassToMock::class);
$myMockedObject->method('myMethodToMock')
    ->willReturn($valueINeed);

Ce n'est pas le cas pour les fonctions natives, comme time() ou random_bytes().
C'est problématique si vous voulez tester une méthode comme celle-ci :

// src/Generator/TokenGenerator.php
namespace App\Generator;

use App\Exception\TokenGenerationException;

class TokenGenerator
{
    public function generateBase64Token(int $byteLength): string
    {
        if ($byteLength <= 0) {
            throw new \InvalidArgumentException('The length must be greater than zero');
        }

        try {
            $token = random_bytes($byteLength);
        } catch(\Exception $e) {
            throw new TokenGenerationException('An unexpected error has occured');
        }

        return base64_encode($token);
    }
}

Gérer le retour d'une fonction native

À la place de mocker la fonction, vous pouvez la redéfinir à votre convenance, dans le namespace où vous en avez besoin.

Par exemple :

namespace App\Generator;

function random_bytes(int $length) {
    return 'fake_random_string';
}

Lors de l'exécution, PHP cherchera la fonction dans le namespace courant, puis dans le namespace racine / s'il ne la trouve pas.
(À noter que si vous utilisez \random_bytes(), PHP cherchera alors uniquement dans le namespace racine.)

On peut ainsi tester la méthode correctement :

// tests/What/Ever/Probably/Just/App/Generator/TokenGeneratorTest.php
namespace App\Generator {
    function random_bytes(int $length) {
        return 'fake_random_string';
    }
}

namespace What\Ever\Probably\Just\App\Generator {

    use App\Generator\TokenGenerator;
    use PHPUnit\Framework\TestCase;

    class TokenGeneratorTest extends TestCase
    {
        /** @var TokenGenerator */
        private $tokenGenerator;

        public function testGenerateBase64Token(): void
        {
            $validLength = 128;
            $generated = $this->tokenGenerator->generateBase64Token($validLength);

            $expected = 'ZmFrZV9yYW5kb21fc3RyaW5n';

            $this->assertEquals($expected, $generated);
        }

        public function testGenerateBase64TokenWithInvalidLength(): void
        {
            $invalidLength = -12;

            $this->expectException(\InvalidArgumentException::class);

            $this->tokenGenerator->generateBase64Token($invalidLength);
        }

        protected function setUp(): void
        {
            parent::setUp();
            $this->tokenGenerator = new TokenGenerator();
        }
    }
}

Remarque : Quand il y a plusieurs namespace utilisés dans un même fichier PHP, on précise le code concerné en l'encadrant par des {}.

Gérer plusieurs retours d'une fonction native

Imaginons maintenant qu'on veuille tester le cas où la fonction random_bytes() lève une exception, puisque la documentation nous informe que cela peut arriver (en cas de problème mémoire).

Il faut donc que notre "fonction de surcharge" puisse retourner notre valeur "bouchon" ou lever une exception.
La difficulté c'est qu'on souhaite sans modifier sa signature, qu'elle ait un comportement différent selon le cas de test où on l'appelle.

Pour ça il y a une astuce (assez sale) : utiliser une variable globale pour indiquer à la fonction le comportement à suivre.

On peut ainsi modifier notre fonction de surcharge et implémenter un 3e cas de test :

// tests/What/Ever/Probably/Just/App/TokenGeneratorTest.php
namespace {
    $randomBytesFuncThrowsException = false;
}

namespace App\Generator {

    function random_bytes(int $length) {
        global $randomBytesFuncThrowsException;

        if (isset($randomBytesFuncThrowsException) && $randomBytesFuncThrowsException === true) {
            throw new \Exception();
        }

        return 'fake_random_string';
    }
}

namespace What\Ever\Probably\Just\App\Generator {

    use App\Exception\TokenGenerationException;
    use App\Generator\TokenGenerator;
    use PHPUnit\Framework\TestCase;

    class TokenGeneratorTest extends TestCase
    {
        // [...]

        public function testGenerateBase64TokenWithUnexpectedError(): void
        {
            global $randomBytesFuncThrowsException;

            $randomBytesFuncThrowsException = true;
            $noMatterWhatLength = 666;

            $this->expectException(TokenGenerationException::class);

            $this->tokenGenerator->generateBase64Token($noMatterWhatLength);
        }

        // [...]
    }
}

Remarque : Pour déclarer une variable globale, on se place dans le namespace racine.

DateTime

Un autre cas courant et problématique est le constructeur DateTime().

Quand on veut la date courante, on l'utilise souvent sans argument et là encore, pas moyen de mocker l'objet généré. À chaque test l'heure aura un peu avancé..

La solution toute simple consiste à utiliser le premier argument du constructeur :

// Remplacer
$dateTime = new \DateTime();

// par
$dateTime = new \DateTime(sprintf('@%s', time()));

On peut alors utiliser la technique présentée plus haut, et surcharger la fonction time() :

namespace Here\I\Instanciate\Datetime {
    function time()
    {
        return 1557481811;
    }
}

namespace Some\Where {

    class SomethingWhichDealsWithDateTest extends KernelTestCase
    {

        private $someServiceWhichInstanciatesDateTime;

        public function testMethodWhichInstanciateDatetime(): void
        {
            $this->someServiceWhichInstanciatesDateTime->getDatetimeInstance();

            // TODO: test something
        }
    }

    // [...]
}