Builder Pattern : Simplifier les Mocks dans les Tests Symfony
Le Builder Pattern transforme radicalement la lisibilité des tests PHP. Découvrez comment l'implémenter en Symfony pour des mocks clairs, des fixtures maintenables et des tests résistants aux changements de signature.
Builder Pattern : Simplifier les Mocks dans les Tests Symfony
Le Builder Pattern est l’un de ces outils qui, une fois adopté, transforme radicalement la façon d’écrire des tests. Cet article montre comment l’implémenter en PHP/Symfony et pourquoi il rend les tests lisibles, maintenables et robustes face aux changements.
Le Problème : Des Tests Fragiles et Verbeux
Imaginons une entité Order avec plusieurs collaborateurs :
// src/Entity/Order.php
class Order
{
public function __construct(
private readonly CustomerId $customerId,
private readonly Address $shippingAddress,
private readonly Address $billingAddress,
private readonly OrderLines $lines,
private readonly Coupon|null $coupon,
private readonly PaymentMethod $paymentMethod,
private readonly OrderStatus $status,
private readonly \DateTimeImmutable $createdAt,
) {}
}
Sans Builder Pattern, chaque test qui a besoin d’un Order ressemble à ceci :
public function testOrderTotalWithCoupon(): void
{
$order = new Order(
customerId: new CustomerId('cust-123'),
shippingAddress: new Address('12 rue de la Paix', 'Paris', '75001', 'FR'),
billingAddress: new Address('12 rue de la Paix', 'Paris', '75001', 'FR'),
lines: new OrderLines([
new OrderLine(new ProductId('prod-1'), 2, Money::EUR(5000)),
new OrderLine(new ProductId('prod-2'), 1, Money::EUR(2000)),
]),
coupon: new Coupon('PROMO10', DiscountType::Percentage, 10),
paymentMethod: new PaymentMethod(PaymentType::Card, 'pm_test_123'),
status: OrderStatus::Pending,
createdAt: new \DateTimeImmutable('2025-01-15'),
);
// ... le test lui-même
}
Trois problèmes immédiats :
- Bruit — 10 lignes de setup pour tester une seule règle métier
- Couplage — si le constructeur change, tous les tests cassent
- Intention cachée — impossible de voir d’un coup d’oeil ce qui est pertinent pour ce test
La Solution : Le Builder Pattern
Le Builder Pattern sépare la construction d’un objet de sa représentation. Pour les tests, il permet de définir des valeurs par défaut sensées et d’overrider uniquement ce qui est pertinent.
Structure de base
// tests/Builder/OrderBuilder.php
final class OrderBuilder
{
private CustomerId $customerId;
private Address $shippingAddress;
private Address $billingAddress;
private OrderLines $lines;
private Coupon|null $coupon = null;
private PaymentMethod $paymentMethod;
private OrderStatus $status;
private \DateTimeImmutable $createdAt;
public function __construct()
{
// Valeurs par défaut "valides" — un Order prêt à l'emploi
$this->customerId = new CustomerId('cust-default');
$this->shippingAddress = AddressBuilder::aFrenchAddress()->build();
$this->billingAddress = AddressBuilder::aFrenchAddress()->build();
$this->lines = new OrderLines([
OrderLineBuilder::aLine()->build(),
]);
$this->paymentMethod = new PaymentMethod(PaymentType::Card, 'pm_test_default');
$this->status = OrderStatus::Pending;
$this->createdAt = new \DateTimeImmutable('2025-01-01');
}
// Named constructor — point d'entrée sémantique
public static function anOrder(): self
{
return new self();
}
public function withCustomerId(CustomerId $customerId): self
{
$clone = clone $this;
$clone->customerId = $customerId;
return $clone;
}
public function withCoupon(Coupon $coupon): self
{
$clone = clone $this;
$clone->coupon = $coupon;
return $clone;
}
public function withLines(OrderLine ...$lines): self
{
$clone = clone $this;
$clone->lines = new OrderLines($lines);
return $clone;
}
public function withStatus(OrderStatus $status): self
{
$clone = clone $this;
$clone->status = $status;
return $clone;
}
public function confirmedAt(\DateTimeImmutable $at): self
{
$clone = clone $this;
$clone->status = OrderStatus::Confirmed;
$clone->createdAt = $at;
return $clone;
}
public function build(): Order
{
return new Order(
customerId: $this->customerId,
shippingAddress: $this->shippingAddress,
billingAddress: $this->billingAddress,
lines: $this->lines,
coupon: $this->coupon,
paymentMethod: $this->paymentMethod,
status: $this->status,
createdAt: $this->createdAt,
);
}
}
Pourquoi
clone $thisplutôt que$this->prop = ...+return $this? L’immuabilité du builder permet de le réutiliser comme base dans plusieurs tests sans effets de bord.
Avant / Après : La Différence en Pratique
Test du calcul avec coupon
// Avant
public function testTotalIsReducedByCoupon(): void
{
$order = new Order(
customerId: new CustomerId('cust-123'),
shippingAddress: new Address('12 rue de la Paix', 'Paris', '75001', 'FR'),
billingAddress: new Address('12 rue de la Paix', 'Paris', '75001', 'FR'),
lines: new OrderLines([
new OrderLine(new ProductId('prod-1'), 2, Money::EUR(5000)),
]),
coupon: new Coupon('PROMO10', DiscountType::Percentage, 10),
paymentMethod: new PaymentMethod(PaymentType::Card, 'pm_test_123'),
status: OrderStatus::Pending,
createdAt: new \DateTimeImmutable('2025-01-15'),
);
self::assertEquals(Money::EUR(9000), $order->total());
}
// Après
public function testTotalIsReducedByCoupon(): void
{
$order = OrderBuilder::anOrder()
->withLines(OrderLineBuilder::aLine()->costing(Money::EUR(10000))->build())
->withCoupon(new Coupon('PROMO10', DiscountType::Percentage, 10))
->build();
self::assertEquals(Money::EUR(9000), $order->total());
}
L’intention est immédiate : on teste l’effet du coupon sur le total. Tout le reste est bruit.
Application au Mocking dans Symfony
C’est ici que le Builder Pattern devient vraiment puissant. En Symfony, on mocke souvent des services, des repositories ou des entités complexes. Le Builder s’applique aux deux côtés : à la construction des objets ET à la configuration des mocks.
Pattern 1 : Builder pour configurer un Mock de Repository
// tests/Builder/OrderRepositoryMockBuilder.php
final class OrderRepositoryMockBuilder
{
/** @var Order[] */
private array $orders = [];
private MockObject $mock;
public function __construct(private readonly TestCase $testCase)
{
$this->mock = $this->testCase->createMock(OrderRepository::class);
}
public static function create(TestCase $testCase): self
{
return new self($testCase);
}
public function withOrder(Order $order): self
{
$clone = clone $this;
$clone->orders[] = $order;
return $clone;
}
public function returningNothingForId(OrderId $id): self
{
$clone = clone $this;
$clone->mock
->method('findById')
->with($id)
->willReturn(null);
return $clone;
}
public function build(): OrderRepository
{
$this->mock
->method('findAll')
->willReturn($this->orders);
foreach ($this->orders as $order) {
$this->mock
->method('findById')
->with($order->id())
->willReturn($order);
}
return $this->mock;
}
}
Utilisation dans un test de commande Symfony :
public function testGetOrderReturns404WhenNotFound(): void
{
$unknownId = new OrderId('order-unknown');
$repository = OrderRepositoryMockBuilder::create($this)
->returningNothingForId($unknownId)
->build();
$handler = new GetOrderQueryHandler($repository);
$this->expectException(OrderNotFoundException::class);
$handler(new GetOrderQuery($unknownId->value()));
}
Pattern 2 : Mother Object + Builder (Object Mother Pattern)
L’Object Mother est un Builder pré-configuré pour des scénarios métier courants. Il vit dans tests/Mother/ et centralise les cas d’usage récurrents.
// tests/Mother/OrderMother.php
final class OrderMother
{
public static function aPendingOrder(): Order
{
return OrderBuilder::anOrder()
->withStatus(OrderStatus::Pending)
->build();
}
public static function aConfirmedOrderWithExpiredCoupon(): Order
{
return OrderBuilder::anOrder()
->withCoupon(CouponBuilder::aCoupon()->expiredAt(new \DateTimeImmutable('-1 day'))->build())
->confirmedAt(new \DateTimeImmutable('-10 days'))
->build();
}
public static function aHighValueOrder(): Order
{
return OrderBuilder::anOrder()
->withLines(
OrderLineBuilder::aLine()->costing(Money::EUR(50000))->withQuantity(3)->build(),
OrderLineBuilder::aLine()->costing(Money::EUR(30000))->withQuantity(2)->build(),
)
->build();
}
}
Les tests deviennent alors de la documentation vivante :
public function testExpiredCouponIsNotApplied(): void
{
$order = OrderMother::aConfirmedOrderWithExpiredCoupon();
self::assertFalse($order->hasCouponApplied());
}
public function testHighValueOrderQualifiesForPremiumShipping(): void
{
$order = OrderMother::aHighValueOrder();
self::assertTrue($order->qualifiesForPremiumShipping());
}
Pattern 3 : Builder pour les Fixtures Doctrine dans les Tests d’Intégration
Dans les tests d’intégration Symfony avec une vraie base de données, le Builder s’intègre naturellement avec les fixtures :
// tests/Builder/OrderPersistenceBuilder.php
final class OrderPersistenceBuilder
{
private OrderBuilder $orderBuilder;
public function __construct(
private readonly EntityManagerInterface $em,
) {
$this->orderBuilder = OrderBuilder::anOrder();
}
public static function create(EntityManagerInterface $em): self
{
return new self($em);
}
public function withStatus(OrderStatus $status): self
{
$clone = clone $this;
$clone->orderBuilder = $clone->orderBuilder->withStatus($status);
return $clone;
}
public function persist(): Order
{
$order = $this->orderBuilder->build();
$this->em->persist($order);
$this->em->flush();
return $order;
}
}
Utilisé dans un test de repository :
// tests/Integration/Repository/OrderRepositoryTest.php
class OrderRepositoryTest extends KernelTestCase
{
use ResetDatabase;
public function testFindsPendingOrdersOnly(): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
OrderPersistenceBuilder::create($em)->withStatus(OrderStatus::Pending)->persist();
OrderPersistenceBuilder::create($em)->withStatus(OrderStatus::Confirmed)->persist();
OrderPersistenceBuilder::create($em)->withStatus(OrderStatus::Cancelled)->persist();
$repository = self::getContainer()->get(OrderRepository::class);
$pendingOrders = $repository->findByStatus(OrderStatus::Pending);
self::assertCount(1, $pendingOrders);
}
}
Résistance aux Changements de Signature
C’est l’avantage le moins visible mais le plus précieux sur la durée.
Ajoutons un nouveau champ obligatoire à Order — par exemple une DeliveryWindow :
// Nouveau champ dans Order
public function __construct(
// ... champs existants
private readonly DeliveryWindow $deliveryWindow, // nouveau
) {}
Sans Builder : tous les new Order(...) dans les tests cassent. On doit aller en modifier des dizaines.
Avec Builder : un seul endroit à modifier — OrderBuilder::__construct() :
public function __construct()
{
// ... valeurs existantes
$this->deliveryWindow = DeliveryWindow::standard(); // valeur par défaut ajoutée ici
}
public function withDeliveryWindow(DeliveryWindow $window): self
{
$clone = clone $this;
$clone->deliveryWindow = $window;
return $clone;
}
Tous les tests qui n’ont pas besoin de la DeliveryWindow continuent de fonctionner sans modification.
Organisation Recommandée
tests/
├── Builder/
│ ├── OrderBuilder.php # Construction flexible
│ ├── OrderLineBuilder.php
│ ├── AddressBuilder.php
│ ├── CouponBuilder.php
│ └── OrderRepositoryMockBuilder.php
├── Mother/
│ ├── OrderMother.php # Scénarios métier nommés
│ └── CustomerMother.php
├── Integration/
│ └── Repository/
│ └── OrderRepositoryTest.php
└── Unit/
└── Domain/
└── OrderTest.php
Règles à Retenir
| Règle | Pourquoi |
|---|---|
| Valeurs par défaut toujours valides | Le Builder doit produire un objet fonctionnel sans override |
| Un Builder par Aggregate Root | Évite la prolifération et centralise la logique de construction |
| Méthodes nommées sur le métier | confirmedAt() > withStatusConfirmedAndCreatedAt() |
| Clone dans chaque with*() | Immuabilité = réutilisation sans effet de bord |
| Object Mother pour les scénarios récurrents | DRY sur les cas d’usage, pas sur la construction |
Conclusion
Le Builder Pattern dans les tests n’est pas du sur-engineering. C’est un investissement qui paie dès le deuxième test qui utilise le même objet.
En Symfony, il s’adapte à tous les niveaux du test : unitaire avec des entités pures, intégration avec Doctrine, fonctionnel avec des services mockés. La clé est de le voir comme un DSL de test qui parle métier — OrderMother::aHighValueOrder() communique une intention là où new Order(...) avec 8 arguments communique une implémentation.
Un bon Builder de test se reconnaît quand supprimer un champ de l’entité ne casse qu’un seul fichier de test.