Symfony Testen in 2026: PHPUnit, KernelTestCase en Functionele Tests

Een uitgebreide gids voor moderne teststrategieën in Symfony met PHPUnit 12, KernelTestCase en functionele tests voor robuuste applicaties.

Symfony Testen met PHPUnit en KernelTestCase

Modern software testen is geen optionele functie meer, maar een fundamentele vereiste voor professionele Symfony-applicaties. In 2026 is het testing-ecosysteem van Symfony verder geëvolueerd en biedt ontwikkelaars krachtige tools zoals PHPUnit 12, KernelTestCase en geavanceerde functionele testmogelijkheden.

Symfony 7.2+ introduceert verbeterde testing-tools, waaronder geoptimaliseerde KernelTestCase-prestaties en naadloze PHPUnit 12-integratie. Deze functies maken snellere testuitvoering en betere isolatie mogelijk.

Waarom Testen in Symfony Onmisbaar Is

De complexiteit van moderne webapplicaties vereist een robuust vangnet. Symfony-applicaties communiceren met databases, externe API's, message queues en talloze andere componenten. Zonder geautomatiseerde tests wordt elke codewijziging een risico.

Tests vervullen verschillende kritieke functies:

  • Regressiepreventie: Wijzigingen breken geen bestaande functionaliteit
  • Documentatie: Tests beschrijven het verwachte gedrag van de code
  • Refactoring-veiligheid: Code kan worden verbeterd zonder angst voor onontdekte fouten
  • Deployment-vertrouwen: Geautomatiseerde pipelines kunnen veilig deployen

PHPUnit 12 Configureren in Symfony

PHPUnit 12 brengt significante verbeteringen voor Symfony-projecten. De installatie gebeurt via Composer:

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

De configuratie wordt gedefinieerd in 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 Tests: De Basis van de Testpiramide

Unit tests vormen het fundament van elke teststrategie. Ze testen geïsoleerde eenheden zonder externe afhankelijkheden:

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: Integratie met de Symfony Container

KernelTestCase biedt toegang tot de Symfony service container tijdens tests. Dit is essentieel voor integratietests die echte services nodig hebben:

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

Database Tests met Transacties

Voor tests die met de database communiceren, wordt het gebruik van transacties aanbevolen voor isolatie:

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: Functionele Tests voor Controllers

Functionele tests simuleren HTTP-verzoeken en verifiëren het gedrag van de gehele applicatie:

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

API Tests met JSON Assertions

Moderne Symfony-applicaties zijn vaak API-first. Specifieke assertions vergemakkelijken het testen van JSON responses:

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 van Externe Services

Externe afhankelijkheden moeten worden vervangen door mocks in tests:

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 Fixtures en Factories

Voor consistente testdata worden factory-klassen aanbevolen:

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 en Kwaliteitsmetrieken

Code coverage moet dienen als richtlijn, niet als absoluut doel:

bash
# Coverage rapport genereren
php bin/phpunit --coverage-html coverage/

# Coverage met minimale vereiste
php bin/phpunit --coverage-text --coverage-filter src/ --min-coverage=80

Klaar om je Symfony gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Continuous Integration

Tests moeten automatisch worden uitgevoerd in de CI/CD-pipeline:

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

Conclusie

Effectief testen in Symfony vereist een doordachte combinatie van verschillende testsoorten. Unit tests garanderen de correctheid van individuele componenten, terwijl KernelTestCase-gebaseerde integratietests de samenwerking met de Symfony container valideren. Functionele tests met WebTestCase simuleren echte gebruikersinteracties en zorgen ervoor dat de applicatie als geheel functioneert.

De investering in een solide testinfrastructuur betaalt zich terug door verhoogde codekwaliteit, snellere ontwikkelcycli en veilige deployments. Met PHPUnit 12 en de uitgebreide testing-tools van Symfony 7.2 beschikken ontwikkelaars over een volwassen ecosysteem dat professioneel testen op het hoogste niveau mogelijk maakt.

Tags

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

Delen

Gerelateerde artikelen