Tester les implémentations d'une interface avec le "Behavioral Contract Testing"
Lorsque l’on doit écrire plusieurs implémentations d’une interface, il est crucial de veiller à ce qu’elles aient le même comportement. Par exemple, une interface PaymentProcessor
permettant de s’interfacer avec plusieurs plateformes de paiements possède trois implémentations, il est important que chacune d’entre elles se comporte de la même manière. Pour cela, il est possible d’utiliser le principe du Behavioral Contract Testing.
Le Behavioral Contract Testing (qui est également appelé Interface Contract Testing) est un sous-ensemble du Contract Testing. C’est une technique de test qui permet de vérifier les contrats (ou interfaces dans notre cas) entre différents composants d’un système. Cette approche va nous permettre de garantir que le comportement de chaque implémentation va être identique, tout en mutualisant le code nécessaire à la mise en place des tests. Comme souvent avec les tests, le Contract Testing peut servir de documentation vivante, chacun des tests décrivant un comportement attendu.
Pennons maintenant un exemple concret: une abstraction permettant de gérer un système de cache. Commençons par définir notre contrat d’interface:
interface Cache
{
public function get(string $key): mixed;
public function set(string $key, mixed $value, ?int $ttl = null): void;
public function delete(string $key): void;
}
Cette dernière pourrait être déclinée en de multiples implémentations, telles que: un cache mémoire, fichier ou encore en base de données…
final class InMemoryCache implements Cache
{
// ...
}
final class FileCache implements Cache
{
// ...
}
final class RedisCache implements Cache
{
// ...
}
Mettre en place du Behavioral Contract Testing, c’est créer des tests qui vont être exécutés sur les différentes instances d’implémentations de notre interface. Nous allons donc commencer par définir la structure de nos tests au sein d’une classe de test abstraite:
abstract class CacheContractTest extends TestCase
{
abstract protected function createCacheInstance(): Cache;
public function testGetItem(): void
{
$cache = $this->createCacheInstance();
// test implementation [...]
}
public function testCacheExpiration(): void
{
$cache = $this->createCacheInstance();
// test implementation [...]
}
#[Test]
public function testCacheDeletion(): void
{
$cache = $this->createCacheInstance();
// test implementation [...]
}
// ...
}
Une fois notre classe abstraite définie, il faut créer les classes de tests correspondant à chacune des implémentations:
class InMemoryCacheTest extends CacheContractTest
{
protected function createCacheInstance(): Cache
{
return new InMemoryCache();
}
}
class FileCacheTest extends CacheContractTest
{
protected function createCacheInstance(): Cache
{
return new FileCache(sys_get_temp_dir());
}
}
class RedisCacheTest extends CacheContractTest
{
protected function createCacheInstance(): Cache
{
return new RedisCache(/* ... */);
}
}
Il ne vous restera plus qu’à exécuter les tests pour vous assurer que tout est OK.
Comme nous venons de le voir, mettre en place du Behavioral Contract Testing dans sa version la plus simple (et qui couvre un large cas d’utilisation) n’est pas quelque chose de compliqué. Et pourtant, c’est un mécanisme d’une grande puissante. Nous vérifions que chaque implémentation respecte et se comporte de manière identique pour un même contrat. Si cela s’avère nécessaire, il est également possible de gérer les cas particuliers de chaque type de cache dans sa classe de test respective.
La force de cette approche est également que, si vous avez besoin de créer une nouvelle implémentation de cache, cette dernière bénéficiera automatiquement de tous les tests que vous avez pu écrire en amonts.