Testování DDD kódu v Symfony: unit testy doménové vrstvy, InMemory repozitáře, integrační testy s Doctrine, funkční testy API, testy doménových událostí a architektonické testy.
Doménová vrstva v DDD nezávisí na frameworku ani na databázi, takže ji lze testovat přímo z PHPUnitu bez
bootstrappingu Symfony kernelu. To je hlavní praktický rozdíl proti tradičním vrstveným architekturám,
kde unit testy potřebují kontejner a každý z nich zaplatí bootstrap. Bez kernelu je jeden test řádově
rychlejší a celou doménovou sadu se vyplatí spouštět po každé změně, ne jen v CI. Stavební kameny doménové
vrstvy popisuje kapitola Základní koncepty DDD: entity, hodnotové objekty,
agregáty a doménové události.
FIG. 17.1-ATestovací pyramida pro DDD aplikaci – poměr a obsah jednotlivých vrstev
Testovací pyramida pro DDD
Testovací pyramida (koncept popularizovaný Mikem Cohnem v knize Succeeding with Agile, 2009
[1])
rozděluje testovací sadu do tří vrstev. Liší se rychlostí, mírou izolace a tím, kolik kódu jeden test pokryje:
Poměr vrstev není konstanta
Pyramida říká, že pomalých testů má být méně než rychlých. V jakém poměru, neříká. Fowler k ní proto
staví protipól ice-cream cone: sadu, ve které převažují pomalé testy přes UI. Rozpadá se ze tří
důvodů: build trvá dlouho, drobná změna systému rozbije mnoho testů naráz a headless běh v pipeline
je problematický [2]. Ham Vocke tvar pyramidy
drží, na názvech Cohnových vrstev mu ale nezáleží. Trvá na dvou věcech: testy mají mít různou
granularitu a čím výš v pyramidě, tím méně jich má být. Integrační testy pojímá úzce – každý ověřuje
jediný integrační bod a zbytek nahradí test doubles [3].
Pro DDD projekt z toho plyne vodítko: těžiště sady kopíruje tloušťku doménové logiky. Bounded context
s bohatým modelem unese širokou základnu unit testů. Kontext, který jen překládá HTTP požadavky
na dotazy do databáze, takovou základnu nemá a jeho záruku nesou integrační testy. Kent C. Dodds na téže
úvaze staví alternativu Testing Trophy s největší investicí do integrační vrstvy
[4]. Formulovaná je
pro JavaScript, ale otázka, kolik logiky se vlastně testuje v izolaci, platí i v PHP.
Unit testy doménové vrstvy pokrývají největší podíl kódu a běží v řádu milisekund. Stačí jim
PHPUnit a samotné doménové třídy; kernel, databázi ani fixtures nepotřebují.
Ukázky v této kapitole cílí na PHPUnit 13 a PHP 8.4. Na verzi tentokrát záleží víc než obvykle. PHPUnit 12
odstranil podporu metadat v doc-komentářích, takže @dataProvider, @covers, @test ani @group už
nejsou pokyny pro framework – zůstávají obyčejným komentářem. Metadata se zapisují atributy z namespace
PHPUnit\Framework\Attributes: #[DataProvider], #[TestWith], #[CoversClass], #[Group], #[Test][5]. Test s @dataProvider přenesený z návodu pro
PHPUnit 9 skončí chybou o chybějícím argumentu metody. Záludnější je @test: metoda bez prefixu test
se tiše přestane spouštět.
Testování Value Objects
Test value objektu ověřuje tři věci: neplatný vstup vyhodí odpovídající výjimku, dvě instance
se stejnou hodnotou jsou si rovny přes equals() a objekt zůstává neměnný – jiná hodnota znamená
novou instanci.
Pojmenované klíče v data provideru se objeví ve výstupu PHPUnitu, takže spadlý případ je vidět
bez čtení testu: EmailTest::testThrowsExceptionForInvalidInput@chybí doména with data ('jan@').
Pro jeden nebo dva vstupy stačí atribut #[TestWith([''])] přímo nad metodou; samostatný
provider se vyplatí od tří případů výš.
Testování entit
Test entity ověřuje, co entita dělá, ne jak vypadají její vlastnosti. Volá se veřejná metoda, ověřuje se
výsledný stav přes další veřejné metody a u zakázaných operací se očekává konkrétní doménová výjimka.
Přístup k privátním vlastnostem přes reflexi je signál, že test sleduje implementaci místo chování.
Testování agregátů
Agregát chrání konzistenci skupiny entit a vydává doménové události. Test agregátu proto ověřuje
dvě věci: transakční invarianty (pravidla platná pro celý agregát po každé operaci) a to, že
operace vydala očekávané události ve správném pořadí.
Doménovými událostmi agregát mluví se zbytkem systému. Test proto ověřuje přímo to, co
agregát po operaci vydá: typ události, její data a pořadí během jedné transakce. Spoléhat se na
vedlejší efekt event dispatcheru je křehké a do unit testu to přidává zbytečnou závislost. Pro systémy,
které používají události jako zdroj pravdy, popisuje doplňující strategie testování auditovatelnosti
a rebuildu projekcí kapitola Event Sourcing.
Given-when-then nad event streamem
U event-sourced agregátu je stav odvozený z historie, takže se test píše jinak. Vstupem není
konstruktor, ale stream událostí: given je historie, when volání doménové metody a then
události nahrané právě touto operací. Bázovou třídu EventSourcedAggregate s metodami recordEvent()
a reconstituteFromEvents() definuje kapitola
Event Sourcing.
Rekonstrukce z historie stav aplikuje, ale nic nenahrává – releaseEvents() po ní vrátí prázdné pole.
Aserce se tedy vztahuje výhradně k tomu, co přidala testovaná operace.
Druhý test hlídá pravidlo, které stavově ukládaný agregát nemá jak porušit: neúspěšná operace nesmí
nechat v bufferu událost. Kdyby confirm() nahrál OrderConfirmed ještě před kontrolou invariantu,
repozitář by událost při nejbližším uložení zapsal do streamu a historie by tvrdila něco, co se nestalo.
Test double je obecný název pro náhradu reálné závislosti v testu. Taxonomii pěti typů – dummy, stub,
spy, mock a fake – zavedl Gerard Meszaros v knize xUnit Test Patterns: Refactoring Test Code
(Addison-Wesley, 2007) [6]; do širšího povědomí ji dostal Fowler článkem
Mocks Aren't Stubs[7]. S PHPUnit se pracuje
hlavně se čtyřmi z nich (stub, mock, fake, spy). Každý vede k jinému stylu testu a k jiné odolnosti
vůči refaktoringu.
Za tímto doporučením stojí Fowlerovo rozlišení dvou způsobů ověření. State verification kontroluje
stav systému po akci, behavior verification kontroluje, že proběhla očekávaná volání spolupracovníků.
Z pěti typů doubles trvá na behavior verification jedině mock – ostatní obvykle vystačí s kontrolou stavu.
Fake repozitář patří ke stylu, kterému Fowler říká
classical TDD: reálné objekty všude, kde to jde, a doubles jen na hranicích systému.
InMemory repozitář zde unikátnost e-mailu jen aproximuje: kontroluje ji v paměti a rovnou
hází DuplicateEmailException. Finální záruku dává unikátní constraint v databázi –
kanonický handler překládá jeho porušení na tutéž výjimku
(viz race condition v naivní variantě).
Test ověřuje chování handleru, ne řešení souběhu, proto EntityManager stačí jako stub.
Testovací data: builder místo opakovaného konstruktoru
Testy v předchozích ukázkách opakují v každé metodě totéž volání
User::register($id, new UserName('Jan Novák'), $email, HashedPassword::fromPlainText('SilneHeslo123')). Pro test
je z něj podstatný jeden argument, zbytek je tam proto, že ho vyžaduje konstruktor. Až přibude pátý
parametr, mění se všechny testy naráz.
Řešením je Test Data Builder od Steva Freemana a Nata Pryce z knihy Growing Object-Oriented
Software, Guided by Tests (Addison-Wesley, 2009)
[8]. Builder má pole pro každý parametr
konstruktoru, inicializované bezpečnou hodnotou, řetězitelné metody pro přepsání těchto polí
a metodu build(), která z nich složí cílový objekt. Volitelně přidá statickou tovární metodu,
aby bylo v testu na první pohled zřejmé, co se staví; autoři ji ukazují právě na OrderBuilder
s metodou anOrder(). Přínos shrnují do tří bodů: builder obalí syntaktický šum kolem vytváření
objektů, výchozí případ udrží jednoduchý a zvláštní jen o málo složitější a odstíní testy od změny
struktury objektu – po přidání parametru se mění jediné místo.
Builder je mutabilní, takže každý test si volá anOrder() znovu; sdílená instance mezi testy je
zdrojem těžko dohledatelných závislostí na pořadí.
Starší příbuzný vzor je Object Mother (Peter Schuh, Stephanie Punke, ObjectMother: Easing Test
Object Creation In XP, XP Universe, 2001): pojmenovaná tovární metoda vrátí hotovou fixturu,
například OrderMother::confirmedOrder(). Čte se dobře, dokud variant nepřibývá. Fowler mu vyčítá
dvě věci [9]: mnoho testů se váže na přesná data
jedné fixtury a po změně tříd je potřeba migrovat všechny mothers najednou. Builder tento tlak
rozpouští tím, že variantu skládá volající.
Fixtury, které mají skončit v databázi, řeší v Symfony zenstruck/foundry[10]. Zápis OrderFactory::new()->confirmed()->create()
je týž vzor obalený integrací na Doctrine. Doménové unit testy persistenci nepotřebují a vystačí
si s builderem; factory patří do integračních a funkčních testů z následujících dvou sekcí.
Integrační testy odpovídají na otázku, kterou unit testy pokrýt nemohou: zda Doctrine mapování, dotazy
repozitářů a transakce skutečně dělají to, co jejich rozhraní slibuje. Výchozí volbou je stejný
databázový engine jako v produkci, spuštěný v kontejneru. SQLite in-memory je rychlejší, ale liší se
typovým systémem, chováním unikátních constraintů i transakční sémantikou, tedy přesně v tom,
co má integrační test ověřit. Zůstává vědomou zkratkou pro rychlou zpětnou vazbu při lokálním vývoji,
ne konfigurací, na které stojí CI.
Doctrine i InMemory implementace přitom plní tutéž smlouvu rozhraní: findById(UserId $id): ?User
vrací při nenalezení null, stejně jako InMemory varianta ze sekce o test doubles.
Kniha drží obě konvence, každou pro jiný případ. Command handler, který agregát podle identity
nutně potřebuje, volá get() a při nenalezení dostane doménovou výjimku
(OrderNotFoundException::withId() z Domain\Exception). Návrat null zůstává metodám find…()
tam, kde absence chybou není, například findByEmail() při registraci.
Bundle potřebuje dvě registrace: v config/bundles.php (viz Konfigurace a spouštění)
a v konfiguraci PHPUnitu jako bootstrap extension:
Testovací kontejner (static::getContainer()) vydá i privátní služby, ale jen ty, které kompilace
neodstranila. Služba, kterou žádná jiná přímo nepoužívá, v něm chybí a get() na ní selže. Test, který
sahá po konkrétní implementaci, si ji proto v test prostředí zveřejní:
yamlconfig/services_test.yaml
1services:2# _defaults se mezi soubory nedědí. Bez něj tahle definice nahradí tu3# ze services.yaml celou, služba se postaví bez argumentů a testy4# spadnou na ArgumentCountError místo na ServiceNotFoundException.5_defaults:6autowire:true7autoconfigure:true89# Jen pro testy. V produkci zůstává služba privátní a chodí se na ni10# přes injektované rozhraní UserRepository.11App\UserManagement\Infrastructure\Repository\DoctrineUserRepository:12public:true
Funkční test prochází celý zásobník: request přijde do kontroleru, projde aplikační vrstvou, dotkne se
databáze a vrátí odpověď. Ověřuje se HTTP status kód, tělo (typicky JSON), hlavičky a chování při
chybových vstupech. V DDD je to jediná vrstva testů, která ověří, že prezentační a aplikační vrstva
spolu opravdu fungují.
Asynchronní zpracování přes Messenger rozděluje tok na dvě poloviny: odeslání zprávy a její zpracování
workerem. Test musí pokrýt obě, každou zvlášť. Architekturu busů popisuje kapitola
CQRS, transakční předávání zpráv kapitola Outbox Pattern.
In-memory transport
V testovacím prostředí nahradí reálný broker transport in-memory://. Zprávy nikam neodcházejí,
zůstávají v paměti procesu a test si je vyzvedne přímo z kontejneru. Stačí přepsat DSN
transportu pro prostředí test:
Ukázky v této sekci počítají s posluchačem UserRegistered, který na command.bus odešle příkaz
SendWelcomeEmail. Příkaz směruje řádek App\UserManagement\Application\Message\SendWelcomeEmail: async_commands
doplněný do framework.messenger.routing z kapitoly CQRS.
Transport vystavuje getSent(), getAcknowledged() a reset(). Úklid ale obstará framework:
v testech dědících z KernelTestCase nebo WebTestCase se všechny in-memory transporty po každém
testu resetují samy [13]. Volba serialize
(v DSN in-memory://?serialize=true) navíc zprávy protáhne serializační vrstvou, takže se otestuje i to, co se v produkci posílá po drátě.
Funkční test pak ověří, že endpoint zprávu skutečně odeslal, aniž by čekal na workera:
zenstruck/messenger-test
Holé aserce nad transportem fungují, ale v každém testu se opakují. Balíček
zenstruck/messenger-test je zabalí do čitelného API: trait InteractsWithMessenger zpřístupní
frontu transportu a metoda process() zpracuje zařazené zprávy přímo v testu, bez spouštění workera.
Balíček přináší vlastní transport a trvá na svém DSN. Nad in-memory:// trait nefunguje, konfigurace
z předchozí ukázky se proto pro tento styl testů přepíše na test://[14]:
Obě konfigurace jsou alternativy, ne vrstvy: transport má v daném prostředí jedno DSN. Sada, která
zůstane u in-memory://, si vystačí s getSent(); sada postavená na zenstruck jde na test://
a aserce píše přes trait.
Test idempotence handleru
Asynchronní transport doručuje zprávy v režimu at-least-once: po pádu workera dorazí tatáž
zpráva podruhé. Handler proto musí být idempotentní: dvojí zpracování smí vyvolat jen jeden efekt.
Deduplikační mechanismus popisuje Idempotent Inbox, kde se záznam ukládá
pod dvojicí eventId (UUID v7) a consumer. Test je krátký: zavolá handler dvakrát se stejnou
zprávou a spočítá efekty.
Test outboxu
Outbox dává dvě garance a každá potřebuje vlastní integrační test. První: po flush() leží
událost v outbox tabulce, zapsaná ve stejné transakci jako agregát. Druhá: relay ji publikuje
do transportu a označí jako odeslanou (sent). Obě varianty relay procesu rozebírá
kapitola o Outbox Pattern.
Druhý test záměrně končí dvojicí asercí: zpráva odešla a fronta pending záznamů je prázdná.
Kdyby relay publikoval, ale neoznačil záznam jako odeslaný, příští běh by událost poslal znovu.
Pravidlo, že doménová vrstva nesmí záviset na infrastruktuře ani na aplikační vrstvě, drží jen do prvního
uspěchaného code review, ve kterém někdo do entity natáhne repozitář z Infrastructure. Architektonické
testy tomu brání technicky: pravidla závislostí se popíšou deklarativně a porušení padne v CI jako
spadlý test, ne až v review.
Deptrac
Deptrac analyzuje závislosti staticky. Konfigurace popisuje vrstvy (layers) a povolená pravidla
mezi nimi (ruleset), nástroj projde kód a vypíše porušení. V CI běží jako samostatný krok vedle
statické analýzy.
Na historii balíčku záleží, protože podle ní se hledá dokumentace. Projekt vznikl
v sensiolabs-de, pokračoval pod hlavičkou QOSSMIC a dnes má vlastní organizaci. Balíček
qossmic/deptrac je na Packagistu označený jako abandoned a nahradil ho
deptrac/deptrac[15]. Řada 4.x drží konfiguraci ve výchozím
souboru deptrac.php s typovaným API; YAML zůstává podporovaný, ale vendor/bin/deptrac init
generuje PHP.
V novém projektu drží pravidlo od prvního commitu. Do existující kódové báze se dostane jen přes
baseline: analyse --formatter=baseline zapíše současná porušení do deptrac.baseline.yaml,
konfigurace ho načte a build zůstane zelený. Nová porušení pak selžou, stará se odbourávají postupně.
Bez baseline skončí první spuštění stovkou chyb a pravidlo se z CI zase vyhodí.
phparkitect jako alternativa
Pravidla závislostí umí vynutit i phparkitect (phparkitect/phparkitect)
[16]. Pracuje na jiné úrovni než Deptrac: místo vrstev
popisuje pravidla nad jednotlivými třídami, jejich namespace a názvy a zapisuje je do souboru
phparkitect.php. Nástroj má vlastní CLI, nespouští se přes PHPUnit a v CI běží jako samostatný
krok vedle testovací sady. Instalaci a přehled pravidel uvádí kapitola
Doplňující taktické vzory; plnou konfiguraci pro modulární monolit
ukazuje kapitola DDD a microservices.
Zde stačí zapojení do pipeline:
Volba mezi oběma nástroji je věcí preferencí týmu. Deptrac popisuje vrstvy a hodí se pro plošná
pravidla mezi nimi; phparkitect dovolí jemnější pravidla nad jednotlivými třídami (suffixy názvů,
konkrétní namespace). Oba selžou v CI stejně – jako spadlý build. Projekty, které používají Pest,
mají třetí možnost: vestavěné arch() API zapisuje totéž pravidlo jako
arch('domain')->expect('App\Domain')->not->toUse('App\Infrastructure') a běží rovnou uvnitř
testovací sady [17].
Code coverage měří, jaké procento řádků kódu se při běhu testů provede. Sama metrika nic neříká
o kvalitě testů: 100% pokrytí lze dosáhnout testy, které jen volají metody bez asercí. Užitečná
je ale opačně: kde je pokrytí nízké, leží kód, který nikdo netestuje, a tam se vyplatí podívat.
Mutation testing
Coverage říká, které řádky test spustil. Neříká, jestli by si všiml, kdyby se změnily. Na tuto otázku
odpovídá mutation testing: nástroj zanese do kódu drobné změny (obrátí podmínku, posune mez, smaže
volání) a spustí testy. Mutace, kterou nikdo nezachytil, ukazuje na řádek, který má pokrytí, ale
nemá assert. Podíl zachycených mutací je mutation score.
V PHP mutace generuje Infection [18]. Nejvíc se vyplatí nad doménovou
vrstvou: logika je tam hustá, testy rychlé a mutace mají co porušit. Nad infrastrukturou běh trvá
dlouho a nálezy bývají technické.
Konfigurace a spouštění
Čtyři testovací sady z předchozích sekcí definuje phpunit.dist.xml. PHPUnit hledá konfiguraci
v pořadí phpunit.xml, phpunit.dist.xml, phpunit.xml.dist. Starší název tedy dál funguje,
jen má nejnižší prioritu. Ve stejném souboru se registruje i extension pro transakční izolaci
z sekce o integračních testech:
Samotný zápis v <extensions> nestačí. Recept dama/doctrine-test-bundle leží v contrib
repozitáři, takže ho Flex bez potvrzení (nebo bez extra.symfony.allow-contrib) nespustí
a bundle nezaregistruje. Rozšíření pak sice nastartuje, ale nic nevrací zpět. Pozná se
to podle toho, že testy projdou jednou a při druhém běhu spadnou na datech z toho prvního.
Bundle proto patří do config/bundles.php:
Testovací pyramida v DDD funguje díky tomu, že doménová vrstva je čisté PHP bez závislostí na frameworku.
Doménová sada se proto dá spustit po každé změně, ne až v CI. Integrační a funkční testy doplňují
pokrytí tam, kde vstupuje infrastruktura, a architektonické testy hlídají, aby izolace domény nezmizela
při dalším refaktoringu.
Časté otázky
Jak testovat agregát – unit test s mock repozitářem, nebo integrační test?
Agregát se testuje primárně unit testem, protože je to čisté PHP bez závislostí na frameworku nebo databázi. Test vytvoří agregát, volá jeho metody a ověřuje výsledný stav i vyvolané doménové události. Mock repozitáře přitom není potřeba, protože samotný agregát repozitář nevolá. Integrační test doplňuje pokrytí až na úrovni, kde vstupuje persistence, tedy při ukládání a načítání agregátu. Podrobný rozbor v sekci Unit testy doménové vrstvy.
K čemu slouží InMemory repozitář a kdy ho preferovat před mockem?
InMemory repozitář je plnohodnotná implementace rozhraní repozitáře, která drží agregáty v poli v paměti. Oproti mocku simuluje reálné chování (najít, uložit, počítat), takže testy aplikačních služeb procházejí celý use case věrohodněji. Mock se hodí tam, kde jde o konkrétní interakci: kolikrát se metoda volala a s jakými argumenty. InMemory repozitář naopak slouží pro ověření výsledku, ne volání. Rozbor variant v sekci Test doubles a InMemory repozitáře.
Jak ověřit, že agregát publikuje správné doménové události?
Po vykonání metody se z agregátu vyčte seznam zaznamenaných událostí (typicky přes releaseEvents()) a testem se ověří jejich typ, pořadí i obsah. Kontroluje se, že agregát vyvolal přesně ty události, které má, a nevyvolal žádné navíc. Pro funkční test lze stejné události zachytávat přes Messenger event bus a ověřit reakce dalších částí systému. Praktický příklad v sekci Testování doménových událostí.
Mají se testovat privátní invarianty agregátu, nebo jen veřejné rozhraní?
Testuje se pouze veřejné rozhraní, tedy chování agregátu přes metody, které se reálně volají z aplikační vrstvy. Privátní invarianty jsou detailem implementace. Jejich přímé testování sváže test s konkrétní strukturou kódu a brání refaktoringu. Dobře navržený test ověřuje, že po sérii veřejných volání je agregát ve validním stavu, vyvolal očekávané události a při porušení pravidla vyhodil konkrétní doménovou výjimku. Detailní rozbor v sekci Unit testy doménové vrstvy.
Co jsou architektonické testy a co kontrolují?
Architektonické testy automaticky ověřují, že kód dodržuje zvolená pravidla struktury: například že doménová vrstva nezávisí na infrastruktuře, že agregáty nevolají repozitáře přímo, nebo že kontrolery nekomunikují s infrastrukturou. V PHP se k tomu běžně používá Deptrac (balíček deptrac/deptrac), který pravidla popisuje deklarativně v souboru deptrac.php a spouští se v CI jako samostatný krok vedle testovací sady. Porušení pravidla se projeví jako spadlý build, nikoli až při code review. Rozbor nástrojů a pravidel v sekci Architektonické testy.