Symfony Testing 2026: PHPUnit, KernelTestCase und funktionale Tests

Ein umfassender Leitfaden zu modernen Teststrategien in Symfony mit PHPUnit 12, KernelTestCase und funktionalen Tests für robuste Anwendungen.

Symfony Testing mit PHPUnit und KernelTestCase

Modernes Softwaretesting ist kein optionales Feature mehr, sondern eine fundamentale Anforderung für professionelle Symfony-Anwendungen. Im Jahr 2026 hat sich das Testing-Ökosystem von Symfony weiter entwickelt und bietet Entwicklern leistungsstarke Werkzeuge wie PHPUnit 12, KernelTestCase und erweiterte funktionale Testmöglichkeiten.

Symfony 7.2+ bringt verbesserte Testing-Tools mit sich, darunter optimierte KernelTestCase-Performance und nahtlose PHPUnit 12-Integration. Diese Funktionen ermöglichen schnellere Testausführung und bessere Isolierung.

Warum Testing in Symfony unverzichtbar ist

Die Komplexität moderner Webanwendungen erfordert ein robustes Sicherheitsnetz. Symfony-Anwendungen interagieren mit Datenbanken, externen APIs, Message Queues und zahlreichen anderen Komponenten. Ohne automatisierte Tests wird jede Codeänderung zu einem Risiko.

Tests erfüllen mehrere kritische Funktionen:

  • Regression Prevention: Änderungen brechen keine bestehende Funktionalität
  • Dokumentation: Tests beschreiben das erwartete Verhalten des Codes
  • Refactoring-Sicherheit: Code kann ohne Angst vor unentdeckten Fehlern verbessert werden
  • Deployment-Vertrauen: Automatisierte Pipelines können sicher deployen

PHPUnit 12 in Symfony konfigurieren

PHPUnit 12 bringt signifikante Verbesserungen für Symfony-Projekte. Die Installation erfolgt über Composer:

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

Die Konfiguration erfolgt in der 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: Die Basis der Testpyramide

Unit Tests bilden das Fundament jeder Teststrategie. Sie testen isolierte Einheiten ohne externe Abhängigkeiten:

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: Integration mit dem Symfony-Container

KernelTestCase ermöglicht den Zugriff auf den Symfony-Service-Container während der Tests. Dies ist essentiell für Integrationstests, die echte Services benötigen:

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

Datenbank-Tests mit Transaktionen

Für Tests, die mit der Datenbank interagieren, empfiehlt sich die Verwendung von Transaktionen für die Isolation:

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: Funktionale Tests für Controller

Funktionale Tests simulieren HTTP-Anfragen und überprüfen das Verhalten der gesamten Anwendung:

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 mit JSON-Assertions

Moderne Symfony-Anwendungen sind häufig API-first. Spezielle Assertions erleichtern das Testen von 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 externer Services

Externe Abhängigkeiten sollten in Tests durch Mocks ersetzt werden:

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 und Factories

Für konsistente Testdaten empfehlen sich Factory-Klassen:

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 und Qualitätsmetriken

Code Coverage sollte als Leitfaden, nicht als absolutes Ziel dienen:

bash
# Coverage-Report generieren
php bin/phpunit --coverage-html coverage/

# Coverage mit Mindestanforderung
php bin/phpunit --coverage-text --coverage-filter src/ --min-coverage=80

Bereit für deine Symfony-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Continuous Integration

Tests sollten automatisch in der CI/CD-Pipeline ausgeführt werden:

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

Fazit

Effektives Testing in Symfony erfordert eine durchdachte Kombination verschiedener Testarten. Unit Tests garantieren die Korrektheit einzelner Komponenten, während KernelTestCase-basierte Integrationstests das Zusammenspiel mit dem Symfony-Container validieren. Funktionale Tests mit WebTestCase simulieren echte Benutzerinteraktionen und stellen sicher, dass die Anwendung als Ganzes funktioniert.

Die Investition in eine solide Testinfrastruktur zahlt sich durch erhöhte Codequalität, schnellere Entwicklungszyklen und sicheres Deployment aus. Mit PHPUnit 12 und den erweiterten Testing-Tools von Symfony 7.2 steht Entwicklern ein ausgereiftes Ökosystem zur Verfügung, das professionelles Testing auf höchstem Niveau ermöglicht.

Tags

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

Teilen

Verwandte Artikel