Aller au contenu principal
Retour au blog

Architecture Symfony de A à Z — Partie 3 : Testing et qualité

Flavien Métivier25 novembre 20258 min

Troisième volet de notre série Architecture Symfony. Après les couches et responsabilités, on aborde le sujet qui fait la différence entre un projet qui tient et un projet qui casse : les tests.

La pyramide des tests Symfony

Un projet Symfony bien testé repose sur 3 niveaux, chacun avec sa cible et sa vitesse d'exécution :

  • Tests unitaires — Logique du Domain (entités, Value Objects, handlers). Rapides, sans I/O. Cible : >= 80% du Domain.
  • Tests d'intégration — Services + base de données. Vérifient que les implémentations Doctrine fonctionnent. Plus lents.
  • Tests fonctionnels — Couche HTTP. Appellent les endpoints, vérifient les réponses. Les plus lents mais les plus réalistes.

Structure des tests : le miroir de src/

La convention : la structure des tests reflète exactement celle du code source. Quand un développeur modifie src/Domain/Entity/User.php, il sait immédiatement que les tests sont dans tests/Unit/Domain/Entity/UserTest.php.

tests/
├── Unit/                        # Tests sans I/O
   ├── Domain/
   ├── Entity/
   └── UserTest.php
   └── ValueObject/
       └── EmailTest.php
   └── Application/
       └── Command/
           └── CreateUserCommandHandlerTest.php
├── Integration/                 # Tests avec DB
   └── Infrastructure/
       └── Doctrine/
           └── Repository/
               └── UserRepositoryTest.php
└── Functional/                  # Tests HTTP
    └── Controller/
        └── UserControllerTest.php

Configuration PHPUnit

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         colors="true"
         executionOrder="depends,defects"
         failOnRisky="true"
         failOnWarning="true">

    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
        <testsuite name="Functional">
            <directory>tests/Functional</directory>
        </testsuite>
    </testsuites>

    <coverage>
        <report>
            <html outputDirectory="var/coverage"/>
            <clover outputFile="var/coverage/clover.xml"/>
        </report>
    </coverage>

    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

Test unitaire : Entity

<?php

declare(strict_types=1);

namespace Tests\Unit\Domain\Entity;

use App\Domain\Entity\User;
use App\Domain\Event\UserCreated;
use App\Domain\ValueObject\Email;
use PHPUnit\Framework\TestCase;

final class UserTest extends TestCase
{
    public function testCreateUserWithValidEmail(): void
    {
        $email = Email::fromString('test@example.com');
        $user = User::create($email);

        self::assertNotNull($user->id());
        self::assertTrue($user->email()->equals($email));
        self::assertNotNull($user->createdAt());
    }

    public function testRecordsDomainEvent(): void
    {
        $user = User::create(Email::fromString('test@example.com'));
        $events = $user->pullDomainEvents();

        self::assertCount(1, $events);
        self::assertInstanceOf(UserCreated::class, $events[0]);
    }

    public function testPullEventsIsIdempotent(): void
    {
        $user = User::create(Email::fromString('test@example.com'));

        self::assertCount(1, $user->pullDomainEvents());
        self::assertCount(0, $user->pullDomainEvents());
    }
}

Test d'intégration : Repository

<?php

declare(strict_types=1);

namespace Tests\Integration\Infrastructure\Doctrine\Repository;

use App\Domain\Entity\User;
use App\Domain\ValueObject\Email;
use App\Infrastructure\Doctrine\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class UserRepositoryTest extends KernelTestCase
{
    private UserRepository $repository;

    protected function setUp(): void
    {
        $kernel = self::bootKernel();
        $this->repository = $kernel->getContainer()
            ->get('doctrine')
            ->getManager()
            ->getRepository(User::class);
    }

    public function testSaveAndFind(): void
    {
        $user = User::create(Email::fromString('repo@example.com'));
        $this->repository->save($user);

        $found = $this->repository->findByEmail(
            Email::fromString('repo@example.com')
        );

        self::assertNotNull($found);
        self::assertTrue($found->id()->equals($user->id()));
    }
}

Besoin d'un expert Symfony ?

Réserver un appel

Test fonctionnel : Controller

<?php

declare(strict_types=1);

namespace Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

final class UserControllerTest extends WebTestCase
{
    public function testCreateUser(): void
    {
        $client = static::createClient();

        $client->request('POST', '/api/users', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['email' => 'new@example.com'], JSON_THROW_ON_ERROR));

        self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
    }

    public function testCreateUserWithInvalidEmailReturns400(): void
    {
        $client = static::createClient();

        $client->request('POST', '/api/users', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['email' => 'not-an-email'], JSON_THROW_ON_ERROR));

        self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
    }
}

Mutation testing avec Infection

La couverture de code ne suffit pas. Un test peut couvrir une ligne sans réellement vérifier son comportement. Le mutation testing modifie ton code source (mutants) et vérifie que tes tests détectent les changements.

# Lancer Infection
docker run --rm -v $(pwd):/app \
  vendor/bin/infection --min-msi=80 --min-covered-msi=90

# Métriques clés :
# MSI (Mutation Score Indicator) — >= 80% : tes tests sont solides
# Covered MSI                    — >= 90% : le code testé est bien testé
# Escaped mutants                — Les bugs que tes tests ne détectent pas

Tests d'architecture

Un test que peu de projets ont — et qui pourtant évite les régressions architecturales : vérifier automatiquement que le Domain ne dépend jamais de l'Infrastructure.

<?php

declare(strict_types=1);

namespace Tests\Architecture;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;

final class ArchitectureTest extends TestCase
{
    public function testDomainHasNoDependencyOnInfrastructure(): void
    {
        $finder = new Finder();
        $finder->files()->in(__DIR__ . '/../../src/Domain')->name('*.php');

        foreach ($finder as $file) {
            $content = $file->getContents();
            $this->assertStringNotContainsString(
                'use Doctrine\\',
                $content,
                "Domain ne doit pas dépendre de Doctrine : {$file->getRelativePathname()}"
            );
            $this->assertStringNotContainsString(
                'use Symfony\\',
                $content,
                "Domain ne doit pas dépendre de Symfony : {$file->getRelativePathname()}"
            );
        }
    }
}

Objectifs de coverage réalistes

  • Domain — >= 80% (logique métier critique)
  • Application — >= 70% (orchestration)
  • Infrastructure — >= 60% (tests d'intégration)
  • Global — >= 80% minimum avant production
  • MSI (Infection) — >= 80% pour valider la qualité des tests

Ces objectifs ne sont pas arbitraires — ce sont les seuils que nous appliquons sur tous nos projets. Si votre codebase Symfony est en dessous, Bear Upgrade inclut la mise en place d'une stratégie de tests complète avec l'infrastructure CI/CD qui va avec.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.