La pyramide de tests en architecture hexagonale PHP : protéger le cœur métier, contractualiser les ports
Vous ouvrez le tableau de bord CI. La suite de tests tourne depuis onze minutes, en est à son quatrième retry sur un test de checkout instable. Le code métier n'a pas changé depuis trois semaines. Ce qui a changé, c'est une mise à jour de version Doctrine — et maintenant la moitié de la suite démarre un conteneur MySQL pour vérifier que deux plus deux font quatre.
C'est ce à quoi ressemble une pyramide de tests à l'envers. Et c'est précisément le problème que l'architecture hexagonale permet de résoudre structurellement.
Cet article s'inspire du billet de Gabriel Anhaia sur DEV.to, que nous enrichissons ici de la perspective MulerTech sur le découplage applicatif.
Pourquoi votre suite de tests est probablement construite à l'envers
Dans la majorité des projets PHP que nous rencontrons chez MulerTech, les tests fonctionnels et d'intégration sont sur-représentés : ils bootent le kernel Symfony, touchent la base de données, appellent des services réels. À l'inverse, les tests unitaires — ceux qui vérifient les règles métier pures — sont rares ou inexistants.
Le résultat : des suites lentes, fragiles, qui cassent pour les mauvaises raisons. Un upgrade de dépendance, un changement de configuration réseau, un conteneur Docker qui met du temps à démarrer — et voilà votre pipeline rouge, sans que votre logique métier ait bougé d'un iota.
L'architecture hexagonale offre une réponse structurelle à ce problème, parce qu'elle organise naturellement le code en couches qui appellent des stratégies de test distinctes.
Les trois couches de l'hexagone et leur rapport au test
1. Le domaine : tests unitaires purs
Le domaine, c'est le cœur de votre application : entités, objets valeur, cas d'usage. En architecture hexagonale bien appliquée, cette couche est du PHP pur, sans import de framework. Aucune dépendance à Symfony, Doctrine, ou quoi que ce soit d'externe.
Cela signifie que vous pouvez — et devez — tester cette couche avec des tests unitaires rapides et déterministes :
final class MoneyTest extends TestCase
{
public function test_cannot_add_different_currencies(): void
{
$this->expectException(CurrencyMismatchException::class);
Money::of(100, 'EUR')->add(Money::of(50, 'USD'));
}
}
Ce test s'exécute en quelques millisecondes. Il ne dépend de rien d'externe. Il ne peut pas être rendu instable par une mise à jour de Doctrine. C'est là que votre valeur métier est protégée.
2. Les ports : tests de contrat
Les ports sont les interfaces qui définissent comment le domaine communique avec le monde extérieur : OrderRepositoryInterface, PaymentGatewayInterface, EmailSenderInterface. Ce sont des contrats, pas des implémentations.
Les tests de contrat vérifient que chaque adaptateur respecte le contrat défini par le port :
abstract class OrderRepositoryContractTest extends TestCase
{
abstract protected function repository(): OrderRepositoryInterface;
public function test_persists_and_retrieves_order(): void
{
$order = Order::create(OrderId::generate(), CustomerId::of('cust-1'));
$repo = $this->repository();
$repo->save($order);
$this->assertEquals($order, $repo->findById($order->id()));
}
}
Chaque implémentation concrète (Doctrine, Redis, en mémoire) étend cette classe abstraite et hérite de tous les tests du contrat. C'est élégant : le contrat est testé une fois, les adaptateurs sont interchangeables.
C'est aussi cette approche qui rend vos déploiements sereins chez MulerTech : changer d'implémentation de persistance ne casse pas le domaine, et les tests de contrat confirment que le nouvel adaptateur se comporte exactement comme l'ancien.
3. La couche d'assemblage : tests end-to-end minimalistes
Le kernel Symfony, les contrôleurs, la wiring des services — tout cela mérite quelques tests d'intégration, mais ils doivent être intentionnellement rares. Leur rôle n'est pas de re-tester la logique métier (déjà couverte par les tests unitaires), mais de vérifier que les pièces s'assemblent correctement :
- Le routing répond-il sur le bon endpoint ?
- L'authentification est-elle bien appliquée ?
- Le format de réponse JSON est-il conforme ?
Pas plus. Tester la règle de calcul de TVA au niveau HTTP, c'est tester deux fois au mauvais endroit, avec un coût d'exécution dix fois supérieur.
Ce que cela change concrètement
| Couche | Type de test | Vitesse | Dépendances externes |
|---|---|---|---|
| Domaine | Unitaire | < 1 ms | Aucune |
| Ports | Contrat | Quelques ms | En mémoire ou stub |
| Assemblage | E2E / Intégration | Secondes | Base de données, kernel |
Lorsque la pyramide est dans le bon sens — large à la base (unitaires), resserrée au sommet (E2E) — vous obtenez :
- ✅ Un feedback rapide en développement local
- ✅ Une CI stable, non impactée par les upgrades d'infrastructure
- ✅ Une confiance réelle dans le code métier, pas dans le framework
- ✅ La liberté de migrer une implémentation technique sans toucher aux règles business
C'est exactement ce que nous visons pour nos clients : des applications où la logique qui fait gagner de l'argent est isolée, testée, et indépendante des outils techniques qui l'entourent.
Conclusion : testez ce que vous avez écrit
Le framework Symfony, Doctrine, les bundles tiers — vous ne les avez pas écrits. Leurs mainteneurs les testent. Ce que vous avez écrit, c'est votre domaine métier : les règles de prix, les workflows de commande, les invariants de votre modèle.
L'architecture hexagonale n'est pas qu'une question d'organisation de fichiers. C'est une discipline qui rend les bonnes pratiques de test naturelles et inévitables. Quand votre domaine est du PHP pur, il appelle des tests unitaires purs. Quand vos ports sont des interfaces, ils appellent des tests de contrat.
Si votre CI tourne depuis onze minutes sur un test flaky lié à une migration Doctrine, ce n'est pas un problème de configuration : c'est un signal d'architecture. Et c'est réparable.