Nicolas Le Borgne

Développeur

Les tests sensibles au temps

Le 10 octobre 2021

Les tests sensibles au temps peuvent être une source de non-déterminisme. Il peut donc être difficile d'écrire un test autour de logique tel que des horaires d'ouverture, des planifications temporelles ... Fonction de l'instant ou le test est lancé, on risque de se retrouver avec une suite de tests par terre. Pour prévenir ces situations, on trouve des librairies pour mocker les fonctions de base des languages ou des appels système. Avec ces solutions, j'ai été confronté à certaines limitations et souvent je retombe sur cette citation : "You can't test what you don't own" (Impossible d'en retrouver l'auteur ...). Par conséquent, je préfèrerais une solution me permettant de maitriser la notion du temps dans la base de code.

Php Symfony, le ClockMock

Un exemple de limitation rencontrée récemment : le ClockMock, fournit par le PhpUnitBridge de Symfony. Si lors d'une suite de tests, un élément est enregistré dans le ClockMock alors qu'il en est fait un usage plus haut dans la suite, sans ClockMock, alors le ClockMock ne sera pas utilisé. Le détail ici :

"You can't test what you don't own" : solution maison

Passé la surprise et le temps perdu à comprendre pourquoi ce ClockMock ne fonctionne pas, il faut rebondir. Étant donné la citation exposée dans l'introduction, je suis assez partisan de la solution maison.

L'une d'entre elles, pourrait être de définir une classe Clock directement dans notre base de code et de s'en servir chaque fois qu'une date doit être générée !

final class Clock
{
    private static ?\DateTimeImmutable $now = null;

    public static function now(): \DateTimeImmutable
    {
        $now = \DateTimeImmutable::createFromFormat('U', (string) time(), new \DateTimeZone('UTC'));

        if (self::$now) {
            $now = self::$now;
        }

        // @codeCoverageIgnoreStart
        if (!$now instanceof \DateTimeImmutable) {
            throw new \LogicException('Cannot create DateTime');
        }
        // @codeCoverageIgnoreEnd

        return $now;
    }

    public static function setNow(?\DateTimeImmutable $now): void
    {
        self::$now = $now;
    }

    public static function clear(): void
    {
        self::$now = null;
    }
}

Ainsi dans notre code de production, lorsque l'on doit générer une date :

// On ne fait plus
$date = new \DateTimeImmutable();
// Mais
$date = Clock::now();

Et dans nos tests, nous pouvons nous approprier le temps:

public function setUp(): void
{
    Clock::setNow(\DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2021-10-10 12:00:00'));
}

public function teardown(): void
{
    Clock::clear();
}

/** @test */
public function itComputeDeliveryDate()
{
    $deliveryMethod = DeliveryMethodBuilder::any();
    $computer = DeliveryDateComputerBuilder::any();

    $actualDeliveryDate = $computer->compute($deliveryMethod);

    $expectedDeliveryDate = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2021-10-10 13:00:00');
    $this->assertEquals(expectedDeliveryDate, $actualDeliveryDate);
}

Clean code

C'est bien, on maitrise le temps. Mais notre test reste encore flou. En effet, à quoi correspond cette date dans $expectedDeliveryDate ? On ne sait pas. Il peut être intéressant de tirer profit des notions d'intervalle de date et de format relatif. En php, notre Clock ressemblerait à ceci :

final class Clock
{
    private static ?\DateTimeImmutable $now = null;

    public static function now(?string $relativeFormatInterval = null): \DateTimeImmutable
    {
        $interval = \DateInterval::createFromDateString($relativeFormatInterval ?? '+0 year'); // Fake interval if no format provided
        $now = \DateTimeImmutable::createFromFormat('U', (string) time(), new \DateTimeZone('UTC'));

        if (self::$now) {
            $now = self::$now;
        }

        // @codeCoverageIgnoreStart
        if (!$now instanceof \DateTimeImmutable) {
            throw new \LogicException('Cannot create DateTime');
        }
        // @codeCoverageIgnoreEnd

        return $now->add($interval);
    }

    public static function setNow(?\DateTimeImmutable $now): void
    {
        self::$now = $now;
    }

    public static function clear(): void
    {
        self::$now = null;
    }
}

Et notre test ressemblerait désormais à :

/** @test */
public function itComputeDeliveryDate()
{
    $deliveryMethod = DeliveryMethodBuilder::any();
    $computer = DeliveryDateComputerBuilder::any();

    $actualDeliveryDate = $computer->compute($deliveryMethod);

    $this->assertEquals(Clock::now('+60 minutes'), $actualDeliveryDate);
}

On voit clairement que l'attendu correspond désormais à l'instant présent + 60 minutes.

Conclusion

La solution d'une classe statique est si abordable que l'on peut remettre en question une librairie comme le ClockMock de Symfony. En plus, elle peut nous permettre de rendre nos tests plus verbeux et explicites, puisque l'on en garde la totale maitrise. Il peut également être pertinent de faire rentrer des petites logiques métiers dans cette classe. Attention cependant a bien communiquer la présence d'un tel outil dans un projet. En effet, pour en profiter le plus possible, la classe Clock doit être utilisée pour générer la moindre date dans l'application !

Sources

© 2021 Nicolas Le Borgne