Testing in Symfony 2026: PHPUnit, KernelTestCase e Test Funzionali

Guida completa alle strategie di testing moderne in Symfony con PHPUnit 12, KernelTestCase e test funzionali per applicazioni robuste e affidabili.

Testing in Symfony con PHPUnit e KernelTestCase

Il testing del software moderno non rappresenta più una funzionalità opzionale, ma un requisito fondamentale per le applicazioni Symfony professionali. Nel 2026, l'ecosistema di testing di Symfony si è evoluto ulteriormente, offrendo agli sviluppatori strumenti potenti come PHPUnit 12, KernelTestCase e capacità avanzate per i test funzionali.

Symfony 7.2+ introduce strumenti di testing migliorati, tra cui performance ottimizzate per KernelTestCase e integrazione nativa con PHPUnit 12. Queste funzionalità consentono un'esecuzione dei test più rapida e un migliore isolamento.

Perché il Testing in Symfony è Indispensabile

La complessità delle moderne applicazioni web richiede una rete di sicurezza robusta. Le applicazioni Symfony interagiscono con database, API esterne, code di messaggi e numerosi altri componenti. Senza test automatizzati, ogni modifica al codice diventa un rischio.

I test svolgono diverse funzioni critiche:

  • Prevenzione delle regressioni: Le modifiche non compromettono le funzionalità esistenti
  • Documentazione: I test descrivono il comportamento atteso del codice
  • Sicurezza nel refactoring: Il codice può essere migliorato senza timore di errori non rilevati
  • Fiducia nel deployment: Le pipeline automatizzate possono effettuare deploy sicuri

Configurazione di PHPUnit 12 in Symfony

PHPUnit 12 porta miglioramenti significativi per i progetti Symfony. L'installazione avviene tramite Composer:

bash
composer require --dev phpunit/phpunit:^12.0
composer require --dev symfony/test-pack

La configurazione viene definita nel file phpunit.xml.dist:

xml
<?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"
         requireCoverageMetadata="true"
         beStrictAboutOutputDuringTests="true"
         displayDetailsOnTestsThatTriggerWarnings="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="coverage"/>
        </report>
    </coverage>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="test"/>
        <env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
    </php>
</phpunit>

Unit Test: La Base della Piramide dei Test

Gli unit test costituiscono le fondamenta di ogni strategia di testing. Testano unità isolate senza dipendenze esterne:

php
<?php

namespace App\Tests\Unit\Service;

use App\Service\PriceCalculator;
use App\Entity\Product;
use App\Entity\Discount;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new PriceCalculator();
    }

    #[Test]
    public function calculatesPriceWithoutDiscount(): void
    {
        $product = new Product();
        $product->setPrice(100.00);

        $result = $this->calculator->calculate($product);

        $this->assertSame(100.00, $result);
    }

    #[Test]
    public function appliesPercentageDiscount(): void
    {
        $product = new Product();
        $product->setPrice(100.00);

        $discount = new Discount();
        $discount->setType('percentage');
        $discount->setValue(20);

        $result = $this->calculator->calculate($product, $discount);

        $this->assertSame(80.00, $result);
    }

    #[Test]
    #[DataProvider('discountProvider')]
    public function handlesVariousDiscountScenarios(
        float $price,
        string $type,
        float $value,
        float $expected
    ): void {
        $product = new Product();
        $product->setPrice($price);

        $discount = new Discount();
        $discount->setType($type);
        $discount->setValue($value);

        $result = $this->calculator->calculate($product, $discount);

        $this->assertSame($expected, $result);
    }

    public static function discountProvider(): array
    {
        return [
            'percentage_10' => [100.00, 'percentage', 10, 90.00],
            'percentage_50' => [200.00, 'percentage', 50, 100.00],
            'fixed_amount' => [100.00, 'fixed', 25, 75.00],
            'zero_discount' => [100.00, 'percentage', 0, 100.00],
        ];
    }
}

KernelTestCase: Integrazione con il Container di Symfony

KernelTestCase consente l'accesso al service container di Symfony durante i test. Questo è essenziale per i test di integrazione che richiedono servizi reali:

php
<?php

namespace App\Tests\Integration\Service;

use App\Service\UserRegistrationService;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use PHPUnit\Framework\Attributes\Test;

class UserRegistrationServiceTest extends KernelTestCase
{
    private UserRegistrationService $service;
    private UserRepository $userRepository;

    protected function setUp(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        $this->service = $container->get(UserRegistrationService::class);
        $this->userRepository = $container->get(UserRepository::class);
    }

    #[Test]
    public function registersNewUser(): void
    {
        $userData = [
            'email' => 'test@example.com',
            'password' => 'SecurePassword123!',
            'name' => 'Test User',
        ];

        $user = $this->service->register($userData);

        $this->assertNotNull($user->getId());
        $this->assertSame('test@example.com', $user->getEmail());
        $this->assertTrue($user->isActive());
    }

    #[Test]
    public function throwsExceptionForDuplicateEmail(): void
    {
        $userData = [
            'email' => 'duplicate@example.com',
            'password' => 'SecurePassword123!',
            'name' => 'First User',
        ];

        $this->service->register($userData);

        $this->expectException(\App\Exception\DuplicateEmailException::class);

        $this->service->register($userData);
    }

    protected function tearDown(): void
    {
        parent::tearDown();
    }
}

Test del Database con Transazioni

Per i test che interagiscono con il database, si raccomanda l'uso delle transazioni per l'isolamento:

php
<?php

namespace App\Tests\Integration\Repository;

use App\Entity\Article;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use PHPUnit\Framework\Attributes\Test;

class ArticleRepositoryTest extends KernelTestCase
{
    private EntityManagerInterface $entityManager;
    private ArticleRepository $repository;

    protected function setUp(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        $this->entityManager = $container->get(EntityManagerInterface::class);
        $this->repository = $container->get(ArticleRepository::class);

        $this->entityManager->beginTransaction();
    }

    #[Test]
    public function findsPublishedArticles(): void
    {
        $article = new Article();
        $article->setTitle('Test Article');
        $article->setStatus('published');
        $article->setPublishedAt(new \DateTimeImmutable());

        $this->entityManager->persist($article);
        $this->entityManager->flush();

        $results = $this->repository->findPublished();

        $this->assertCount(1, $results);
        $this->assertSame('Test Article', $results[0]->getTitle());
    }

    #[Test]
    public function excludesDraftArticles(): void
    {
        $draft = new Article();
        $draft->setTitle('Draft Article');
        $draft->setStatus('draft');

        $published = new Article();
        $published->setTitle('Published Article');
        $published->setStatus('published');
        $published->setPublishedAt(new \DateTimeImmutable());

        $this->entityManager->persist($draft);
        $this->entityManager->persist($published);
        $this->entityManager->flush();

        $results = $this->repository->findPublished();

        $this->assertCount(1, $results);
    }

    protected function tearDown(): void
    {
        $this->entityManager->rollback();
        parent::tearDown();
    }
}

WebTestCase: Test Funzionali per i Controller

I test funzionali simulano richieste HTTP e verificano il comportamento dell'intera applicazione:

php
<?php

namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;
use PHPUnit\Framework\Attributes\Test;

class ArticleControllerTest extends WebTestCase
{
    #[Test]
    public function listArticlesReturnsOk(): void
    {
        $client = static::createClient();

        $client->request('GET', '/api/articles');

        $this->assertResponseIsSuccessful();
        $this->assertResponseHeaderSame('content-type', 'application/json');
    }

    #[Test]
    public function createArticleRequiresAuthentication(): void
    {
        $client = static::createClient();

        $client->request('POST', '/api/articles', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode([
            'title' => 'New Article',
            'content' => 'Article content',
        ]));

        $this->assertResponseStatusCodeSame(Response::HTTP_UNAUTHORIZED);
    }

    #[Test]
    public function authenticatedUserCanCreateArticle(): void
    {
        $client = static::createClient();
        $client->loginUser($this->getTestUser());

        $client->request('POST', '/api/articles', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode([
            'title' => 'New Article',
            'content' => 'Article content',
        ]));

        $this->assertResponseStatusCodeSame(Response::HTTP_CREATED);

        $responseData = json_decode($client->getResponse()->getContent(), true);
        $this->assertArrayHasKey('id', $responseData);
        $this->assertSame('New Article', $responseData['title']);
    }

    private function getTestUser(): object
    {
        $container = static::getContainer();
        $userRepository = $container->get(\App\Repository\UserRepository::class);

        return $userRepository->findOneBy(['email' => 'test@example.com']);
    }
}

Test delle API con Assertion JSON

Le moderne applicazioni Symfony sono spesso API-first. Assertion specifiche facilitano il testing delle response JSON:

php
<?php

namespace App\Tests\Functional\Api;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use PHPUnit\Framework\Attributes\Test;

class ProductApiTest extends WebTestCase
{
    #[Test]
    public function getProductReturnsCorrectStructure(): void
    {
        $client = static::createClient();

        $client->request('GET', '/api/products/1');

        $this->assertResponseIsSuccessful();

        $this->assertJsonContains([
            'id' => 1,
            'type' => 'product',
        ]);

        $content = json_decode($client->getResponse()->getContent(), true);

        $this->assertArrayHasKey('name', $content);
        $this->assertArrayHasKey('price', $content);
        $this->assertArrayHasKey('currency', $content);
        $this->assertIsFloat($content['price']);
    }

    #[Test]
    public function searchProductsWithFilters(): void
    {
        $client = static::createClient();

        $client->request('GET', '/api/products', [
            'category' => 'electronics',
            'min_price' => 100,
            'max_price' => 500,
        ]);

        $this->assertResponseIsSuccessful();

        $content = json_decode($client->getResponse()->getContent(), true);

        $this->assertArrayHasKey('data', $content);
        $this->assertArrayHasKey('meta', $content);
        $this->assertIsArray($content['data']);
    }

    #[Test]
    public function invalidProductIdReturns404(): void
    {
        $client = static::createClient();

        $client->request('GET', '/api/products/99999');

        $this->assertResponseStatusCodeSame(404);
    }
}

Mocking dei Servizi Esterni

Le dipendenze esterne devono essere sostituite da mock nei test:

php
<?php

namespace App\Tests\Integration\Service;

use App\Service\PaymentService;
use App\Service\PaymentGatewayInterface;
use App\Entity\Order;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use PHPUnit\Framework\Attributes\Test;

class PaymentServiceTest extends KernelTestCase
{
    #[Test]
    public function processesPaymentSuccessfully(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        $gatewayMock = $this->createMock(PaymentGatewayInterface::class);
        $gatewayMock->expects($this->once())
            ->method('charge')
            ->with(
                $this->equalTo(99.99),
                $this->equalTo('EUR'),
                $this->isType('string')
            )
            ->willReturn(['status' => 'success', 'transaction_id' => 'txn_123']);

        $container->set(PaymentGatewayInterface::class, $gatewayMock);

        $paymentService = $container->get(PaymentService::class);

        $order = new Order();
        $order->setTotal(99.99);
        $order->setCurrency('EUR');

        $result = $paymentService->process($order);

        $this->assertTrue($result->isSuccessful());
        $this->assertSame('txn_123', $result->getTransactionId());
    }

    #[Test]
    public function handlesPaymentFailure(): void
    {
        self::bootKernel();
        $container = static::getContainer();

        $gatewayMock = $this->createMock(PaymentGatewayInterface::class);
        $gatewayMock->method('charge')
            ->willThrowException(new \App\Exception\PaymentDeclinedException('Card declined'));

        $container->set(PaymentGatewayInterface::class, $gatewayMock);

        $paymentService = $container->get(PaymentService::class);

        $order = new Order();
        $order->setTotal(99.99);
        $order->setCurrency('EUR');

        $this->expectException(\App\Exception\PaymentDeclinedException::class);

        $paymentService->process($order);
    }
}

Test Fixture e Factory

Per dati di test consistenti, si raccomandano le classi factory:

php
<?php

namespace App\Tests\Factory;

use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class UserFactory
{
    private static int $counter = 0;

    public function __construct(
        private readonly UserPasswordHasherInterface $passwordHasher
    ) {}

    public function create(array $attributes = []): User
    {
        self::$counter++;

        $user = new User();
        $user->setEmail($attributes['email'] ?? "user" . self::$counter . "@test.com");
        $user->setName($attributes['name'] ?? 'Test User ' . self::$counter);
        $user->setRoles($attributes['roles'] ?? ['ROLE_USER']);

        $password = $attributes['password'] ?? 'password123';
        $hashedPassword = $this->passwordHasher->hashPassword($user, $password);
        $user->setPassword($hashedPassword);

        return $user;
    }

    public function createAdmin(array $attributes = []): User
    {
        return $this->create(array_merge($attributes, [
            'roles' => ['ROLE_ADMIN'],
        ]));
    }
}

Code Coverage e Metriche di Qualità

La code coverage dovrebbe essere una guida, non un obiettivo assoluto:

bash
# Generare report di coverage
php bin/phpunit --coverage-html coverage/

# Coverage con requisito minimo
php bin/phpunit --coverage-text --coverage-filter src/ --min-coverage=80

Pronto a superare i tuoi colloqui su Symfony?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

Continuous Integration

I test devono essere eseguiti automaticamente nella pipeline CI/CD:

yaml
# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: app_test
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: xdebug

      - name: Install dependencies
        run: composer install --prefer-dist

      - name: Run tests
        run: php bin/phpunit --coverage-text
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/app_test

Conclusione

Il testing efficace in Symfony richiede una combinazione ponderata di diversi tipi di test. Gli unit test garantiscono la correttezza dei singoli componenti, mentre i test di integrazione basati su KernelTestCase validano l'interazione con il container di Symfony. I test funzionali con WebTestCase simulano interazioni reali degli utenti e assicurano che l'applicazione funzioni nel suo complesso.

L'investimento in una solida infrastruttura di testing ripaga attraverso una maggiore qualità del codice, cicli di sviluppo più rapidi e deployment sicuri. Con PHPUnit 12 e gli strumenti di testing avanzati di Symfony 7.2, gli sviluppatori dispongono di un ecosistema maturo che consente un testing professionale al massimo livello.

Tag

#symfony
#phpunit
#testing
#kerneltestcase
#functional-tests

Condividi

Articoli correlati