Testování DDD kódu v Symfony
Filozofie testování v DDD
Domain-Driven Design a automatizované testování se vzájemně přirozeně doplňují. Čistá doménová vrstva, která neobsahuje závislosti na frameworku, databázi ani jiné infrastruktuře, je ze své podstaty snadno testovatelná. Zatímco v tradičních vrstvených architekturách jsou unit testy kompromitovány provázaností s frameworkem, v DDD je doménová logika izolována a lze ji testovat čistými PHP třídami bez jakéhokoli bootstrappingu.
Proč je DDD přirozeně testovatelný:
- Žádné závislosti na frameworku - Doménové třídy (entity, value objects, agregáty) jsou čisté PHP objekty. Nepotřebují Symfony kontejner, Doctrine ani HTTP stack.
- Explicitní závislosti - Závislosti jsou vždy předávány přes konstruktor (constructor injection), nikoli získávány ze statických globálních objektů. To umožňuje jejich snadnou záměnu za test doubles.
- Bohaté doménové modely - Veškerá doménová logika je soustředěna v doménových objektech, nikoliv roztroušena v kontrolerech nebo šablonách, takže testy pokrývají skutečně důležité chování.
- Invarianty jsou vynucovány při konstrukci - Value objekty a agregáty ověřují svá invariantní pravidla v konstruktoru nebo v továrních metodách, což usnadňuje testování správného i nesprávného stavu.
Testovací pyramida pro DDD
Testovací pyramida (koncept popularizovaný Mikem Cohnem v knize Succeeding with Agile, 2009 [1]) v DDD kontextu definuje tři vrstvy testů, přičemž každá vrstva pokrývá jinou část aplikace a vyznačuje se jiným poměrem rychlosti, izolace a pokrytí:
Vrstvy testovací pyramidy:
-
Unit testy - doménová vrstva (základ pyramidy, nejvíce testů)
Testují izolované doménové objekty: value objects, entity, agregáty a doménové služby. Nepotřebují databázi ani framework. Jsou velmi rychlé (stovky testů za sekundu). Cíl: ověřit doménová pravidla a invarianty. -
Integrační testy - infrastrukturní vrstva (střed pyramidy)
Testují spolupráci doménového kódu s infrastrukturou: Doctrine repozitáře, e-mailové odesílatele, messagingové systémy. Vyžadují databázi nebo jiné externí zdroje. Jsou pomalejší, ale nezbytné pro ověření mapování a persistence. Cíl: ověřit, že infrastruktura správně implementuje doménová rozhraní. -
Funkční testy - aplikační vrstva / API (špička pyramidy, nejméně testů)
Testují celé use cases přes HTTP vrstvu nebo přímo přes aplikační služby. Simulují reálného uživatele aplikace. Jsou nejpomalejší a nejkřehčí. Cíl: ověřit integraci všech vrstev v hlavních scénářích.
Testovací strategie - co testovat na každé vrstvě:
- Doménová vrstva: Validační logika value objects, invarianty entit, transakční konzistence agregátů, vydávání doménových událostí, doménové výjimky.
- Aplikační vrstva: Command handlery a query handlery - s použitím fake (InMemory) repozitářů, ověření, že správné metody repozitáře jsou volány s očekávanými argumenty.
- Infrastrukturní vrstva: Správné Doctrine mapování, dotazy repozitářů, transakce, volání externích API.
- Prezentační vrstva: Správné HTTP status kódy, formát odpovědi, autentizace a autorizace.
Unit testy doménové vrstvy
Unit testy doménové vrstvy jsou nejcennější součástí testovací sady DDD aplikace. Testují čisté PHP objekty bez jakýchkoli závislostí na frameworku. Pro jejich spuštění stačí nainstalovat PHPUnit a samotné doménové třídy - žádný bootstrap Symfony kernelu není potřeba.
Testování Value Objects
Value objekty jsou immutabilní a porovnávají se hodnotou, nikoli identitou. Testy value objektů ověřují: immutabilitu (operace vrací novou instanci, nikoli modifikují stávající), rovnost dvou instancí se stejnou hodnotou, validaci - že neplatné vstupy vyvolají odpovídající výjimku.
Příklad: Test pro Email value object (PHPUnit)
<?php
declare(strict_types=1);
namespace Tests\UserManagement\Domain\ValueObject;
use PHPUnit\Framework\TestCase;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\Exception\InvalidEmailException;
final class EmailTest extends TestCase
{
public function testCreatesValidEmail(): void
{
$email = new Email('jan.novak@example.com');
$this->assertSame('jan.novak@example.com', $email->value());
}
public function testNormalizesToLowercase(): void
{
$email = new Email('Jan.Novak@EXAMPLE.COM');
$this->assertSame('jan.novak@example.com', $email->value());
}
public function testThrowsExceptionForInvalidFormat(): void
{
$this->expectException(InvalidEmailException::class);
$this->expectExceptionMessage('Neplatná e-mailová adresa: "not-an-email"');
new Email('not-an-email');
}
public function testThrowsExceptionForEmptyString(): void
{
$this->expectException(InvalidEmailException::class);
new Email('');
}
public function testEqualityBySameValue(): void
{
$email1 = new Email('jan@example.com');
$email2 = new Email('jan@example.com');
$this->assertTrue($email1->equals($email2));
}
public function testInequalityForDifferentValues(): void
{
$email1 = new Email('jan@example.com');
$email2 = new Email('petr@example.com');
$this->assertFalse($email1->equals($email2));
}
public function testImmutabilityViaNewInstance(): void
{
$original = new Email('jan@example.com');
// Hodnotové objekty jsou immutabilní - změna vyžaduje vytvoření nové instance
$different = new Email('petr@example.com');
$this->assertSame('jan@example.com', $original->value());
$this->assertSame('petr@example.com', $different->value());
$this->assertFalse($original->equals($different));
}
}
Testování entit
Entity mají identitu a měnitelný stav. Testy entit ověřují doménová pravidla (invarianty), správné chování metod a vyhazování doménových výjimek při porušení pravidel. Klíčové je testovat chování, ne strukturu - tedy co entita dělá, ne jak vypadají její fieldy.
Příklad: Test pro User entitu
<?php
declare(strict_types=1);
namespace Tests\UserManagement\Domain\Model;
use PHPUnit\Framework\TestCase;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\HashedPassword;
use App\UserManagement\Domain\Exception\UserAlreadyActiveException;
final class UserTest extends TestCase
{
private UserId $userId;
private Email $email;
protected function setUp(): void
{
$this->userId = UserId::generate();
$this->email = new Email('jan@example.com');
}
public function testCreatesInactiveUserByDefault(): void
{
$user = User::register($this->userId, 'Jan Novák', $this->email, HashedPassword::fromPlainText('secret123'));
$this->assertFalse($user->isActive());
}
public function testActivatesUser(): void
{
$user = User::register($this->userId, 'Jan Novák', $this->email, HashedPassword::fromPlainText('secret123'));
$user->activate();
$this->assertTrue($user->isActive());
}
public function testThrowsExceptionWhenActivatingAlreadyActiveUser(): void
{
$user = User::register($this->userId, 'Jan Novák', $this->email, HashedPassword::fromPlainText('secret123'));
$user->activate();
$this->expectException(UserAlreadyActiveException::class);
$user->activate();
}
public function testChangesEmailAddress(): void
{
$user = User::register($this->userId, 'Jan Novák', $this->email, HashedPassword::fromPlainText('secret123'));
$newEmail = new Email('novy@example.com');
$user->changeEmail($newEmail);
$this->assertTrue($newEmail->equals($user->email()));
}
public function testEmailRemainsUnchangedWhenSameValueProvided(): void
{
$user = User::register($this->userId, 'Jan Novák', $this->email, HashedPassword::fromPlainText('secret123'));
$user->changeEmail(new Email('jan@example.com'));
// Žádná událost by neměla být vydána, email je stále stejný
$this->assertCount(0, $user->releaseDomainEvents());
}
}
Pozn.: V tomto zjednodušeném příkladu metoda activate() nepřijímá token.
Plnou implementaci s VerificationToken naleznete v kapitole
Anti-vzory.
Testování agregátů
Agregáty jsou nejsložitějšími doménovými objekty - ochraňují konzistenci skupiny entit a vydávají doménové události. Testy agregátů ověřují transakční invarianty (pravidla platná pro celý agregát) a správné vydávání doménových událostí jako vedlejší efekt doménových operací.
Příklad: Test pro Order agregát
<?php
declare(strict_types=1);
namespace Tests\OrderManagement\Domain\Model;
use PHPUnit\Framework\TestCase;
use App\OrderManagement\Domain\Model\Order;
use App\OrderManagement\Domain\ValueObject\OrderId;
use App\OrderManagement\Domain\ValueObject\CustomerId;
use App\OrderManagement\Domain\ValueObject\Money;
use App\OrderManagement\Domain\ValueObject\Currency;
use App\OrderManagement\Domain\Event\OrderPlaced;
use App\OrderManagement\Domain\Event\OrderItemAdded;
use App\OrderManagement\Domain\Exception\EmptyOrderException;
use App\OrderManagement\Domain\Exception\OrderAlreadyPlacedException;
final class OrderTest extends TestCase
{
public function testAddsItemToOrder(): void
{
$order = Order::create(OrderId::generate(), CustomerId::generate());
$order->addItem('Kniha o DDD', new Money(49900, Currency::CZK), 2);
$this->assertSame(1, $order->itemCount()); // 1 řádek objednávky
$this->assertEquals(new Money(99800, Currency::CZK), $order->total()); // 49 900 × 2
}
public function testThrowsExceptionWhenPlacingEmptyOrder(): void
{
$order = Order::create(OrderId::generate(), CustomerId::generate());
$this->expectException(EmptyOrderException::class);
$order->place();
}
public function testPlacesOrderSuccessfully(): void
{
$order = Order::create(OrderId::generate(), CustomerId::generate());
$order->addItem('Produkt A', new Money(10000, Currency::CZK), 1);
$order->place();
$this->assertTrue($order->isPlaced());
}
public function testThrowsExceptionWhenPlacingAlreadyPlacedOrder(): void
{
$order = Order::create(OrderId::generate(), CustomerId::generate());
$order->addItem('Produkt A', new Money(10000, Currency::CZK), 1);
$order->place();
$this->expectException(OrderAlreadyPlacedException::class);
$order->place();
}
public function testReleasesOrderPlacedEvent(): void
{
$order = Order::create(OrderId::generate(), CustomerId::generate());
$order->addItem('Produkt A', new Money(10000, Currency::CZK), 1);
$order->place();
$events = $order->releaseDomainEvents();
$this->assertCount(2, $events); // OrderItemAdded + OrderPlaced
$this->assertInstanceOf(OrderItemAdded::class, $events[0]);
$this->assertInstanceOf(OrderPlaced::class, $events[1]);
}
}
Testování doménových událostí
Doménové události jsou hlavním mechanismem DDD pro komunikaci mezi agregáty a bounded contexty. Je nezbytné testovat, že agregát vydá správné události se správnými daty jako reakci na doménové operace. Přímé testování doménových událostí - bez spoléhání na vedlejší efekty event dispatcheru - je nejspolehlivější přístup.
Pattern "Record and Verify Events":
Agregáty sbírají vydané události interně v privátním poli (viz AggregateRoot bázová třída nebo trait).
Metoda releaseDomainEvents() vrátí všechny nashromážděné události a pole vymaže - tento přístup nevyžaduje
žádný event dispatcher ani bus v unit testech. Testovací kód pak jednoduše zavolá doménovou operaci a ověří
obsah vrácených událostí.
Příklad: Trait pro testování doménových událostí
<?php
declare(strict_types=1);
namespace Tests\Shared\Domain;
use App\Shared\Domain\Event\DomainEvent;
/**
* Reusable trait pro ověřování doménových událostí v unit testech.
* Použití: `use DomainEventAssertions;` ve třídě TestCase.
*/
trait DomainEventAssertions
{
/**
* Ověří, že kolekce událostí obsahuje právě jednu událost daného typu.
*
* @param array<DomainEvent> $events
*/
protected function assertSingleEventOfType(string $expectedType, array $events): DomainEvent
{
$matching = array_filter($events, fn(DomainEvent $e) => $e instanceof $expectedType);
$this->assertCount(
1,
$matching,
sprintf('Očekávána právě jedna událost typu %s, nalezeno %d.', $expectedType, count($matching))
);
return array_values($matching)[0];
}
/**
* Ověří, že kolekce událostí neobsahuje žádnou událost daného typu.
*
* @param array<DomainEvent> $events
*/
protected function assertNoEventOfType(string $unexpectedType, array $events): void
{
$matching = array_filter($events, fn(DomainEvent $e) => $e instanceof $unexpectedType);
$this->assertCount(
0,
$matching,
sprintf('Neočekávána žádná událost typu %s, ale nalezena.', $unexpectedType)
);
}
/**
* Ověří přesné pořadí vydaných událostí.
*
* @param array<class-string> $expectedTypes
* @param array<DomainEvent> $events
*/
protected function assertEventSequence(array $expectedTypes, array $events): void
{
$actualTypes = array_map(fn(DomainEvent $e) => $e::class, $events);
$this->assertSame(
$expectedTypes,
$actualTypes,
'Pořadí doménových událostí neodpovídá očekávání.'
);
}
}
// --- Příklad použití traitu v testu ---
namespace Tests\OrderManagement\Domain\Model;
use App\OrderManagement\Domain\Model\Order;
use App\OrderManagement\Domain\ValueObject\OrderId;
use App\OrderManagement\Domain\ValueObject\CustomerId;
use App\OrderManagement\Domain\ValueObject\Money;
use App\OrderManagement\Domain\ValueObject\Currency;
use App\OrderManagement\Domain\Event\OrderPlaced;
final class OrderEventsTest extends \PHPUnit\Framework\TestCase
{
use DomainEventAssertions;
public function testOrderPlacedEventContainsCorrectData(): void
{
$customerId = CustomerId::generate();
$order = Order::create(OrderId::generate(), $customerId);
$order->addItem('Produkt A', new Money(25000, Currency::CZK), 3);
$order->place();
$events = $order->releaseDomainEvents();
$placedEvent = $this->assertSingleEventOfType(OrderPlaced::class, $events);
// Ověření dat události
$this->assertTrue($customerId->equals($placedEvent->customerId()));
$this->assertEquals(new Money(75000, Currency::CZK), $placedEvent->total());
$this->assertNotNull($placedEvent->occurredOn());
}
public function testNoOrderPlacedEventWhenOrderNotPlaced(): void
{
$order = Order::create(OrderId::generate(), CustomerId::generate());
$order->addItem('Produkt B', new Money(10000, Currency::CZK), 1);
$events = $order->releaseDomainEvents();
$this->assertNoEventOfType(OrderPlaced::class, $events);
}
}
Test doubles a InMemory repozitáře
Test doubles jsou náhradní implementace závislostí používané v testech místo reálných objektů. Správná volba typu test double závisí na tom, co testujeme a jaké chování chceme ověřit.
Typy test doubles a jejich použití v DDD:
-
Stub - Vrací předpřipravené odpovědi bez jakékoli logiky. Vhodný, když potřebujeme, aby závislost vrátila konkrétní hodnotu, ale nezajímá nás, zda a kolikrát byla volána. Příklad:
$stub->method('findById')->willReturn($user). -
Mock - Stub s ověřením volání. Ověřuje, že byla zavolána konkrétní metoda s konkrétními argumenty přesně n-krát. Vhodný pro ověření vedlejších efektů (volání repozitáře, odeslání e-mailu). Příklad:
$mock->expects($this->once())->method('save'). - Fake - Plnohodnotná, ale zjednodušená implementace rozhraní (typicky in-memory). Nemá reálnou databázovou závislost, ale chová se stejně jako skutečná implementace. Nejlepší přístup pro DDD repozitáře - umožňuje psát čitelné testy bez konfigurování mocků.
- Spy - Podobný mocku, ale ověření probíhá až po akci (post-assertion style). Méně časté v PHP.
Proč preferovat Fake (InMemory) před Mockem pro repozitáře:
- Testy jsou čitelnější - nepotřebují konfigurace
expects()->method()->with()->willReturn(). - InMemory repozitář lze sdílet mezi command handlerem a query handlerem v jednom testu - ověříme reálný průchod datů.
- Při změně signatury rozhraní IDE/statická analýza okamžitě upozorní, na rozdíl od string-based konfigurace mocků.
- Mocky testují implementační detail (které metody jsou volány), Fake testuje chování (co se stane s daty).
Příklad: InMemoryUserRepository implementace
<?php
declare(strict_types=1);
namespace Tests\UserManagement\Infrastructure\Repository;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\Repository\UserRepository;
/**
* InMemory implementace UserRepository pro unit a integrační testy.
* Simuluje chování Doctrine repozitáře bez potřeby databáze.
*/
final class InMemoryUserRepository implements UserRepository
{
/** @var array<string, User> */
private array $storage = [];
public function save(User $user): void
{
$this->storage[(string) $user->id()] = $user;
}
public function findById(UserId $id): ?User
{
return $this->storage[(string) $id] ?? null;
}
public function findByEmail(Email $email): ?User
{
foreach ($this->storage as $user) {
if ($user->email()->equals($email)) {
return $user;
}
}
return null;
}
public function existsByEmail(Email $email): bool
{
return $this->findByEmail($email) !== null;
}
public function remove(User $user): void
{
unset($this->storage[(string) $user->id()]);
}
/** Pomocná metoda pro assertiony v testech. */
public function count(): int
{
return count($this->storage);
}
/** @return array<User> */
public function all(): array
{
return array_values($this->storage);
}
}
Příklad: Test command handleru s InMemoryRepository
<?php
declare(strict_types=1);
namespace Tests\UserManagement\Application\Command;
use PHPUnit\Framework\TestCase;
use App\UserManagement\Registration\Command\RegisterUser;
use App\UserManagement\Registration\Command\RegisterUserHandler;
use App\UserManagement\Domain\Exception\EmailAlreadyTakenException;
use Tests\UserManagement\Infrastructure\Repository\InMemoryUserRepository;
final class RegisterUserHandlerTest extends TestCase
{
private InMemoryUserRepository $userRepository;
private RegisterUserHandler $handler;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$this->handler = new RegisterUserHandler($this->userRepository);
}
public function testRegistersNewUser(): void
{
$command = new RegisterUser(
name: 'Jan Novák',
email: 'jan@example.com',
password: 'SilneHeslo123!'
);
($this->handler)($command);
$this->assertSame(1, $this->userRepository->count());
$user = $this->userRepository->findByEmail(new \App\UserManagement\Domain\ValueObject\Email('jan@example.com'));
$this->assertNotNull($user);
$this->assertFalse($user->isActive()); // nový uživatel je neaktivní
}
public function testThrowsExceptionWhenEmailAlreadyTaken(): void
{
$command = new RegisterUser(name: 'Jan Novák', email: 'jan@example.com', password: 'Heslo123!');
($this->handler)($command); // první registrace
$this->expectException(EmailAlreadyTakenException::class);
($this->handler)($command); // duplicitní registrace
}
public function testDoesNotPersistUserWhenEmailAlreadyTaken(): void
{
$command = new RegisterUser(name: 'Jan Novák', email: 'jan@example.com', password: 'Heslo123!');
($this->handler)($command);
try {
($this->handler)($command);
} catch (EmailAlreadyTakenException) {
// očekáváno
}
$this->assertSame(1, $this->userRepository->count());
}
}
Varování: Přílišné používání mocků
Nadměrné použití mocků (mockování každé závislosti) vede k tzv. over-specification testů. Takové testy ověřují implementační detaily, nikoli chování, a při každém refaktoringu přestanou procházet, i když se chování systému nezměnilo. Preferujte InMemory Fake implementace pro repozitáře a používejte mocky pouze tam, kde skutečně ověřujete vedlejší efekty (odeslání e-mailu, volání externího API).
Integrační testy s Doctrine
Integrační testy ověřují spolupráci doménového kódu s infrastrukturou - především správnost Doctrine mapování, dotazů repozitářů a transakčního chování. Na rozdíl od unit testů potřebují skutečnou databázi (typicky SQLite in-memory nebo testovací PostgreSQL/MySQL instanci).
KernelTestCase vs WebTestCase:
- KernelTestCase - Bootstrapuje Symfony kernel bez HTTP vrstvy. Vhodný pro testování Doctrine repozitářů, služeb z DI kontejneru a dalších komponent infrastruktury. Rychlejší než WebTestCase.
- WebTestCase - Bootstrapuje kernel i simulovaný HTTP klient. Vhodný pro funkční testy kontrolerů a API endpointů. Pomalejší, ale testuje celý zásobník.
Transakce a rollback po každém testu:
Nejjednodušší způsob, jak zajistit izolaci integračních testů, je zabalit každý test do databázové transakce
a po jeho dokončení provést rollback. Symfony poskytuje DoctrineTestHelper a bundle
dama/doctrine-test-bundle, který toto chování implementuje automaticky pomocí dekorátoru
nad Connection. Bez toho by každý test zanechával data v databázi a testy by se navzájem ovlivňovaly.
Příklad: Integrační test DoctrineUserRepository
<?php
declare(strict_types=1);
namespace Tests\UserManagement\Infrastructure\Repository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\HashedPassword;
use App\UserManagement\Domain\Exception\UserNotFoundException;
use App\UserManagement\Infrastructure\Repository\DoctrineUserRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* Integrační test pro DoctrineUserRepository.
* Vyžaduje běžící databázi (konfigurovanou přes DATABASE_URL v .env.test).
* Transakční rollback zajišťuje dama/doctrine-test-bundle.
*/
final class DoctrineUserRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private DoctrineUserRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = static::getContainer()->get(EntityManagerInterface::class);
$this->repository = static::getContainer()->get(DoctrineUserRepository::class);
}
public function testPersistsAndRetrievesUser(): void
{
$userId = UserId::generate();
$email = new Email('integrace@example.com');
$user = User::register($userId, 'Test Uživatel', $email, HashedPassword::fromPlainText('Heslo123!'));
$this->repository->save($user);
$this->entityManager->clear(); // vyčistíme identity map - nutné pro skutečné čtení z DB
$retrieved = $this->repository->findById($userId);
$this->assertTrue($userId->equals($retrieved->id()));
$this->assertTrue($email->equals($retrieved->email()));
}
public function testThrowsExceptionForNonExistentUser(): void
{
$this->expectException(UserNotFoundException::class);
$this->repository->findById(UserId::generate());
}
public function testFindsByEmailAddress(): void
{
$email = new Email('hledat@example.com');
$user = User::register(UserId::generate(), 'Test Uživatel', $email, HashedPassword::fromPlainText('Heslo123!'));
$this->repository->save($user);
$this->entityManager->clear();
$found = $this->repository->findByEmail($email);
$this->assertNotNull($found);
$this->assertTrue($email->equals($found->email()));
}
public function testExistsByEmail(): void
{
$email = new Email('exists@example.com');
$user = User::register(UserId::generate(), 'Test Uživatel', $email, HashedPassword::fromPlainText('Heslo123!'));
$this->assertFalse($this->repository->existsByEmail($email));
$this->repository->save($user);
$this->assertTrue($this->repository->existsByEmail($email));
}
}
Proč volat $entityManager->clear()?
Doctrine udržuje tzv. Identity Map - interní cache, která vrátí stejnou instanci objektu
pro stejné ID bez dalšího dotazu do databáze. Bez volání clear() by integrační test
mohl úspěšně projít, i kdyby data v databázi vůbec nebyla uložena - Doctrine by je vrátil
z paměti. Voláme tedy clear() mezi zápisem a čtením, aby byl test skutečně integrační.
Funkční testy API a kontrolerů
Funkční testy ověřují chování celé aplikace přes HTTP vrstvu. Testují, že správný request na správnou URL vrátí správnou odpověď - včetně HTTP status kódu, formátu těla odpovědi (JSON), hlaviček a chování při chybových stavech. V DDD kontextu ověřují integraci prezentační vrstvy s aplikační vrstvou.
WebTestCase v Symfony:
Symfony\Bundle\FrameworkBundle\Test\WebTestCase poskytuje metodu createClient(),
která vrátí HTTP klienta simulujícího prohlížeč. Klient lze použít k odeslání GET, POST, PUT, PATCH a DELETE
requestů. Response obsahuje status kód, tělo a hlavičky, které lze snadno assertovat.
Příklad: Funkční test registračního endpointu
<?php
declare(strict_types=1);
namespace Tests\UserManagement\Registration\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Funkční testy registračního REST API endpointu.
* Testují HTTP vrstvu + celý zásobník až po databázi.
*/
final class RegistrationControllerTest extends WebTestCase
{
public function testRegistersUserSuccessfully(): void
{
$client = static::createClient();
$client->request(
method: 'POST',
uri: '/api/users/register',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([
'email' => 'novy@example.com',
'password' => 'SilneHeslo123!',
])
);
$this->assertResponseStatusCodeSame(201);
$this->assertResponseHeaderSame('Content-Type', 'application/json');
$responseData = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('userId', $responseData);
$this->assertSame('novy@example.com', $responseData['email']);
}
public function testReturns422ForInvalidEmail(): void
{
$client = static::createClient();
$client->request(
method: 'POST',
uri: '/api/users/register',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([
'email' => 'not-valid-email',
'password' => 'Heslo123!',
])
);
$this->assertResponseStatusCodeSame(422);
$responseData = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('errors', $responseData);
$this->assertStringContainsString('email', strtolower($responseData['errors'][0]['field']));
}
public function testReturns409WhenEmailAlreadyRegistered(): void
{
$client = static::createClient();
$payload = json_encode(['email' => 'existujici@example.com', 'password' => 'Heslo123!']);
$client->request('POST', '/api/users/register',
server: ['CONTENT_TYPE' => 'application/json'],
content: $payload
);
$this->assertResponseStatusCodeSame(201);
// druhý pokus se stejným emailem
$client->request('POST', '/api/users/register',
server: ['CONTENT_TYPE' => 'application/json'],
content: $payload
);
$this->assertResponseStatusCodeSame(409);
}
public function testReturns400ForMissingRequiredFields(): void
{
$client = static::createClient();
$client->request(
method: 'POST',
uri: '/api/users/register',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode([])
);
$this->assertResponseStatusCodeSame(400);
}
}
Rozsah funkčních testů
Funkční testy jsou nejpomalejší a nejkřehčí. Testujte pouze hlavní happy path a nejdůležitější chybové scénáře. Vše ostatní (edge cases, validace, doménová pravidla) pokryjte rychlými unit testy doménové vrstvy. Příliš mnoho funkčních testů prodlužuje dobu CI/CD pipeline a snižuje motivaci vývojářů ke spouštění testů lokálně.
Architektonické testy
Architektonické testy automaticky ověřují, že kód dodržuje definovaná architektonická pravidla - především pravidla závislostí mezi vrstvami. V DDD je klíčovým pravidlem, že doménová vrstva nesmí záviset na infrastrukturní ani aplikační vrstvě. Manuální code review nestačí; architektonické testy toto pravidlo vynucují v CI/CD pipeline a zabrání regresi.
Deptrac
Deptrac je nástroj od QOSSMIC (dříve sensiolabs-de) pro statickou analýzu závislostí v PHP projektech. Definujete vrstvy (layers) a povolená pravidla závislostí (ruleset). Deptrac analyzuje skutečné závislosti v kódu a nahlásí porušení. Typicky se spouští v CI jako součást statické analýzy.
Příklad: deptrac.yaml konfigurace pro DDD projekt
deptrac:
paths:
- ./src
layers:
- name: Domain
collectors:
- type: directory
value: src/.*/Domain/.*
- name: Application
collectors:
- type: directory
value: src/.*/Application/.*
- name: Infrastructure
collectors:
- type: directory
value: src/.*/Infrastructure/.*
- name: Presentation
collectors:
- type: directory
value: src/.*/Controller/.*
- name: Shared
collectors:
- type: directory
value: src/Shared/.*
ruleset:
Domain:
# Doménová vrstva nesmí záviset na ničem jiném než na Shared
- Shared
Application:
# Aplikační vrstva závisí na doméně a sdílených komponentách
- Domain
- Shared
Infrastructure:
# Infrastruktura implementuje doménová rozhraní - závisí na doméně
- Domain
- Application
- Shared
Presentation:
# Kontrolery závisí na aplikační vrstvě (Commands, Queries)
- Application
- Shared
Shared:
# Sdílené komponenty nezávisí na ničem projektovém
[]
skip_violations:
# Dočasné výjimky - měly by být minimalizovány
# UserManagement\Domain\Model\User:
# - Symfony\Component\Security\Core\User\UserInterface # Symfony interface v doméně - antipattern
Příklad: Spuštění Deptrac v CI
# Instalace (dev závislost)
composer require --dev qossmic/deptrac-shim
# Spuštění analýzy
./vendor/bin/deptrac analyze --config-file=deptrac.yaml
# Výstup v případě porušení:
# [ERROR] Found 1 Violation
# UserManagement\Domain\Model\User must not depend on
# Doctrine\ORM\Mapping\Column (Infrastructure layer)
PHP-Arkitect jako alternativa
PHP-Arkitect (phparkitect/phparkitect) je alternativní nástroj pro architektonické testy napsaný v PHP. Na rozdíl od Deptrac s YAML konfigurací používá PHP API pro definici pravidel, což umožňuje typově bezpečnou konfiguraci s podporou IDE. Pravidla jsou definována jako PHPUnit test, takže výsledky se integrují přímo do testovací sady.
Příklad: PHP-Arkitect pravidla
<?php
// phparkitect.php
use Arkitect\ClassSet;
use Arkitect\CLI\Config;
use Arkitect\Expression\ForClasses\HaveNameMatching;
use Arkitect\Expression\ForClasses\NotDependsOnTheseNamespaces;
use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
use Arkitect\Rules\Rule;
return static function (Config $config): void {
$srcSet = ClassSet::fromDir(__DIR__ . '/src');
$config->add(
$srcSet,
// Doménová vrstva nesmí záviset na Symfony ani Doctrine
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces('App\UserManagement\Domain'))
->should(new NotDependsOnTheseNamespaces(
'Symfony',
'Doctrine',
))
->because('Doménová vrstva musí být nezávislá na frameworku a infrastruktuře.'),
// Všechny třídy v Command namespace musí mít suffix Command nebo Handler
Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces('App\UserManagement\Application\Command'))
->should(new HaveNameMatching('*Command|*Handler'))
->because('Command namespace smí obsahovat pouze Command a Handler třídy (konvence projektu).'),
);
};
Code coverage a best practices
Code coverage (pokrytí kódem) je metrika udávající, jaké procento kódu je spouštěno při běhu testů. Vysoké pokrytí samo o sobě nezaručuje kvalitu testů - lze dosáhnout 100% pokrytí s testy, které neověřují žádné chování. Přesto je pokrytí užitečným indikátorem nepokrytých oblastí.
Doporučené pokrytí pro DDD vrstvy:
- Doménová vrstva (Domain) - 90–100 %. Tato vrstva obsahuje veškerou doménovou logiku. Každý invariant, každá validace a každé doménové pravidlo musí být pokryto testy.
- Aplikační vrstva (Application) - 80–90 %. Command a query handlery by měly být pokryty unit testy s InMemory repozitáři.
- Infrastrukturní vrstva (Infrastructure) - 60–80 %. Repozitáře pokryjte integračními testy. Generovaný kód (Doctrine mappings) nemusí být testován.
- Prezentační vrstva (Presentation) - 50–70 %. Kontrolery pokryjte funkčními testy pro hlavní scénáře.
Naming conventions pro testy v DDD:
- Testovací třída odpovídá testované třídě:
Email→EmailTest,RegisterUserHandler→RegisterUserHandlerTest. - Testovací metody popisují chování anglicky nebo česky:
testThrowsExceptionForInvalidEmail(),testRegistersNewUser(). - Struktura testovacích souborů zrcadlí strukturu produkčního kódu:
src/UserManagement/Domain/→tests/UserManagement/Domain/. - Suffix
Testpro PHPUnit testovací třídy je povinný (PHPUnit třídu bez suffixu nespustí).
Arrange-Act-Assert (AAA) pattern:
Každý test by měl být strukturován do tří jasně oddělených fází:
- Arrange (připrav) - Nastav počáteční stav: vytvoř objekty, nakonfiguruj závislosti, nastav data.
- Act (proveď) - Proveď jednu testovanou akci: zavolej metodu, odešli command.
- Assert (ověř) - Ověř výsledek: assertuj výstup, zkontroluj stav objektu, ověř vydané události.
Každý test by měl ověřovat právě jednu věc (jeden logický assertion). Více assertů v jednom testu je přijatelné, pokud všechny společně ověřují jeden konzistentní scénář.
Nejčastější chyby při testování DDD
-
Testování getterů místo chování - Špatně:
$this->assertSame('jan@example.com', $user->getEmail())po přímém nastavení fieldu. Správně: zavolat doménovou operaci a ověřit výsledný stav. - Přímý přístup k privátním fieldům přes reflexi - Porušuje zapouzdření. Pokud potřebujete přistupovat k privátnímu stavu v testu, je to příznak špatného návrhu API třídy.
-
Bootstrapování celého Symfony kernelu v unit testech - Unit testy doménové vrstvy nesmí volat
self::bootKernel(). To je výsadou integračních testů. Zbytečně zpomaluje sadu testů. - Sdílený stav mezi testy - Každý test musí být zcela nezávislý. Sdílené statické proměnné nebo globální stav způsobují nestabilní (flaky) testy, jejichž výsledek závisí na pořadí spouštění.
- Mockování value objects - Value objekty jsou jednoduché datové třídy bez závislostí. Není důvod je mockovat - vždy vytvořte skutečnou instanci.
-
Ignorování doménových výjimek v testech - Každá doménová výjimka (
InvalidEmailException,OrderAlreadyPlacedExceptionapod.) musí mít test ověřující, že je skutečně vyhozena za správných podmínek. - Chybějící test pro releaseDomainEvents() po operaci - Pokud agregát vydává doménové události, každá veřejná operace, která by měla událost vydávat, musí mít test ověřující typ, počet a obsah vydaných událostí.
Příklad: Správná konfigurace PHPUnit pro DDD projekt (phpunit.xml)
# Spuštění unit testů doménové vrstvy (rychlé, bez kernelu)
./vendor/bin/phpunit --testsuite=Domain
# Spuštění integračních testů (vyžaduje databázi)
./vendor/bin/phpunit --testsuite=Integration
# Spuštění funkčních testů
./vendor/bin/phpunit --testsuite=Functional
# Generování HTML coverage reportu (vyžaduje Xdebug nebo PCOV)
XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html=coverage/
# Spuštění architektonických testů s Deptrac
./vendor/bin/deptrac analyze
Testovací pyramida v DDD staví na tom, že doménová vrstva je čistý PHP bez závislostí na frameworku – proto je unit testování rychlé a přímočaré. Integrační a funkční testy doplňují pokrytí tam, kde vstupuje infrastruktura.