Tester les applications DDD au-delà des tests unitaires : Behat, adaptateurs en mémoire et cas d’usage réels

Une architecture de test pratique qui combine des tests de domaine rapides, des spécifications applicatives Behat exécutables, des ports en mémoire, un temps déterministe, de véritables frontières Symfony et des tests d’infrastructure ciblés.
Avez-vous déjà eu une suite de tests unitaires entièrement au vert alors que l’application restait défaillante ?
La politique de mot de passe fonctionne. L’agrégat change d’état. Le mock du dépôt reçoit save(). Chaque classe semble correcte lorsqu’elle est isolée.
Puis une véritable requête atteint l’application et révèle que la commande est reliée au mauvais gestionnaire, que le gestionnaire a oublié d’appeler la politique, qu’un double de test se comporte différemment du dépôt de production ou qu’un événement de domaine n’atteint jamais la frontière applicative.
C’est l’un des problèmes de test les plus difficiles dans une application fondée sur le Domain-Driven Design : comment tester un comportement métier complet sans que chaque test doive démarrer HTTP, PostgreSQL, Redis, S3 et la moitié d’Internet ?
En développant une plateforme Symfony organisée en contextes délimités, j’ai séparé les tests selon la question à laquelle ils répondent :
-
Les tests unitaires PHPUnit vérifient les règles de domaine et les transitions d’état prises individuellement.
-
Les spécifications applicatives Behat exécutent de véritables commandes et requêtes à travers les véritables bus de l’application.
-
Les adaptateurs en mémoire remplacent la persistance et l’infrastructure externe sans remplacer le cas d’usage.
-
Des tests fonctionnels Symfony ciblés protègent les intégrations HTTP, de sécurité, de mapping, de Doctrine et de DBAL.
-
Les tests de contrat des packages protègent les schémas d’API exposés au frontend et les constructeurs de requêtes.
En tant que cofondateur, je veux aussi que la suite de tests préserve la connaissance du produit. Un scénario comme « un utilisateur couvert par une organisation ne peut pas acheter un autre abonnement » est bien plus utile à un nouveau développeur qu’un test nommé testHandleReturnsFalse().
Dans cet article, je présente la construction de cette architecture de test et la manière d’appliquer la même approche dans un projet Symfony et DDD générique.
L’objectif est d’avoir confiance à la bonne frontière
Un test n’a de valeur que si je sais quelle frontière il protège.
Les couches se chevauchent volontairement, sans dupliquer la même assertion.
Pour un cas d’usage d’inscription :
-
Un test unitaire démontre précisément pourquoi un mot de passe est faible.
-
Un scénario Behat démontre que l’inscription invoque réellement cette politique et ne stocke aucun compte après un rejet.
-
Un test HTTP démontre que le JSON mal formé et l’autorisation sont correctement convertis.
-
Un test de dépôt démontre l’unicité des adresses e-mail et le mapping de persistance dans PostgreSQL.
Chaque échec désigne une partie plus restreinte du système.
Pourquoi les tests unitaires sont nécessaires mais insuffisants
Le test unitaire classique d’une politique de mot de passe est utile :
Il est rapide, déterministe et documente précisément la matrice de la politique.
Mais il ne démontre pas que l’inscription appelle PasswordPolicy, que le gestionnaire de commande utilise le mot de passe fourni ni qu’une inscription rejetée laisse le dépôt inchangé.
Ces questions concernent l’application, pas la politique.
Étape 1 : décider ce qui appartient à chaque couche de test
J’utilise quatre catégories de tests backend.
Tests unitaires du domaine
Utilisez PHPUnit sans démarrer Symfony pour :
-
la validation et la normalisation des objets-valeurs ;
-
les transitions d’état des agrégats ;
-
les événements de domaine enregistrés par une entité ;
-
les politiques et les services de domaine ;
-
les matrices de calcul et les cas limites.
Ces tests doivent pouvoir s’exécuter sans Doctrine, HTTP, Messenger, Redis ni conteneur de services.
Spécifications applicatives
Utilisez Behat pour les comportements produit exposés à travers des commandes et des requêtes :
-
inscrire un utilisateur ;
-
payer une commande et accorder un accès ;
-
n’accepter une invitation qu’une seule fois ;
-
publier un document juridique versionné ;
-
créer une alerte après une modification surveillée.
Le scénario exécute le véritable gestionnaire et les collaborateurs du domaine à travers la frontière applicative, tandis que les ports techniques peuvent être remplacés par des implémentations en mémoire.
J’exige au moins un scénario applicatif pour chaque gestionnaire de commande et de requête, puis ajoute des scénarios de rejet pour les politiques et invariants importants accessibles par ce cas d’usage. Une couverture applicative manquante devient ainsi une lacune architecturale visible plutôt qu’une décision subjective lors de la revue.
Tests fonctionnels Symfony
Utilisez des tests PHPUnit ciblés du noyau ou du web pour les intégrations techniques :
-
les contrats de routes et de codes de statut ;
-
le mapping et la validation des payloads de requête ;
-
l’authentification, l’autorisation et les protections de plateforme ;
-
la conversion des exceptions en réponses de problème ;
-
la sérialisation et les structures de réponse importantes ;
-
l’intégration de la livraison des e-mails lorsque le comportement du transport compte.
Ne réécrivez pas un processus métier complet à travers HTTP simplement parce que l’endpoint existe. Le comportement applicatif appartient déjà à Behat.
Tests d’infrastructure
Utilisez la technologie réelle lorsque son comportement est précisément ce qui doit être testé :
-
les mappings Doctrine et les requêtes des dépôts ;
-
le filtrage, la pagination, JSONB et les projections DBAL ;
-
les contraintes d’unicité et le verrouillage ;
-
les adaptateurs de système de fichiers ;
-
les adaptateurs de protocoles de fournisseurs ;
-
le routage des messages et le comportement des middlewares.
Un dépôt en mémoire ne peut pas démontrer le fonctionnement d’un index unique partiel PostgreSQL. Un test Doctrine ne peut pas expliquer une règle d’abonnement aussi clairement qu’un scénario Behat. Les deux sont nécessaires, mais pour des raisons différentes.
Étape 2 : écrire le cas d’usage dans le langage métier
Ma fonctionnalité représentative est l’inscription :
@identity @application
Feature: User account
To protect access to customer accounts
As the identity application
I need registration to enforce account rules
Scenario: Registering a user starts account verification
Given current time is "2026-07-13 10:00:00"
When a user registers with the following details:
| name | email | phoneNumber | password |
| Jane Doe | jane@example.test | +243970000001 | Password1! |
Then the user should be pending verification
And the user should have the following profile:
| name | email | phoneNumber |
| Jane Doe | jane@example.test | +243970000001 |
And the registration should be announced
And email verification should be requestedLa fonctionnalité décrit la promesse de l’application. Elle ne mentionne ni RegisterUserHandler, ni Doctrine, ni les fabriques d’UUID, ni un mock du répartiteur d’événements.
Le langage technique est acceptable dans l’implémentation du contexte. Gherkin doit employer un vocabulaire qu’un ingénieur produit ou un expert du domaine peut discuter.
Comparez ces étapes :
Then a UserRegistered event should exist in the fake dispatcherThen the registration should be announcedLa seconde décrit pourquoi l’événement compte. Le contexte peut toujours vérifier sa classe exacte.
Étape 3 : traiter les contextes Behat comme des contrôleurs
Un contexte doit convertir les entrées du scénario en message applicatif, comme un contrôleur convertit les entrées HTTP :
final class UserContext extends AbstractContext
{
public function __construct(
private readonly UserRepository $users,
) {}
#[When('a user registers with the following details:')]
public function aUserRegisters(TableNode $details): void
{
$row = $this->singleRow($details, [
'name',
'email',
'phoneNumber',
'password',
]);
$this->handleCommand(new RegisterUserFromInput(
name: $row['name'],
email: $row['email'],
phoneNumber: $row['phoneNumber'],
password: $row['password'],
));
}
}Le contexte ne doit pas appeler directement User::register() pour l’action testée. Cela contournerait la conversion des entrées, le routage vers le gestionnaire, les services applicatifs, les politiques, la persistance et les événements, c’est-à-dire les collaborations précises que le scénario doit vérifier.
Le parcours testé est le suivant :
Il s’agit d’un véritable cas d’usage aux extrémités remplaçables, et non d’un grand test unitaire de la classe de contexte.
Étape 4 : exécuter à travers le même bus qu’en production
Mon contexte de base expose les contrats des bus applicatifs :
abstract class AbstractContext extends Assert implements Context, ServiceSubscriberInterface
{
use ServiceMethodsSubscriberTrait;
public static function getSubscribedServices(): array
{
return [
CommandBus::class,
QueryBus::class,
SharedStorage::class,
];
}
protected function handleCommand(object $command): mixed
{
return $this->container
->get(CommandBus::class)
->handle($command);
}
protected function handleQuery(object $query): mixed
{
return $this->container
->get(QueryBus::class)
->handle($query);
}
}Cette base abonnée aux services rend disponible l’infrastructure de test commune sans obliger chaque contexte enfant à répéter les arguments de constructeur liés aux bus et au stockage. Les contextes individuels n’injectent dans leur constructeur que les collaborateurs propres à leurs scénarios.
L’essentiel est que les contrôleurs et Behat utilisent les mêmes abstractions CommandBus et QueryBus. Symfony Messenger résout toujours le véritable gestionnaire de messages. La distribution des commandes imbriquées et le déballage des exceptions passent toujours par la jonction applicative.
L’environnement de test peut retirer les middlewares propres à l’infrastructure, comme la gestion de véritables transactions de base de données, lorsque tous les dépôts sont en mémoire. Testez ces middlewares séparément avec des tests unitaires ou d’intégration ciblés au lieu de prétendre qu’un dépôt fondé sur un tableau démontre le comportement transactionnel.
Enregistrez les contextes, les hooks et les adaptateurs de test comme services dans le conteneur de test :
# config/services_test.yaml
services:
_defaults:
autowire: true
autoconfigure: true
public: true
App\Tests\Support\:
resource: '../tests/Support/'
App\Tests\Behat\Context\:
resource: '../tests/Behat/Context/'
App\Tests\Behat\Hook\:
resource: '../tests/Behat/Hook/'
App\Tests\Behat\SharedStorage: ~Configurez ensuite Behat pour démarrer le noyau test de Symfony et résoudre les contextes depuis le conteneur :
// behat.php
return new Config()
->withProfile(
new Profile('default')
->withExtension(new Extension(SymfonyExtension::class, [
'bootstrap' => 'tests/bootstrap.php',
'kernel' => [
'class' => Kernel::class,
'environment' => 'test',
'debug' => true,
],
]))
->withSuite(
new Suite('application')
->withPaths('tests/Behat/features')
->addContext(ApplicationStateHook::class)
->addContext(TimeContext::class)
->addContext(UserContext::class),
),
);Behat et son extension Symfony évoluent indépendamment. Avant de copier cette configuration, vérifiez que les versions choisies prennent en charge vos versions de Symfony et de PHP.
Étape 5 : remplacer les ports, pas le cas d’usage
Le domaine déclare une interface de dépôt :
interface UserRepository
{
public function save(User $user): void;
public function get(UserId $id): User;
public function findByEmail(EmailAddress $email): ?User;
public function findByPhoneNumber(PhoneNumber $phoneNumber): ?User;
}La production utilise Doctrine. Behat utilise un adaptateur en mémoire qui implémente le même port :
#[AsAlias(UserRepository::class, when: 'test')]
final class InMemoryUserRepository implements UserRepository
{
/** @var array<string, User> */
private array $users = [];
public function save(User $user): void
{
$this->users[(string) $user->id] = $user;
}
public function get(UserId $id): User
{
return $this->users[(string) $id]
?? throw UserNotFound::withId($id);
}
public function findByEmail(EmailAddress $email): ?User
{
foreach ($this->users as $user) {
if ($user->email->equals($email)) {
return $user;
}
}
return null;
}
public function reset(): void
{
$this->users = [];
}
}L’alias propre aux tests est essentiel :
#[AsAlias(UserRepository::class, when: 'test')]Le code applicatif demande toujours UserRepository. Il ne sait jamais quel adaptateur a été sélectionné.
Le contexte Behat doit généralement injecter lui aussi l’interface :
public function __construct(
private readonly UserRepository $users,
) {}Les types concrets en mémoire ne conviennent que dans les hooks de réinitialisation ou lorsqu’un fake expose volontairement un comportement d’enregistrement propre aux tests.
Les adaptateurs en mémoire ne sont pas des mocks
Un mock répond à des questions sur les interactions :
$users->expects(self::once())
->method('save');Un adaptateur en mémoire fournit un comportement fonctionnel :
$saved = $users->findByEmail(new EmailAddress('jane@example.test'));
self::assertInstanceOf(User::class, $saved);Les spécifications applicatives bénéficient généralement de la seconde approche. Elles observent l’état ou le résultat promis par le cas d’usage plutôt que la séquence précise des appels aux collaborateurs.
Les mocks restent utiles lorsque l’interaction constitue elle-même le contrat, par exemple pour garantir qu’une passerelle de paiement est appelée une seule fois avec une clé d’idempotence. Simuler chaque méthode du dépôt tend toutefois à faire des tests applicatifs un reflet de l’implémentation du gestionnaire.
Étape 6 : faire du temps un port de test
Les tests dépendants du temps deviennent instables lorsque le code de production appelle new DateTimeImmutable() en interne.
L’application dépend d’une horloge :
interface Clock
{
public function now(): DateTimeImmutable;
}L’adaptateur de test encapsule l’horloge simulée de Symfony :
#[AsAlias(Clock::class, when: 'test')]
final class TestClock implements Clock
{
private MockClock $clock;
public function __construct()
{
$this->setNow(new DateTimeImmutable('2026-07-13 10:00:00'));
}
public function setNow(DateTimeImmutable $now): void
{
$this->clock = new MockClock($now);
}
public function now(): DateTimeImmutable
{
return DateTimeImmutable::createFromInterface($this->clock->now());
}
}Behat expose le temps dans le scénario :
final readonly class TimeContext implements Context
{
public function __construct(private TestClock $clock) {}
#[Given('current time is :time')]
public function currentTimeIs(string $time): void
{
$this->clock->setNow(new DateTimeImmutable($time));
}
}Les limites d’expiration sont désormais explicites :
Given current time is "2026-07-13 10:16:00"
When the security challenge is consumed
Then it should be rejected because the token expiredÉvitez les dates par défaut cachées lorsqu’elles influencent le comportement métier. Placez l’heure dans le scénario afin qu’un lecteur puisse vérifier la limite.
Étape 7 : enregistrer les événements sans exécuter les consommateurs sans rapport
Une commande applicative réussie peut publier des événements de domaine. Dans les spécifications applicatives, je veux souvent démontrer que l’annonce a eu lieu sans envoyer de véritable e-mail ni invoquer chaque abonné asynchrone.
Utilisez un adaptateur d’enregistrement :
#[AsAlias(EventDispatcher::class, when: 'test')]
final class InMemoryEventDispatcher implements EventDispatcher
{
/** @var list<object> */
public array $events = [];
public function dispatch(array $events): void
{
array_push($this->events, ...array_values($events));
}
public function reset(): void
{
$this->events = [];
}
}Le Gherkin reste orienté métier :
Then the registration should be announced
And email verification should be requestedLe contexte associe ces déclarations à des types d’événements précis :
#[Then('the registration should be announced')]
public function registrationShouldBeAnnounced(): void
{
$this->assertEventWasRecorded(UserRegistered::class);
}
#[Then('email verification should be requested')]
public function verificationShouldBeRequested(): void
{
$this->assertEventWasRecorded(SecurityChallengeIssued::class);
}Ce test démontre que le cas d’usage a annoncé des faits significatifs. Des tests distincts du transport des messages doivent vérifier le routage, le comportement des nouvelles tentatives et la configuration des workers.
Étape 8 : tester les rejets à travers le cas d’usage complet
Une exception de domaine mérite davantage qu’un test isolé de la politique lorsque les utilisateurs peuvent la déclencher par une commande applicative.
Scenario: Rejecting a registration with an email already used
Given current time is "2026-07-13 10:00:00"
And a registered user exists with the following details:
| name | email | phoneNumber | password |
| Jane Doe | jane@example.test | +243970000001 | Password1! |
When another user registers with the following details:
| name | email | phoneNumber | password |
| Jane Duplicate | jane@example.test | +243970000002 | Password1! |
Then registration should be rejected because the email is already usedLe contexte capture la véritable exception provenant du bus de commandes :
protected function catchThrowable(string $key, callable $action): void
{
try {
$action();
$this->sharedStorage->remove($key);
} catch (Throwable $throwable) {
$this->sharedStorage->set($key, $throwable);
}
}#[Then('registration should be rejected because the email is already used')]
public function registrationShouldBeRejected(): void
{
self::assertInstanceOf(
EmailAlreadyUsed::class,
$this->caughtThrowable('identity.registration.error'),
);
}Cela démontre que le gestionnaire atteint réellement la politique d’unicité et que le bus de production préserve l’échec du domaine.
Étape 9 : vérifier l’absence d’état partiel
Tester uniquement l’exception est incomplet. Une commande peut lever la bonne erreur après avoir déjà modifié quelque chose.
Ajoutez une assertion négative sur l’état :
Scenario: Rejecting a registration with a weak password
Given current time is "2026-07-13 10:00:00"
When a user tries to register with the following details:
| name | email | phoneNumber | password |
| Jane Doe | jane@example.test | +243970000001 | password! |
Then registration should be rejected because the password is weak
And no account should be registered for that email#[Then('no account should be registered for that email')]
public function noAccountShouldBeRegistered(): void
{
$email = $this->sharedStorage->get('identity.registration.email');
self::assertIsString($email);
self::assertNull(
$this->users->findByEmail(new EmailAddress($email)),
);
}En cas d’échec de paiement, vérifiez que la commande n’a pas été marquée comme payée. Pour une publication rejetée, vérifiez qu’aucune version publique n’existe. Pour une commande groupée échouée, vérifiez que chaque agrégat reste inchangé.
Le rejet et sa conséquence sur l’état forment un seul comportement.
Étape 10 : limiter et nommer l’état partagé du scénario
Les étapes doivent parfois partager un identifiant, un modèle renvoyé, un jeton de vérification ou une exception capturée.
Utilisez un petit stockage local au scénario avec des clés à espaces de noms :
identity.user_id
identity.verification_token
identity.registration.error
billing.order
access.subscription
corpus.expressionNe transformez pas le stockage partagé en conteneur caché de fixtures.
Stockez :
-
les ID produits par les commandes précédentes ;
-
les résultats des commandes ou des requêtes ;
-
les jetons observés dans les événements enregistrés ;
-
les exceptions vérifiées par des étapes ultérieures.
Évitez de stocker des agrégats uniquement pour qu’une étape ultérieure puisse appeler leurs méthodes. Rechargez-les à travers un dépôt ou exécutez une requête. Le scénario reste ainsi à la frontière applicative.
Des accesseurs typés rendent les échecs évidents :
public function object(string $key, string $expectedClass): object
{
$value = $this->storage[$key] ?? null;
if (! $value instanceof $expectedClass) {
throw new LogicException(sprintf(
'Expected "%s" to contain %s.',
$key,
$expectedClass,
));
}
return $value;
}Étape 11 : réinitialiser chaque adaptateur de test avant chaque scénario
Un état en mémoire reste un état. Sans réinitialisation, les scénarios dépendent de leur ordre d’exécution.
J’utilise un hook Behat BeforeScenario :
final readonly class ApplicationStateHook implements Context
{
public function __construct(
private SharedStorage $storage,
private TestClock $clock,
private InMemoryEventDispatcher $events,
private InMemoryUserRepository $users,
private InMemorySubscriptionRepository $subscriptions,
) {}
#[BeforeScenario]
public function reset(): void
{
$this->storage->clear();
$this->clock->setNow(
new DateTimeImmutable('2026-07-13 10:00:00'),
);
$this->events->reset();
$this->users->reset();
$this->subscriptions->reset();
}
}Chaque nouvel adaptateur de test avec état doit participer à la réinitialisation. Si la liste devient trop longue, regroupez les adaptateurs réinitialisables derrière un petit registre au lieu de laisser subsister un état caché entre les scénarios.
Les scénarios doivent réussir individuellement, dans un ordre aléatoire et dans la suite complète.
Étape 12 : tester aussi les requêtes à travers le bus de requêtes
Les spécifications applicatives ne concernent pas uniquement les commandes.
Scenario: Listing policy documents
Given the following policy documents exist:
| slug | title |
| privacy | Privacy Policy |
| terms-of-use | Terms of Use |
When policy documents are listed through the application
Then the policy document list should contain:
| slug |
| privacy |
| terms-of-use |L’étape exécute le véritable gestionnaire de requête :
#[When('policy documents are listed through the application')]
public function listPolicyDocuments(): void
{
$result = $this->handleQuery(new ListPolicyDocuments());
$this->sharedStorage->set('policy.document_list', $result);
}Cela démontre le câblage du gestionnaire, l’orchestration tenant compte des accès et le mapping de la projection.
Le modèle de lecture en mémoire ne démontre pas le fonctionnement du SQL de production. Ajoutez à l’implémentation DBAL des tests distincts pour le filtrage, le tri, la pagination, les jointures et les expressions propres à la base de données.
Étape 13 : ne démarrer Symfony que pour les questions liées au framework
Les spécifications applicatives Behat démarrent le conteneur de test afin de résoudre les véritables gestionnaires et alias, mais évitent volontairement HTTP et la persistance réelle.
Utilisez WebTestCase lorsque HTTP lui-même est testé :
final class RegisterUserEndpointTest extends WebTestCase
{
public function testInvalidPayloadReturnsValidationProblem(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/identity/auth/register', [
'name' => '',
'email' => 'not-an-email',
]);
self::assertResponseStatusCodeSame(422);
self::assertResponseHeaderSame(
'content-type',
'application/problem+json',
);
}
}Les assertions fonctionnelles utiles couvrent notamment :
-
la route n’accepte que la méthode HTTP prévue ;
-
le modèle de requête mappe et valide les entrées ;
-
les protections de plateforme et de sécurité rejettent les mauvais appelants ;
-
une exception destinée à l’utilisateur devient le document de problème attendu ;
-
la sérialisation de la réponse préserve le contrat public.
Évitez de répéter toute la matrice des mots de passe à travers HTTP. Un parcours représentatif de validation ou de conflit suffit à démontrer la conversion. La matrice de la politique appartient déjà au test unitaire.
Étape 14 : tester Doctrine avec Doctrine
Un dépôt fondé sur un tableau ne peut pas reproduire :
-
la collation SQL ;
-
les contraintes d’unicité ;
-
les mappings Doctrine et les types DBAL personnalisés ;
-
les opérateurs JSONB ;
-
l’isolation des transactions ;
-
les index partiels ;
-
les jointures, la pagination et le tri ;
-
le verrouillage de la base de données.
Écrivez des tests de persistance ciblés sur la véritable base de données de test pour ces comportements.
J’utilise un suffixe distinct pour la base de données de test et DAMA Doctrine Test Bundle. Celui-ci conserve une connexion statique et encapsule les tests PHPUnit dans des transactions :
when@test:
doctrine:
dbal:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
dama_doctrine_test:
enable_static_connection: true
enable_static_meta_data_cache: true
enable_static_query_cache: trueLes tests de base de données restent ainsi isolés sans reconstruire le schéma pour chaque méthode.
Utilisez les migrations pour créer le schéma de la base de données de test. Employez la plus petite fixture capable de démontrer le fonctionnement de la requête. Un test de dépôt ne doit pas importer un jeu de données de la taille de la production.
Étape 15 : remplacer les systèmes externes par des fakes spécialisés
Les tests applicatifs ne doivent pas appeler de véritables passerelles de paiement, stockages d’objets, fournisseurs d’IA ou d’e-mails, instances Redis ou services en temps réel.
Créez un fake qui modélise le comportement utile du port :
#[AsAlias(PaymentGateway::class, when: 'test')]
final class InMemoryPaymentGateway implements PaymentGateway
{
/** @var list<PaymentRequest> */
public array $requests = [];
public ?Throwable $nextFailure = null;
public function charge(PaymentRequest $request): PaymentResult
{
$this->requests[] = $request;
if ($this->nextFailure instanceof Throwable) {
throw $this->nextFailure;
}
return PaymentResult::accepted('test-reference');
}
}Le fake permet à un scénario de démontrer la réussite, le rejet et la sécurité des entrées en cas de nouvelle tentative, sans l’indéterminisme du réseau.
Dans l’environnement de test, acheminez les transports Messenger asynchrones vers la mémoire :
when@test:
framework:
messenger:
transports:
async: 'in-memory://'
failed: 'in-memory://'Vérifiez ensuite qu’un message a été distribué. Testez séparément le worker réel, le DSN du transport et la politique de nouvelle tentative lorsque ces garanties techniques comptent.
Étape 16 : protéger les contrats frontend sans répéter le comportement du backend
Dans un monorepo, les tests des packages frontend protègent la frontière TypeScript :
-
l’analyse des réponses avec Zod ;
-
la construction des endpoints ;
-
la sérialisation des chaînes de requête ;
-
les clés TanStack Query ;
-
la visibilité des façades de plateforme ;
-
la normalisation des erreurs.
it("rejects an invalid user-list response", () => {
expect(() =>
listUsersResponseSchema.parse({
items: [{ userId: 42 }],
pagination: null,
}),
).toThrow();
});Ne recréez pas les règles d’abonnement dans des mocks TypeScript. Le comportement métier du backend appartient aux spécifications applicatives du backend. Les tests frontend démontrent les contrats d’adaptation et de présentation.
Étape 17 : choisir le test le plus restreint qui peut échouer pour la bonne raison
Avant d’écrire un test, posez-vous les questions suivantes :
Le test utile le plus restreint est généralement plus rapide, plus clair et plus facile à diagnostiquer. « Le plus restreint » ne signifie pas « simuler chaque collaborateur ». Il s’agit de choisir la plus petite frontière qui contient encore le comportement.
Exécuter les différentes couches de test
Utilisez des commandes ciblées pendant le développement :
# Domain and technical PHPUnit tests
APP_ENV=test php -dmemory_limit=-1 ./vendor/bin/phpunit tests/Unit
# One application feature
APP_ENV=test php -dmemory_limit=-1 ./vendor/bin/behat \
tests/Behat/features/identity/user.feature
# Full backend suites
composer app:test
composer app:behat
# Frontend package contracts
bun --filter @app/api testExécutez d’abord la cible pertinente la plus restreinte, puis la suite complète avant la fusion.
Erreurs courantes dans les tests DDD
Tester les gestionnaires uniquement avec des mocks
Des tests centrés sur les interactions peuvent réussir alors que le câblage de l’application, le routage du bus, les politiques et les résultats d’état réels sont défaillants. Conservez des mocks ciblés lorsqu’ils sont utiles, mais ajoutez des spécifications de cas d’usage à travers le bus.
Piloter directement les agrégats depuis les étapes Behat
Cette pratique contourne la couche Application. Utilisez des commandes et des requêtes pour les actions testées.
Considérer les dépôts en mémoire comme des tests de persistance
Ils démontrent l’usage que l’application fait du contrat de dépôt. Ils ne démontrent pas le comportement de Doctrine ni du SQL.
Écrire le Gherkin dans un langage technique
Les fichiers de fonctionnalités doivent préserver le comportement métier, et non les noms de classes PHP ou le vocabulaire des mocks.
Cacher les entrées importantes dans les définitions d’étapes
Les dates, les acteurs, les plateformes, les montants, les statuts et les identifiants doivent apparaître dans le scénario lorsqu’ils influencent le comportement.
Tester uniquement les parcours nominaux
Les politiques, les exceptions, les limites d’expiration, la propriété, l’idempotence et la prévention des états partiels méritent des scénarios explicites.
Partager un état entre les scénarios
Réinitialisez chaque adaptateur en mémoire, enregistreur d’événements, faux fournisseur, horloge et stockage partagé avant chaque scénario.
Dupliquer la même matrice dans chaque couche
Testez une seule fois la matrice complète des mots de passe dans la politique. Utilisez des parcours représentatifs aux frontières applicative et HTTP.
Appeler de véritables services externes
Les tests réseau deviennent lents, coûteux et non déterministes. Testez localement le contrat de votre adaptateur et réservez les tests dans le bac à sable du fournisseur à un pipeline d’intégration contrôlé.
Vérifier les détails privés de l’implémentation
Vérifiez l’état observable, les modèles renvoyés, les rejets du domaine et les annonces significatives. La refactorisation des appels de méthodes internes ne devrait pas imposer la réécriture de chaque spécification applicative.
Conclusion
Tester une application DDD au-delà des tests unitaires ne signifie pas transformer chaque scénario en un lent test de bout en bout dans le navigateur.
-
Les tests unitaires protègent des règles de domaine et des transitions d’état précises.
-
Les scénarios Behat décrivent un comportement applicatif complet dans le langage produit.
-
Les contextes agissent comme des contrôleurs et distribuent de véritables commandes et requêtes.
-
Les adaptateurs en mémoire remplacent les ports d’infrastructure sans remplacer les gestionnaires ni la logique du domaine.
-
Une horloge de test rend les limites temporelles explicites et déterministes.
-
Les adaptateurs d’enregistrement observent les annonces du domaine sans exécuter les consommateurs sans rapport.
-
Les scénarios de rejet vérifient à la fois l’exception et l’absence d’état partiel.
-
Un petit stockage partagé à espaces de noms relie les étapes sans devenir un dépôt caché de fixtures.
-
Les hooks de réinitialisation maintiennent chaque scénario isolé.
-
Les tests fonctionnels Symfony protègent HTTP, la sécurité, la validation et la sérialisation.
-
De véritables tests Doctrine protègent les mappings, les contraintes, les requêtes et la sémantique de la base de données.
-
Des fakes spécialisés isolent les fournisseurs de paiement, de stockage, d’e-mails, d’IA et de messagerie.
-
Les tests des packages frontend protègent les contrats sans dupliquer les règles du backend.
Le résultat n’est pas seulement une suite de tests plus grande. C’est un ensemble de frontières exécutables.
Lorsqu’une règle du domaine change, le test de la politique explique les cas limites. Lorsque l’orchestration échoue, le scénario Behat nomme le cas d’usage concerné. Lorsque l’intégration SQL ou Symfony échoue, un test technique ciblé désigne directement l’adaptateur.
C’est ce type de suite de tests qui permet à une équipe de refactoriser son architecture sans perdre le produit qu’elle renferme.
Bon développement !