Kapitola 17 · Praxe · Testování DDD kódu v Symfony

Testování DDD kódu v Symfony

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.

Autor M. Katuščák
Doba čtení ≈ 31 min
Náročnost pokročilá
Publikováno · Aktualizováno ·
Obsah kapitoly

17.01 Filozofie testování v DDD#

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-A Testovací 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.

17.02 Unit testy doménové vrstvy#

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í.

17.03 Testování doménových událostí#

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.

17.04 Test doubles a InMemory repozitáře#

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í.

17.05 Integrační testy s Doctrine#

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í:

yaml config/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:6        autowire: true7        autoconfigure: true8 9    # Jen pro testy. V produkci zůstává služba privátní a chodí se na ni10    # přes injektované rozhraní UserRepository.11    App\UserManagement\Infrastructure\Repository\DoctrineUserRepository:12        public: true

17.06 Funkční testy API a kontrolerů#

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í.

17.07 Testování asynchronních toků#

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.

17.08 Architektonické testy#

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].

17.09 Code coverage a doporučené postupy#

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:

php config/bundles.php
1<?php2 3return [4    // …5    DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],6];

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.