Testování DDD kódu v Symfony

Ke kapitole patří živá ukázka kódu: Zobrazit na GitHubu →

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());
    }
}

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));
    }
}

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);
    }
}

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ě: EmailEmailTest, RegisterUserHandlerRegisterUserHandlerTest.
  • 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 Test pro 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í:

  1. Arrange (připrav) - Nastav počáteční stav: vytvoř objekty, nakonfiguruj závislosti, nastav data.
  2. Act (proveď) - Proveď jednu testovanou akci: zavolej metodu, odešli command.
  3. 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ář.

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.