Aller au contenu principal
Retour au blog

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

Stratégie de tests Symfony : unitaires, intégration, fonctionnels. Config PHPUnit et mutation testing.

Flavien Métivier · 8 min de lecture
Sommaire · 9 parties

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.

bash
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

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

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Estimer ma migration →

Test fonctionnel : Controller

php
<?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.

bash
# 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
<?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 été utile ? Partagez-le.

Portrait de Flavien Métivier, fondateur de The Bearded Bear : barbe longue, lunettes, sur fond sombre

Écrit par

Flavien Métivier

Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.

Mon parcours

À lire ensuite

Migration Symfony en vue ?

Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.

Newsletter Bear Bytes

Veille Claude Code, Symfony et PHP, retours d'expérience terrain et conseils techniques, directement dans votre boîte mail.