Symfony Clock va venir compléter sur la version 6.2 la grande famille des composants Symfony. Sur sa pull request, Nicolas Grekas exprime le besoin de ce nouveau composant : Pouvoir découpler nos applications de l'horloge système. Cette idée fait suite à une conférence d'Anna Filina au SymfonyWorld Online Summer qui a eu lieu le 16 et 17 Juin 2022 et où elle utilisait son propre "clock" service afin d'améliorer la testabilité de son code. Je n'étais pas présent à cette conférence, mais je pense que cela fait référence à un de ses articles.
Pourquoi découpler nos services de l'horloge système ?
Il peut exister plusieurs cas dans lesquels nous pouvons avoir besoin de découper nos services de l'horloge système. Dans son article, Anna Filina présente la création d'un service retournant l'âge d'une personne. Une méthode getAge()
fait tout simplement une comparaison entre la date de naissance de la personne et la date du jour. Anna fait ensuite évoluer son service en remplaçant la méthode getAge()
par une méthode getAgeOn()
afin de pouvoir connaitre l'âge de la personne à des moments marquants de sa vie ( date de l'obtention de son diplôme par exemple ). La méthode getAgeOn()
contrairement à la méthode getAge()
n'est plus couplée avec l'horloge système. Si nous souhaitons connaitre l'âge de la personne, nous devrons passer en paramètre de la méthode getAgeOn()
la date du jour.
Ce découplage réalisé à la base pour répondre à un besoin fonctionnel, va nous permettre de rendre notre service testable. Car si nous devions ajouter un test unitaire pour vérifier que getAge()
retourne bien 35, cela fonctionnerait très bien en 2022, mais en 2023 notre test tomberait en erreur. Avec getAgeOn()
, notre test fonctionnera de manière indépendante par rapport à l'horloge système.
Pour le moment toujours en brouillon, la PSR-20 a pour but de répondre à cette problématique récurrente lors de la réalisation de nos tests unitaires.
PSR-20 : Common Interface for Accessing the Clock
Lorsque dans notre code, nous utilisons directement les fonctions \date()
, \time()
ou encore new \DateTimeImmutable('now')
, nous créons un couplage fort avec l'horloge système qui va nous empêcher de pouvoir écrire nos tests sans difficultés. La proposition de la PSR-20 consiste donc à avoir recours à une nouvelle interface ClockInterface
lorsque l'on souhaite accéder à la date du jour.
<?php
namespace Psr\Clock;
interface ClockInterface
{
/**
* Returns the current time as a DateTimeImmutable Object
*/
public function now(): \DateTimeImmutable;
}
Pour le moment, cette PSR est en brouillon, et il y a fort à parier que la version 6.2 de Symfony sorte avant la publication de cette PSR-20.
Symfony Clock Component
L'interface proposée par Nicolas est la suivante
interface ClockInterface
{
public function now(): \DateTimeImmutable;
public function sleep(float|int $seconds): void;
public function withTimeZone(\DateTimeZone|string $timezone): static;
}
Elle répond en partie à la PSR-20 avec la méthode now()
et propose deux autres méthodes. sleep
et withTimeZone
qui permet d'obtenir l'horloge sur une autre time zone.
Concernant ses implémentations, nous en avons trois.
- NativeClock, basée sur l'horloge système
- MockClock, qui comme son nom l'indique nous permettra de simuler l'horloge
-
MonotonicClock, qui utilise la fonction php
hrtime()
dont la précision à la nano seconde nous permettra par exemple de mesurer nos performances
Création d'un filtre Twig pour illustrer son utilisation
Pour illustrer l'utilisation du Clock Component, j'ai ajouté une extension Twig me permettant de savoir si un devis est valide ou non. Pour cela, je dois vérifier que le devis est publié et que sa date de validité n'est pas dépassée.
// App\Twig\AppRuntime.php
public function isValidQuote(Quote $quote): bool
{
return (
$quote->getValidityDate() > new \DateTimeImmutable('now') &&
$quote->isPublished()
);
}
Pour m'assurer que cela fonctionne durablement dans le temps, je décide d'ajouter un test unitaire.
// App\Tests\Twig\AppRuntimeTest.php
public function testQuoteIsValid(): void
{
$quote = new Quote();
$quote->setPublished(true);
$quote->setValidityDate(new \DateTimeImmutable('2022-09-30'));
$this->assertTrue(
(new AppRuntime())->isValidQuote($quote)
);
}
Aujourd'hui, le 22 septembre 2022, cela fonctionne.
Mais en octobre, un test unitaire qui était bon aujourd'hui tombera alors en erreur. La raison étant que mon filtre Twig est couplé avec l'horloge système. C'est là que le Clock Component intervient.
class AppRuntime implements RuntimeExtensionInterface
{
private ClockInterface $clock;
public function __construct(ClockInterface $clock)
{
$this->clock = $clock;
}
public function isValidQuote(Quote $quote): bool
{
return (
$quote->getValidityDate() > $this->clock->now() &&
$quote->isPublished()
);
}
}
Grâce au Clock Component, je n'ai plus le couplage fort avec l'horloge système. Dans le FrameworkBundle, le service clock
est construit à partir de la classe NativeClock
. Au moment de la définition du service, l'interface ClockInterface
est définie en tant qu'alias ce qui permet à Symfony de faire l'auto-câblage sans difficultés.
public function testQuoteIsValid(): void
{
$quote = new Quote();
$quote->setPublished(true);
$quote->setValidityDate(new \DateTimeImmutable('2022-09-30'));
$this->assertTrue(
(new AppRuntime(new MockClock('2022-09-20')))->isValidQuote($quote)
);
}
Je peux ainsi faire évoluer mon test de façon à ne pas être lié à l'horloge système. Maintenant que nous en avons la possibilité très facilement sur Symfony, je trouve que c'est une bonne pratique de pouvoir découpler nos services de l'horloge système afin de rendre notre code plus testable.
Un grand merci à Nicolas Grekas pour sa contribution 😍