Stratégies de test
et architecture héxagonale

Qui suis-je?

pingoo
Damien Carcel
Software Engineer @ Akeneo

Cas d'usage

Endpoint d'API permettant de créer un utilisateur

    POST /users
    {
        "email": "john.doe@email.com",
        "firstname": "John",
        "lastname": "Doe"
    }
          

Contrôleur


#[AsController]
#[Route(path: '/users', name: 'create_user', methods: [Request::METHOD_POST])]
final readonly class CreateUserController
{
    public function __construct(private CreateUser $createUser)
    {
    }

    public function __invoke(
        #[MapRequestPayload] CreateUserRequest $createUserRequest,
    ): JsonResponse {
        ($this->createUser)(
             $createUserRequest->email,
             $createUserRequest->firstname,
             $createUserRequest->lastname,
        );

        return new JsonResponse(status: Response::HTTP_CREATED);
    }
}
          

Service métier


final readonly class CreateUser
{
    public function __construct(private UserRepository $userRepository)
    {
    }

    public function __invoke(string $email, string $firstname, string $lastname): void
    {
        $user = new User($email, $firstname, $lastname);
        $this->userRepository->save($user);
    }
}
          

Test fonctionnel


final class CreateUserTest extends KernelTestCase
{
    private CreateUser $createUser;
    private UserRepository $userRepository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->createUser = self::getService(CreateUser::class);
        $this->userRepository = self::getService(UserRepository::class);
    }

    public function testItCreatesAUser(): void
    {
        ($this->createUser)('john.doe@email.com', 'John', 'Doe');

        $retrievedUser = $this->userRepository->getByEmail($userEmail);
        self::assertSame('john.doe@email.com', $retrievedUser->email);
        self::assertSame('John', $retrievedUser->firstname);
        self::assertSame('Doe', $retrievedUser->lastname);
    }
}
          
Utilise la base de donnée = lent

La solution habituelle : les mocks


    public function testItCreatesAUser(): void
    {
        $userRepository = $this->createMock(UserRepository::class);
        $createUser = new CreateUser($userRepository);

        $userRepository
            ->expects($this->once())
            ->method('save')
            ->with(self::callback(
                static fn (User $user): bool => $user->email === 'john.doe@email.com'
                    && $user->firstname === 'John'
                    && $user->lastname === 'Doe',
            ));

        ($createUser)($userEmail, 'John', 'Doe');
    }
          
Rapide, mais couplé à l'implémentation.

Et si l'utilisateur existe déjà?


final readonly class CreateUser
{
    public function __invoke(string $email, string $firstname, string $lastname): void
    {
        if ($this->userAlreadyExists($email)) {
            throw new UserAlreadyExists($email);
        }

        $this->userRepository->save(new User($email, $firstname, $lastname));
    }

    private function userAlreadyExists(string $email): bool
    {
        try {
            $this->userRepository->getByEmail($email);
        } catch (UserNotFound) {
            return false;
        }

        return true;
    }
}
          

Nouveau test pour nouveau scénario


    public function testItThrowsAnExceptionIfUserWithSameEmailAlreadyExists(): void
    {
        ($this->createUser)('jane.doe@email.com', 'Jane', 'Doe');

        try {
            ($this->createUser)('jane.doe@email.com', 'John', 'Doe');
        } catch (\Throwable $exception) {
            self::assertInstanceOf(UserAlreadyExists::class, $exception);
            self::assertSame(
                'User with email "jane.doe@email.com" already exist.',
                $exception->getMessage(),
            );

            $existingUser = $this->userRepository->getByEmail('jane.doe@email.com');
            self::assertSame('Jane', $existingUser->firstname);

            return;
        }

        self::fail('An exception should have been thrown.');
    }
          
Mon test fonctionnel précédent fonctionne toujours.

Et avec les mocks ?


    public function testItThrowsAnExceptionIfUserWithSameEmailAlreadyExists(): void
    {
        $userRepository = $this->createMock(UserRepository::class);
        $createUser = new CreateUser($userRepository);

        $userRepository->expects($this->never())->method('save');
        $userRepository
            ->expects($this->once())
            ->method('getByEmail')
            ->with('jane.doe@email.com')
            ->willReturn(new User('jane.doe@email.com', 'Jane', 'Doe'));

        $this->expectException(UserAlreadyExists::class);
        $this->expectExceptionMessage('User with email "jane.doe@email.com" already exist.');
        ($createUser)('jane.doe@email.com', 'John', 'Doe');
    }
          
Mon test précédent ne fonctionne plus 😞.

Test avec mocks = couplage


    public function testItCreatesAUser(): void
    {
        $userRepository = $this->createMock(UserRepository::class);
        $createUser = new CreateUser($userRepository);

        $userRepository
            ->expects($this->once())
            ->method('getByEmail')
            ->with($userEmail)
            ->willThrowException(new UserNotFound($userEmail));

        $userRepository
            ->expects($this->once())
            ->method('save')
            ->with(self::callback(
                static fn (User $user): bool => $user->email === 'john.doe@email.com'
                    && $user->firstname === 'John'
                    && $user->lastname === 'Doe',
            ));

        ($createUser)($userEmail, 'John', 'Doe');
    }
          

C'est quoi un "bon" test ?


F.I.R.S.T.

  • Fast : on veut un retour succès/échec rapide
  • Isolated : teste une chose et une seule, pas d'effets de bord
  • Repeatable : un test doit être stable
  • Self-validating : pas de doute sur le succès ou l'échec
  • Thourough : couvre tout le métier

C'est quoi un "bon" test ?


Focalisé sur le métier

Simple

Il n'y a pas que les mocks dans la vie

Doublures (test doubles)

  • Mock : doit être exécuté
  • Stub : simple description
  • Spy : vérification après exécution
  • Double en mémoire

L'architecture hexagonale

hexagon-only.svg

L'architecture hexagonale

hexagon-with-ports.svg

L'architecture hexagonale

hexagonal.svg

L'architecture hexagonale

hexagonal-switch-persistence.svg

L'architecture hexagonale

hexagonal-switch-tests.svg

Exemple d'adaptateurs


final class DatabaseUserRepository extends ServiceEntityRepository implements UserRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, User::class);
    }

    public function save(User ...$users): void
    {
        foreach ($users as $user) {
            $this->getEntityManager()->persist($user);
        }
        $this->getEntityManager()->flush();
    }
}
          

Exemple d'adaptateurs


final class InMemoryUserRepository implements UserRepository
{
    /** @var User[] */
    private array $users = [];

    public function save(User ...$users): void
    {
        foreach ($users as $user) {
            $this->users[] = clone $user;
        }
    }
}
          

Tester les adaptateurs


final class UserRepositoryTest extends AbstractIntegrationTestCase
{
    private readonly UserRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = self::getService(UserRepository::class);
    }

    #[Group('with-in-memory-adapters')]
    #[Group('with-production-adapters')]
    public function testItSavesAUser(): void
    {
        $user = new User('john.doe@email.com', 'John', 'Doe');

        $this->repository->save($user);

        $this->assertUserIsStored($user);
    }
}
          

Tester les adaptateurs


abstract class AbstractIntegrationTestCase extends KernelTestCase
{
    protected static function bootKernel(array $options = []): KernelInterface
    {
        $testEnvironment = self::getCurrentTestEnvironment();

        if (!\in_array($testEnvironment, ['memory', 'test'], true)) {
            throw new \RuntimeException(
                "Invalid TEST_ENV environment variable value \"$testEnvironment\".,
            );
        }

        $options = array_merge($options, ['environment' => $testEnvironment]);

        return parent::bootKernel($options);
    }
}
          

# config/services.yaml
when@memory:
    services:
        App\Domain\Repository\UserRepository:
            class: App\Infrastructure\Persistence\InMemory\InMemoryUserRepository
          

Tester le métier unitairement


    public function testItCreatesAUser(): void
    {
        $userRepository = new InMemoryUserRepository();
        $createUser = new CreateUser($userRepository);

        ($createUser)('john.doe@email.com', 'John', 'Doe');

        $retrievedUser = $userRepository->getByEmail('john.doe@email.com');
        self::assertSame($userEmail, $retrievedUser->email);
        self::assertSame('John', $retrievedUser->firstname);
        self::assertSame('Doe', $retrievedUser->lastname);
    }
          

Et avec le framework ?


final class CreateUserTest extends AbstractAcceptanceTestCase
{
    private CreateUser $createUser;
    private UserRepository $userRepository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->createUser = self::getService(CreateUser::class);
        $this->userRepository = self::getService(UserRepository::class);
    }

    public function testItCreatesAUser(): void
    {
        ($this->createUser)('john.doe@email.com', 'John', 'Doe');

        $retrievedUser = $this->userRepository->getByEmail($userEmail);
        self::assertSame('john.doe@email.com', $retrievedUser->email);
        self::assertSame('John', $retrievedUser->firstname);
        self::assertSame('Doe', $retrievedUser->lastname);
    }
}
          
Tests "d'acceptance"

Et avec le framework ?


abstract class AbstractAcceptanceTestCase extends KernelTestCase
{
    protected static function bootKernel(array $options = []): KernelInterface
    {
        $options = array_merge($options, ['environment' => 'memory']);

        return parent::bootKernel($options);
    }
}
          

Choisir le "bon" type de test

  • Unitaire : pour tout ce qui est strictement dans votre hexagone
  • Acceptance : pour le code métier qui intéragit avec les ports
  • Integration : pour vos adaptateurs
  • End-to-end : scenarios critiques

Conclusion

  • Doubles "in memory" = tests métier rapides et fiables
  • Pas d'effets de bords lors de refactorisation de code
  • Assure le respect des règles métier

No silver bullet

  • Requiert un bon design du code avec des séparations claires
  • Plusieurs types de tests
  • Implémentation "in memory" systématique pour tous les ports contrôlés
  • Pas appliquable sur tout, par exemple requêtes de lecture complexes

Sources de la présentation

Merci de votre attention