Zum Inhalt springen

PHPUnit Komplett-Handbuch auf Deutsch

Veröffentlicht am 22. Feb. 2026 | ca. 4 Min. Lesezeit |

Lange Zeit fehlte ein umfassendes, deutschsprachiges Handbuch für PHPUnit. Die offizielle Dokumentation ist englisch und oft sehr knapp. Konferenz-Talks gehen selten über die Basics hinaus. Dieses Handbuch schließt diese Lücke — für alle, die PHPUnit von Grund auf lernen oder ihr Wissen vertiefen wollen.

Inhaltsverzeichnis

  1. Einführung und Grundlagen
  2. Installation und Konfiguration
  3. Der erste Unit Test — Schritt für Schritt
  4. Assertions — Alle Prüfmethoden im Überblick
  5. Data Providers — Parametrisierte Tests
  6. Test Doubles: Mocks, Stubs und Fakes
  7. Exception Testing
  8. Service-Objekte testen
  9. Datenbank-Tests
  10. API-Mocking — Pseudo-Endpoints erstellen
  11. Microservices testen
  12. Symfony-Integration im Detail
  13. Code Coverage und Qualitätsmetriken
  14. CI/CD-Integration
  15. Best Practices und Projektstruktur
  16. Anhang: Wichtige Attribute (PHPUnit 12+)

1. Einführung und Grundlagen

Was ist PHPUnit?

PHPUnit ist das Standard-Test-Framework für PHP, entwickelt und gepflegt von Sebastian Bergmann. Es basiert auf der xUnit-Architektur und ermöglicht es, automatisierte Tests für PHP-Code zu schreiben. Statt manuell durch die Anwendung zu klicken und zu prüfen, ob alles funktioniert, schreibt man Tests, die das automatisch erledigen — jedes Mal, wenn man den Befehl dafür auslöst.

Warum automatisierte Tests?

Man kennt das Szenario: Eine Funktion wird geändert und plötzlich geht an einer ganz anderen Stelle etwas kaputt. Genau das verhindern automatisierte Tests:

  • Regression verhindern: Änderungen brechen keinen bestehenden Code, weil die Tests das sofort melden.
  • Refactoring ermöglichen: Man kann Code sicher umbauen, weil die Tests bestätigen, dass alles noch funktioniert.
  • Dokumentation: Tests beschreiben, was der Code tun soll — besser als jeder Kommentar.
  • Schnelleres Feedback: Statt 10 Minuten manuell zu testen, läuft die Test-Suite in Sekunden.
  • Vertrauen: Man deployt am Freitagnachmittag, ohne Angst zu haben.

Arten von Tests

Testart Was wird getestet? Geschwindigkeit Abhängigkeiten
Unit Test Eine einzelne Klasse/Methode isoliert Sehr schnell (ms) Keine (alles gemockt)
Integrations-Test Zusammenspiel mehrerer Klassen, DB, APIs Mittel (Sekunden) Datenbank, Services
Funktions-/E2E-Test Gesamter Request-Response-Zyklus Langsam Volle Anwendung

Die Test-Pyramide: Man schreibt viele Unit Tests (schnell, günstig), einige Integrationstests und wenige E2E-Tests. Unit Tests bilden die Basis.

Begriffe

Begriff Erklärung
Test Case Eine Klasse, die von TestCase erbt und Testmethoden enthält
Test Suite Eine Sammlung von Test Cases, konfiguriert in phpunit.xml
Assertion Eine Prüfung, z.B. assertSame(5, $result) — der Kern jedes Tests
Fixture Der definierte Ausgangszustand (setUp/tearDown)
Mock Ein Fake-Objekt, das Methodenaufrufe verifiziert
Stub Ein Fake-Objekt, das vordefinierte Werte zurückgibt
Data Provider Liefert mehrere Datensätze für denselben Test
Code Coverage Prozentsatz des Codes, der durch Tests ausgeführt wird

2. Installation und Konfiguration

Voraussetzungen

PHPUnit-Version PHP-Mindestversion Support bis
PHPUnit 11 PHP 8.2 Eingestellt (02/2026)
PHPUnit 12 PHP 8.3 Februar 2027
PHPUnit 13 PHP 8.4 Februar 2028

Installation via Composer

composer require --dev phpunit/phpunit

PHPUnit 10+ erfordert PHP 8.1 oder höher. Die Konfiguration erfolgt über phpunit.xml.dist:

<?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="vendor/autoload.php"
         colors="true"
         cacheDirectory=".phpunit.cache"
         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>
    <source>
        <include>
            <directory>src</directory>
        </include>
        <exclude>
            <directory>src/Migrations</directory>
        </exclude>
    </source>
    <php>
        <env name="APP_ENV" value="test"/>
    </php>
</phpunit>

Hinweis: PHPUnit 10 hat die Konfiguration geändert — <coverage> wurde durch <source> ersetzt, und Optionen wie convertDeprecationsToExceptions existieren nicht mehr.

Verzeichnisstruktur

tests/
├── Unit/           # Isolierte Tests ohne externe Abhängigkeiten
├── Integration/    # Tests mit Datenbank, Dateisystem etc.
└── Functional/     # HTTP-Tests (Symfony WebTestCase)

Tests ausführen

# Alle Tests ausführen
vendor/bin/phpunit

# Nur die Unit-Tests ausführen
vendor/bin/phpunit --testsuite Unit

# Nur eine bestimmte Testklasse
vendor/bin/phpunit tests/Unit/Service/CalculatorTest.php

# Nur eine bestimmte Methode
vendor/bin/phpunit --filter testAddsTwoNumbers

# Stoppe beim ersten Fehler
vendor/bin/phpunit --stop-on-failure

3. Der erste Unit Test — Schritt für Schritt

Die zu testende Klasse

Ein klassisches Lehrbeispiel — ein Taschenrechner, der alle wichtigen Konzepte zeigt:

<?php

declare(strict_types=1);

namespace App\Service;

class Calculator
{
    public function add(float $a, float $b): float
    {
        return $a + $b;
    }

    public function subtract(float $a, float $b): float
    {
        return $a - $b;
    }

    public function divide(float $a, float $b): float
    {
        if ($b === 0.0) {
            throw new \DivisionByZeroError(
                'Division durch Null ist nicht erlaubt.'
            );
        }

        return $a / $b;
    }

    public function percentage(float $value, float $percent): float
    {
        return $value * ($percent / 100);
    }
}

Der Test dazu

Jeder Test folgt dem AAA-Pattern (Arrange, Act, Assert): Zuerst bereitet man die Daten vor (Arrange), dann führt man die Aktion aus (Act), und schließlich prüft man das Ergebnis (Assert).

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\Calculator;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(Calculator::class)]
class CalculatorTest extends TestCase
{
    private Calculator $calculator;

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

    #[Test]
    public function addsTwoPositiveNumbers(): void
    {
        $result = $this->calculator->add(2, 3);

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

    #[Test]
    public function addsTwoNegativeNumbers(): void
    {
        $result = $this->calculator->add(-2, -3);
        $this->assertSame(-5.0, $result);
    }

    #[Test]
    public function dividesCorrectly(): void
    {
        $result = $this->calculator->divide(10, 3);
        $this->assertEqualsWithDelta(3.333, $result, 0.001);
    }

    #[Test]
    public function divideByZeroThrowsException(): void
    {
        $this->expectException(\DivisionByZeroError::class);
        $this->expectExceptionMessage('Division durch Null');

        $this->calculator->divide(10, 0);
    }
}

Lifecycle-Methoden (Fixtures)

Methode Wann wird sie aufgerufen? Typischer Einsatz
setUp() Vor JEDEM einzelnen Test Objekte erstellen, Testdaten vorbereiten
tearDown() Nach JEDEM einzelnen Test Temporäre Dateien löschen, Verbindungen schließen
setUpBeforeClass() Einmal vor ALLEN Tests der Klasse Datenbankverbindung öffnen, teure Setups
tearDownAfterClass() Einmal nach ALLEN Tests der Klasse Datenbankverbindung schließen

Wichtig: Jeder Test muss unabhängig funktionieren. Tests dürfen sich NICHT gegenseitig beeinflussen. Deshalb nutzt man setUp() statt den Konstruktor — so bekommt jeder Test ein frisches Objekt.


4. Assertions — Alle Prüfmethoden im Überblick

Assertions sind das Herzstück jedes Tests. PHPUnit bietet über 60 verschiedene Assertions. Hier die wichtigsten, gruppiert nach Anwendungsfall:

Gleichheit und Identität

Assertion Prüft
assertSame($expected, $actual) Identisch (===)
assertEquals($expected, $actual) Gleich (==)
assertNotSame($expected, $actual) Nicht identisch (!==)
assertEqualsWithDelta($expected, $actual, $delta) Float-Vergleich mit Toleranz

Booleans und Null

Assertion Prüft
assertTrue($value) Ist true
assertFalse($value) Ist false
assertNull($value) Ist null
assertNotNull($value) Ist nicht null
assertEmpty($value) Ist leer
assertNotEmpty($value) Ist nicht leer

Strings

Assertion Prüft
assertStringContainsString($needle, $haystack) String enthält Teilstring
assertStringStartsWith($prefix, $string) String beginnt mit ...
assertStringEndsWith($suffix, $string) String endet mit ...
assertMatchesRegularExpression($pattern, $string) Regex-Match

Arrays und Collections

Assertion Prüft
assertCount($expected, $array) Anzahl der Elemente
assertContains($needle, $array) Array enthält Element
assertArrayHasKey($key, $array) Key existiert
assertContainsOnly($type, $array) Alle Elemente sind vom Typ

Typen und Objekte

Assertion Prüft
assertInstanceOf($class, $object) Objekttyp
assertIsArray($value) Ist ein Array
assertIsString($value) Ist ein String
assertIsInt($value) Ist ein Integer
assertObjectHasProperty($prop, $obj) Objekt hat Property

Dateien

Assertion Prüft
assertFileExists($path) Datei existiert
assertFileIsReadable($path) Datei ist lesbar
assertFileEquals($expected, $actual) Dateien sind identisch
assertDirectoryExists($path) Verzeichnis existiert

Wichtig: Man sollte assertSame() gegenüber assertEquals() bevorzugen. assertSame() prüft Typ und Wert, assertEquals() nur den Wert. Das verhindert subtile Fehler bei Typ-Jonglierung.


5. Data Providers — Parametrisierte Tests

Data Providers vermeiden Duplikation bei Tests mit verschiedenen Eingabewerten. Statt für jeden Eingabewert einen eigenen Test zu schreiben, definiert man einen Test und liefert ihm verschiedene Datensätze:

Einfacher Data Provider

<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

class CalculatorTest extends TestCase
{
    public static function additionProvider(): array
    {
        return [
            'zwei positive'    => [1, 2, 3.0],
            'zwei negative'    => [-1, -2, -3.0],
            'positiv+negativ'  => [5, -3, 2.0],
            'mit Null'         => [0, 0, 0.0],
            'Gleitkomma'       => [0.1, 0.2, 0.3],
        ];
    }

    #[Test]
    #[DataProvider('additionProvider')]
    public function addsNumbers(float $a, float $b, float $expected): void
    {
        $calc = new Calculator();
        $this->assertEqualsWithDelta($expected, $calc->add($a, $b), 0.0001);
    }
}

Data Provider mit Generator

Bei sehr vielen Testfällen kann man einen Generator nutzen, um Speicher zu sparen:

public static function largeDatasetProvider(): \Generator
{
    for ($i = 0; $i < 1000; $i++) {
        yield "Testfall $i" => [$i, $i * 2, $i * 3];
    }
}

Data Provider mit externen Daten

Auch CSV-Dateien oder JSON lassen sich als Datenquelle nutzen:

public static function csvProvider(): array
{
    $data = [];
    $file = fopen(__DIR__ . '/testdata/calculations.csv', 'r');
    fgetcsv($file); // Header überspringen

    while (($row = fgetcsv($file)) !== false) {
        $data[$row[0]] = [(float) $row[1], (float) $row[2], (float) $row[3]];
    }

    fclose($file);

    return $data;
}

Achtung: Data-Provider-Methoden müssen seit PHPUnit 10+ static sein! Ohne static erhält man eine Deprecation-Warnung (PHPUnit 12) oder einen Fehler (PHPUnit 13).


6. Test Doubles: Mocks, Stubs und Fakes

In der Realität hat fast jede Klasse Abhängigkeiten: eine andere Klasse, eine Datenbank, eine API. Bei einem echten Unit Test will man aber NUR die eine Klasse testen — ohne die Abhängigkeiten. Dafür gibt es Test Doubles.

Die Unterschiede verstehen

Typ Was tut es? Erstellt mit
Stub Gibt vordefinierte Rückgabewerte. Prüft NICHT, ob Methoden aufgerufen werden. createStub()
Mock Gibt Rückgabewerte UND verifiziert, dass bestimmte Methoden aufgerufen werden. createMock()
Fake Eine vereinfachte, funktionierende Implementierung (z.B. In-Memory Repository). Manuelle Klasse

Stubs im Detail

Stubs eignen sich, wenn man nur Abhängigkeiten simulieren will, ohne zu prüfen, ob sie aufgerufen werden:

<?php

declare(strict_types=1);

// src/Service/PriceCalculator.php
class PriceCalculator
{
    public function __construct(
        private TaxServiceInterface $taxService
    ) {}

    public function calculateGross(float $net): float
    {
        $taxRate = $this->taxService->getCurrentRate();

        return $net * (1 + $taxRate / 100);
    }
}

// tests/Unit/Service/PriceCalculatorTest.php
#[Test]
public function calculatesGrossPriceWithTax(): void
{
    $taxService = $this->createStub(TaxServiceInterface::class);
    $taxService->method('getCurrentRate')
        ->willReturn(19.0);

    $calculator = new PriceCalculator($taxService);

    $result = $calculator->calculateGross(100.0);
    $this->assertSame(119.0, $result);
}

Mocks im Detail

Mocks eignen sich, wenn man verifizieren will, DASS und WIE eine Methode aufgerufen wird:

<?php

declare(strict_types=1);

#[Test]
public function sendsEmailWhenOrderIsCompleted(): void
{
    $order = new Order(42, 'kunde@example.de');
    $repository = $this->createStub(OrderRepository::class);
    $repository->method('find')->willReturn($order);

    $mailer = $this->createMock(MailerInterface::class);
    $mailer->expects($this->once())
        ->method('send')
        ->with(
            'kunde@example.de',
            'Bestellung abgeschlossen',
            $this->stringContains('#42')
        );

    $processor = new OrderProcessor($mailer, $repository);
    $processor->completeOrder(42);
}

Invocation Counts

Methode Bedeutung
$this->once() Genau einmal
$this->exactly(3) Genau 3x
$this->atLeastOnce() Mindestens einmal
$this->atMost(2) Höchstens 2x
$this->never() Niemals (darf nicht aufgerufen werden)

Callback-basierte Rückgabewerte

Manchmal muss man den Rückgabewert dynamisch berechnen:

$repository = $this->createStub(UserRepository::class);
$repository->method('findByEmail')
    ->willReturnCallback(function (string $email): ?User {
        if ($email === 'admin@example.de') {
            return new User('admin@example.de', 'Admin');
        }

        return null;
    });

Aufeinanderfolgende Rückgabewerte

Wenn eine Methode mehrfach aufgerufen wird und jedes Mal etwas anderes zurückgeben soll:

$mock->method('isAvailable')
    ->willReturnOnConsecutiveCalls(true, true, false);

Fakes — selbst geschriebene Test Doubles

Manchmal ist ein Mock zu umständlich. Dann schreibt man ein Fake:

<?php

declare(strict_types=1);

class InMemoryUserRepository implements UserRepositoryInterface
{
    /** @var User[] */
    private array $users = [];

    public function save(User $user): void
    {
        $this->users[$user->getId()] = $user;
    }

    public function find(int $id): ?User
    {
        return $this->users[$id] ?? null;
    }

    public function findByEmail(string $email): ?User
    {
        foreach ($this->users as $user) {
            if ($user->getEmail() === $email) {
                return $user;
            }
        }

        return null;
    }
}

Wann Stub, wann Mock, wann Fake?

  • Stub: Man braucht Rückgabewerte, prüft aber nicht die Interaktion.
  • Mock: Man will prüfen, DASS und WIE etwas aufgerufen wird.
  • Fake: Die Logik ist zu komplex für Stubs/Mocks (z.B. ein ganzes Repository).

7. Exception Testing

Der Code soll nicht nur im Normalfall funktionieren, sondern auch bei Fehlern korrekt reagieren. PHPUnit bietet mehrere Wege, Exceptions zu testen:

Einfacher Exception-Test

#[Test]
public function throwsExceptionForInvalidEmail(): void
{
    $this->expectException(\InvalidArgumentException::class);
    $this->expectExceptionMessage('Ungültige E-Mail');

    new EmailAddress('keine-email');
}

Exception-Code prüfen

#[Test]
public function throwsExceptionWithCorrectCode(): void
{
    $this->expectException(PaymentException::class);
    $this->expectExceptionCode(402);
    $this->expectExceptionMessageMatches('/Zahlung.*fehlgeschlagen/');

    $gateway->processPayment(-50);
}

Exception mit Callback (flexibler)

Wenn man die Exception genauer untersuchen muss:

#[Test]
public function exceptionContainsContext(): void
{
    try {
        $service->processOrder(999);
        $this->fail('Erwartete Exception wurde nicht geworfen');
    } catch (OrderNotFoundException $e) {
        $this->assertSame(999, $e->getOrderId());
        $this->assertStringContainsString('nicht gefunden', $e->getMessage());
    }
}

Reihenfolge beachten! Bei expectException() muss die Erwartung VOR dem Code stehen, der die Exception auslöst. Code nach dem auslösenden Aufruf wird NICHT ausgeführt.


8. Service-Objekte testen

In einer typischen Symfony-Anwendung ist die Geschäftslogik in Services gekapselt. Services haben Abhängigkeiten (andere Services, Repositories, Mailer etc.), die per Dependency Injection reinkommen. Genau das macht sie gut testbar.

Ein realistisches Beispiel

<?php

declare(strict_types=1);

namespace App\Service;

class InvoiceService
{
    public function __construct(
        private InvoiceRepositoryInterface $repository,
        private TaxCalculatorInterface $taxCalculator,
        private PdfGeneratorInterface $pdfGenerator,
        private MailerInterface $mailer,
    ) {}

    public function createAndSendInvoice(
        Customer $customer,
        array $lineItems
    ): Invoice {
        $netTotal = array_sum(array_map(
            fn(LineItem $item) => $item->getPrice() * $item->getQuantity(),
            $lineItems
        ));

        $tax = $this->taxCalculator->calculate($netTotal, $customer->getCountry());

        $invoice = new Invoice($customer, $lineItems, $netTotal, $tax);
        $this->repository->save($invoice);

        $pdf = $this->pdfGenerator->generate($invoice);

        $this->mailer->sendWithAttachment(
            $customer->getEmail(),
            'Ihre Rechnung #' . $invoice->getNumber(),
            $pdf
        );

        return $invoice;
    }
}

Der vollständige Test

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(InvoiceService::class)]
class InvoiceServiceTest extends TestCase
{
    private InvoiceService $service;
    private $repository;
    private $taxCalculator;
    private $pdfGenerator;
    private $mailer;

    protected function setUp(): void
    {
        $this->repository = $this->createMock(InvoiceRepositoryInterface::class);
        $this->taxCalculator = $this->createStub(TaxCalculatorInterface::class);
        $this->pdfGenerator = $this->createStub(PdfGeneratorInterface::class);
        $this->mailer = $this->createMock(MailerInterface::class);

        $this->service = new InvoiceService(
            $this->repository,
            $this->taxCalculator,
            $this->pdfGenerator,
            $this->mailer,
        );
    }

    #[Test]
    public function createsInvoiceWithCorrectTotals(): void
    {
        $customer = new Customer('Max', 'max@example.de', 'DE');
        $items = [
            new LineItem('Widget', 10.00, 2),
            new LineItem('Gadget', 25.00, 1),
        ];

        $this->taxCalculator->method('calculate')
            ->willReturn(8.55);

        $this->pdfGenerator->method('generate')
            ->willReturn('PDF-BYTES');

        $this->repository->expects($this->once())->method('save');
        $this->mailer->expects($this->once())
            ->method('sendWithAttachment')
            ->with(
                'max@example.de',
                $this->stringContains('Rechnung'),
                'PDF-BYTES'
            );

        $invoice = $this->service->createAndSendInvoice($customer, $items);

        $this->assertSame(45.00, $invoice->getNetTotal());
        $this->assertSame(8.55, $invoice->getTax());
    }
}

Prinzip: Man testet Verhalten, nicht Implementierung. Der Test prüft, WAS der Service tut (korrekte Totale, E-Mail wird gesendet), nicht WIE er es tut.


9. Datenbank-Tests

Datenbank-Tests sind Integrationstests: Man testet, ob Queries und Doctrine-/Repository-Code korrekt mit einer echten Datenbank arbeiten.

Strategie: Test-Datenbank

Man braucht eine separate Test-Datenbank. Niemals die Produktions- oder Entwicklungsdatenbank für Tests nutzen!

# .env.test
DATABASE_URL="mysql://root:root@127.0.0.1:3306/myapp_test"

# Datenbank erstellen und Schema anlegen
php bin/console doctrine:database:create --env=test
php bin/console doctrine:schema:create --env=test

Basis-Klasse für Datenbank-Tests

Eine abstrakte Basisklasse mit Transaktions-Rollback sorgt dafür, dass die Datenbank nach jedem Test wieder sauber ist:

<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

abstract class DatabaseTestCase extends KernelTestCase
{
    protected EntityManagerInterface $em;

    protected function setUp(): void
    {
        self::bootKernel();

        $this->em = static::getContainer()
            ->get(EntityManagerInterface::class);

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

    protected function tearDown(): void
    {
        if ($this->em->getConnection()->isTransactionActive()) {
            $this->em->rollback();
        }

        $this->em->close();
        parent::tearDown();
    }
}

Repository-Test Beispiel

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Repository;

use App\Entity\Product;
use App\Repository\ProductRepository;
use App\Tests\Integration\DatabaseTestCase;
use PHPUnit\Framework\Attributes\Test;

class ProductRepositoryTest extends DatabaseTestCase
{
    private ProductRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = static::getContainer()
            ->get(ProductRepository::class);
    }

    #[Test]
    public function findsProductBySlug(): void
    {
        $product = new Product();
        $product->setName('Test-Widget');
        $product->setSlug('test-widget');
        $product->setPrice(29.99);

        $this->em->persist($product);
        $this->em->flush();

        $found = $this->repository->findBySlug('test-widget');

        $this->assertNotNull($found);
        $this->assertSame('Test-Widget', $found->getName());
        $this->assertSame(29.99, $found->getPrice());
    }

    #[Test]
    public function returnsNullForUnknownSlug(): void
    {
        $found = $this->repository->findBySlug('gibt-es-nicht');
        $this->assertNull($found);
    }
}

Fixtures mit Foundry

Statt Testdaten manuell anzulegen, kann man zenstruck/foundry nutzen — der Quasi-Standard für Test-Fixtures in Symfony:

composer require --dev zenstruck/foundry
php bin/console make:factory Product
// Im Test nutzen:
#[Test]
public function findsOnlyActiveProducts(): void
{
    ProductFactory::createMany(3);
    ProductFactory::createOne(['active' => false]);

    $active = $this->repository->findAllActive();
    $this->assertCount(3, $active);
}

Tipp: Der Transaktions-Rollback in tearDown() ist der schnellste Weg, die Datenbank nach jedem Test zu säubern. Keine TRUNCATE-Befehle, keine Fixtures neu laden — einfach Rollback.


10. API-Mocking — Pseudo-Endpoints erstellen

Wenn die Anwendung externe APIs aufruft (z.B. Google Maps, Stripe, OpenAI), will man in Tests NICHT die echte API ansprechen. Gründe: Kosten, Geschwindigkeit, Zuverlässigkeit und Reproduzierbarkeit.

Symfonys MockHttpClient

Symfony bietet einen eingebauten MockHttpClient, der HTTP-Responses simuliert:

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;

class GeocodingService
{
    public function __construct(
        private HttpClientInterface $httpClient,
        private string $googleApiKey,
    ) {}

    public function geocode(string $address): array
    {
        $response = $this->httpClient->request('GET',
            'https://maps.googleapis.com/maps/api/geocode/json', [
                'query' => [
                    'address' => $address,
                    'key' => $this->googleApiKey,
                ],
            ]
        );

        $data = $response->toArray();

        if ($data['status'] !== 'OK' || empty($data['results'])) {
            throw new GeocodingException(
                'Geocoding fehlgeschlagen: ' . ($data['status'] ?? 'unbekannt')
            );
        }

        $location = $data['results'][0]['geometry']['location'];

        return [
            'lat' => $location['lat'],
            'lng' => $location['lng'],
        ];
    }
}

Der Test mit MockHttpClient

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service;

use App\Service\GeocodingService;
use App\Exception\GeocodingException;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

class GeocodingServiceTest extends TestCase
{
    #[Test]
    public function geocodesAddressSuccessfully(): void
    {
        $googleResponse = json_encode([
            'status' => 'OK',
            'results' => [[
                'geometry' => [
                    'location' => ['lat' => 52.5200, 'lng' => 13.4050],
                ],
            ]],
        ]);

        $mockClient = new MockHttpClient([
            new MockResponse($googleResponse, [
                'http_code' => 200,
                'response_headers' => ['content-type' => 'application/json'],
            ]),
        ]);

        $service = new GeocodingService($mockClient, 'fake-api-key');
        $result = $service->geocode('Berlin, Deutschland');

        $this->assertEqualsWithDelta(52.52, $result['lat'], 0.01);
        $this->assertEqualsWithDelta(13.405, $result['lng'], 0.01);
    }

    #[Test]
    public function throwsExceptionForInvalidAddress(): void
    {
        $mockClient = new MockHttpClient([
            new MockResponse(json_encode([
                'status' => 'ZERO_RESULTS',
                'results' => [],
            ])),
        ]);

        $service = new GeocodingService($mockClient, 'fake-api-key');

        $this->expectException(GeocodingException::class);
        $service->geocode('xyzzy gibberish');
    }
}

Callback-basierter MockHttpClient

Für dynamischere Szenarien kann man einen Callback nutzen, der die Anfrage untersucht:

$mockClient = new MockHttpClient(function (string $method, string $url) {
    if (str_contains($url, '/geocode/')) {
        return new MockResponse(json_encode([
            'status' => 'OK',
            'results' => [['geometry' => ['location' => ['lat' => 52.52, 'lng' => 13.40]]]],
        ]));
    }

    return new MockResponse('Not Found', ['http_code' => 404]);
});

Response-Fixtures aus Dateien laden

Für große API-Antworten speichert man die JSON-Responses als Dateien:

$json = file_get_contents(__DIR__ . '/../../Fixtures/google_geocode_berlin.json');
$mockClient = new MockHttpClient([
    new MockResponse($json, ['http_code' => 200]),
]);

11. Microservices testen

Wenn die Architektur aus mehreren Microservices besteht, die per HTTP oder Message Queue kommunizieren, steht man vor besonderen Test-Herausforderungen.

Service-Clients isoliert testen

Jeder Microservice hat typischerweise einen Client, der die Kommunikation kapselt:

<?php

declare(strict_types=1);

class UserServiceClient
{
    public function __construct(
        private HttpClientInterface $client,
        private string $baseUrl,
    ) {}

    public function getUser(int $id): UserDto
    {
        $response = $this->client->request('GET',
            $this->baseUrl . '/api/users/' . $id
        );

        if ($response->getStatusCode() === 404) {
            throw new UserNotFoundException($id);
        }

        $data = $response->toArray();

        return new UserDto(
            id: $data['id'],
            name: $data['name'],
            email: $data['email'],
        );
    }
}
#[Test]
public function getsUserById(): void
{
    $mockClient = new MockHttpClient([
        new MockResponse(json_encode([
            'id' => 1,
            'name' => 'Max Mustermann',
            'email' => 'max@example.de',
        ]), ['http_code' => 200]),
    ]);

    $client = new UserServiceClient($mockClient, 'http://fake-url');
    $user = $client->getUser(1);

    $this->assertSame('Max Mustermann', $user->name);
    $this->assertSame('max@example.de', $user->email);
}

Symfony Messenger (asynchrone Kommunikation)

Wenn Microservices über Symfony Messenger kommunizieren, kann man den InMemoryTransport nutzen:

# config/packages/test/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: 'in-memory://'
<?php

declare(strict_types=1);

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;

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

        $orderService = $container->get(OrderService::class);
        $orderService->placeOrder(new OrderDto(/* ... */));

        /** @var InMemoryTransport $transport */
        $transport = $container->get('messenger.transport.async');
        $messages = $transport->getSent();

        $this->assertCount(1, $messages);
        $this->assertInstanceOf(
            ProcessPaymentMessage::class,
            $messages[0]->getMessage()
        );
    }
}

Contract-Tests

Contract-Tests stellen sicher, dass zwei Services sich über das Format der Kommunikation einig sind:

#[Test]
public function userServiceResponseMatchesExpectedContract(): void
{
    $expectedSchema = [
        'id' => 'integer',
        'name' => 'string',
        'email' => 'string',
        'created_at' => 'string',
    ];

    $response = json_decode(
        file_get_contents(__DIR__ . '/../../Fixtures/user_response.json'),
        true
    );

    foreach ($expectedSchema as $field => $type) {
        $this->assertArrayHasKey($field, $response);
        $this->assertSame($type, gettype($response[$field]),
            "Feld '$field' hat falschen Typ"
        );
    }
}

Microservice-Testing-Strategie: Unit Tests für Client-Klassen mit MockHttpClient, Integrationstests für Messenger-Nachrichten, Contract-Tests für API-Verträge, und nur wenige E2E-Tests, die alle Services zusammen testen.


12. Symfony-Integration im Detail

Symfony bringt eigene Testklassen mit, die auf PHPUnit aufbauen und Zugang zum Service Container, HTTP-Client und Crawler geben.

Die drei Symfony-Testklassen

Klasse Erbt von Nutzen
KernelTestCase TestCase Zugang zum Service Container, für Service-/Repository-Tests
WebTestCase KernelTestCase HTTP-Client zum Testen von Controllern, Formularen, Redirects
ApiTestCase * WebTestCase Speziell für JSON/REST-APIs (API Platform)

* ApiTestCase kommt von API Platform, nicht von Symfony Core.

KernelTestCase für Service-Tests

<?php

declare(strict_types=1);

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class BlogServiceTest extends KernelTestCase
{
    public function testGetPostsReturnsSortedResults(): void
    {
        self::bootKernel();
        $service = static::getContainer()->get(BlogService::class);

        $posts = $service->getPosts();

        $this->assertNotEmpty($posts);
        for ($i = 1; $i < count($posts); $i++) {
            $this->assertGreaterThanOrEqual(
                strtotime($posts[$i]['date']),
                strtotime($posts[$i - 1]['date'])
            );
        }
    }
}

WebTestCase für Controller-Tests

<?php

declare(strict_types=1);

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

class ProductControllerTest extends WebTestCase
{
    #[Test]
    public function productListPageReturns200(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/products');

        $this->assertResponseIsSuccessful();
        $this->assertSelectorTextContains('h1', 'Produkte');
        $this->assertSelectorExists('.product-card');
    }

    #[Test]
    public function contactFormSubmission(): void
    {
        $client = static::createClient();
        $crawler = $client->request('GET', '/contact');

        $form = $crawler->selectButton('Absenden')->form([
            'contact[name]' => 'Max Mustermann',
            'contact[email]' => 'max@example.de',
            'contact[message]' => 'Testanfrage',
        ]);
        $client->submit($form);

        $this->assertResponseRedirects('/contact/success');
        $client->followRedirect();
        $this->assertSelectorTextContains('.alert-success', 'Danke');
    }

    #[Test]
    public function adminAreaRequiresAuthentication(): void
    {
        $client = static::createClient();
        $client->request('GET', '/admin/dashboard');

        $this->assertResponseRedirects('/login');
    }
}

API-Tests (JSON-Responses)

<?php

declare(strict_types=1);

class ProductApiTest extends WebTestCase
{
    #[Test]
    public function apiReturnsProductList(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/products', [], [], [
            'HTTP_ACCEPT' => 'application/json',
        ]);

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

        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertIsArray($data);
        $this->assertNotEmpty($data);
    }
}

Authentifizierung in Tests

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

    $userRepository = static::getContainer()
        ->get(UserRepository::class);
    $admin = $userRepository->findOneByEmail('admin@example.de');

    $client->loginUser($admin);

    $client->request('GET', '/admin/dashboard');
    $this->assertResponseIsSuccessful();
}

Services im Test überschreiben

Manchmal will man in einem Funktionaltest einen Service durch einen Fake ersetzen:

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

    $mockPayment = $this->createStub(PaymentGateway::class);
    $mockPayment->method('charge')->willReturn(true);

    // WICHTIG: services_test.yaml muss den Service als public markieren
    static::getContainer()->set(PaymentGateway::class, $mockPayment);

    $client->request('POST', '/checkout/pay', /* ... */);
    $this->assertResponseIsSuccessful();
}
# config/services_test.yaml
services:
    App\Service\PaymentGateway:
        public: true

13. Code Coverage und Qualitätsmetriken

Code Coverage einrichten

Code Coverage misst, welcher Prozentsatz des Codes durch Tests ausgeführt wird. Man braucht dafür entweder Xdebug (langsamer, aber auch zum Debuggen nutzbar) oder PCOV (schneller, nur für Coverage):

# PCOV installieren (empfohlen für CI)
pecl install pcov
echo 'extension=pcov.so' >> php.ini

# Coverage als HTML-Report generieren
vendor/bin/phpunit --coverage-html coverage/

# Coverage als Text in der Konsole
vendor/bin/phpunit --coverage-text

# Clover-Format für CI/CD-Tools
vendor/bin/phpunit --coverage-clover coverage.xml

Coverage-Attribute

Mit Attributen definiert man, welchen Code ein Test abdecken soll:

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use PHPUnit\Framework\Attributes\CoversNothing;

// Deckt die gesamte Klasse ab
#[CoversClass(Calculator::class)]
class CalculatorTest extends TestCase { /* ... */ }

// Deckt nur eine bestimmte Methode ab
#[CoversMethod(Calculator::class, 'add')]
public function addsNumbers(): void { /* ... */ }

// Für Integrationstests, die keinen Code direkt abdecken
#[CoversNothing]
class SmokeTest extends WebTestCase { /* ... */ }

Was ist eine gute Coverage?

Coverage Bewertung Empfehlung
< 30% Kritisch Dringend Tests für Kernlogik schreiben
30-60% Ausbaufähig Fokus auf Geschäftslogik und kritische Pfade
60-80% Gut Realistisches Ziel für die meisten Projekte
80-90% Sehr gut Ideal, aber nicht um jeden Preis
> 90% Exzellent Oft nur sinnvoll für Libraries/kritische Module

100% ist nicht das Ziel! Eine 100%-Coverage bedeutet nicht, dass der Code fehlerfrei ist. Man sollte lieber die wichtigen Szenarien gründlich testen, als jede Getter-Methode abzudecken.


14. CI/CD-Integration

Tests entfalten ihren vollen Wert erst, wenn sie automatisch bei jedem Commit laufen. Hier die Konfigurationen für die gängigsten CI-Systeme.

GitHub Actions

# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]

jobs:
  phpunit:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: myapp_test
        ports:
          - 3306:3306
        options: --health-cmd='mysqladmin ping' --health-interval=10s
    strategy:
      matrix:
        php: ['8.3', '8.4']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: pcov
      - run: composer install --no-interaction
      - run: php bin/console doctrine:schema:create --env=test
      - run: vendor/bin/phpunit --coverage-clover coverage.xml
      - uses: codecov/codecov-action@v3
        with:
          file: coverage.xml

GitLab CI

# .gitlab-ci.yml
phpunit:
  stage: test
  image: php:8.4-cli
  services:
    - mysql:8.0
  variables:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: myapp_test
    DATABASE_URL: 'mysql://root:root@mysql:3306/myapp_test'
  before_script:
    - apt-get update && apt-get install -y git unzip libzip-dev
    - docker-php-ext-install zip pdo pdo_mysql
    - curl -sS https://getcomposer.org/installer | php
    - php composer.phar install --no-interaction
  script:
    - vendor/bin/phpunit --testsuite Unit
    - vendor/bin/phpunit --testsuite Integration --coverage-text
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

Bitbucket Pipelines

image: php:8.2-cli

definitions:
  caches:
    composer: vendor

pipelines:
  default:
    - step:
        name: Tests & Quality
        caches:
          - composer
        script:
          - apt-get update && apt-get install -y git unzip libicu-dev
          - docker-php-ext-install intl pdo_mysql
          - curl -sS https://getcomposer.org/installer | php
          - php composer.phar install --prefer-dist --no-progress
          - php composer.phar audit
          - vendor/bin/phpunit --coverage-text --colors=never
        services:
          - mysql

  pull-requests:
    '**':
      - step:
          name: PR Checks
          caches:
            - composer
          script:
            - apt-get update && apt-get install -y git unzip libicu-dev
            - docker-php-ext-install intl pdo_mysql
            - curl -sS https://getcomposer.org/installer | php
            - php composer.phar install --prefer-dist --no-progress
            - vendor/bin/phpunit --testsuite=Unit

definitions:
  services:
    mysql:
      image: mariadb:10.11
      variables:
        MYSQL_ROOT_PASSWORD: root
        MYSQL_DATABASE: app_test

Pipeline-Strategien

  • Default-Branch: Voller Testlauf mit Coverage + Security-Audit
  • Pull Requests: Nur Unit-Tests für schnelles Feedback
  • Caching: Composer-Dependencies werden gecacht, spart 30-60 Sekunden pro Build
  • Services: MariaDB/MySQL als Service-Container für Integrationstests

Parallelisierung

Für große Test-Suiten kann man Tests parallelisieren:

# Mit Paratest (mehrere PHP-Prozesse gleichzeitig)
composer require --dev brianium/paratest

# 4 parallele Prozesse
vendor/bin/paratest -p 4 --testsuite Unit

15. Best Practices und Projektstruktur

Namenskonventionen

  • Testklasse: Name der getesteten Klasse + Test — z.B. CalculatorTest
  • Testmethode: Beschreibt das erwartete Verhalten: addsPositiveNumbers(), throwsExceptionForNegativePrice(), sendsEmailOnCompletion()
  • Verzeichnisse: Spiegeln die src-Struktur — src/Service/Calculator.php wird zu tests/Unit/Service/CalculatorTest.php

Die wichtigsten Regeln

  1. Ein Test, ein Szenario: Jeder Test prüft genau EINE Sache. Nicht drei Assertions für drei verschiedene Szenarien in einem Test.
  2. Tests müssen isoliert sein: Kein Test darf von einem anderen abhängen. Die Reihenfolge darf keine Rolle spielen.
  3. Keine Logik im Test: Tests sollen einfach und linear sein. Keine if-else, keine Schleifen (außer in Data Providers).
  4. Gegen Interfaces programmieren: Wenn ein Service TaxServiceInterface statt TaxService erwartet, kann man ihn trivial mocken.
  5. Testdaten sind explizit: Man schreibt die Werte direkt in den Test. Der Test soll für sich allein verständlich sein.
  6. setUp() minimieren: Nur das rein, was wirklich ALLE Tests der Klasse brauchen.

Was (nicht) testen?

Testen NICHT testen
Geschäftslogik in Services Getter und Setter ohne Logik
Validierungsregeln Framework-Code (Symfony selbst)
Grenzfälle (null, leere Arrays, 0) Triviale Konstruktoren
Fehlerbehandlung (Exceptions) Drittanbieter-Libraries
Repository-Queries (Integration) Private Methoden direkt
API-Verträge Konfigurationsdateien

Composer-Scripts einrichten

Man fügt Shortcuts in die composer.json ein, damit das Team einheitliche Befehle nutzt:

{
    "scripts": {
        "test": "vendor/bin/phpunit",
        "test:unit": "vendor/bin/phpunit --testsuite Unit",
        "test:integration": "vendor/bin/phpunit --testsuite Integration",
        "test:coverage": "vendor/bin/phpunit --coverage-html coverage/",
        "test:fast": "vendor/bin/phpunit --stop-on-failure --testsuite Unit"
    }
}
# Dann einfach:
composer test
composer test:unit
composer test:coverage

16. Anhang: Wichtige Attribute (PHPUnit 12+)

Seit PHPUnit 12 sind PHP-Attribute der einzige Weg, Metadaten an Tests zu binden. Hier die vollständige Referenz:

Attribut Ersetzt Annotation Beschreibung
#[Test] @test Markiert eine Methode als Test
#[DataProvider('name')] @dataProvider Verknüpft mit Data Provider
#[Depends('testName')] @depends Test hängt von anderem Test ab
#[CoversClass(X::class)] @covers Test deckt Klasse X ab
#[CoversMethod(X::class, 'y')] @covers X::y Test deckt Methode ab
#[CoversNothing] @coversNothing Kein Coverage-Tracking
#[Group('slow')] @group Gruppiert Tests (zum Filtern)
#[RequiresPhp('>=8.4')] @requires PHP Überspringt bei falschem PHP
#[RequiresPhpExtension('redis')] @requires ext Überspringt wenn Extension fehlt
#[BackupGlobals(true)] @backupGlobals Sichert globale Variablen
#[RunInSeparateProcess] @runInSep... Läuft in eigenem PHP-Prozess

Dieses Handbuch hat alle wichtigen Konzepte von PHPUnit behandelt — vom einfachen Unit Test über Datenbank-Tests und API-Mocking bis zur Symfony-Integration und CI/CD-Pipelines. Der wichtigste Tipp zum Schluss: Klein anfangen! Man schreibt für das nächste Feature erst den Test, dann den Code (Test-Driven Development). Nach wenigen Wochen wird es zur Gewohnheit.

Thomas Wunner

Thomas Wunner

Fachinformatiker für Anwendungsentwicklung mit Ausbildereignungsprüfung und über 14 Jahre Erfahrung im Aufbau skalierbarer Webanwendungen mit Symfony und Shopware. Abseits der Tastatur ist Thomas als Rettungsschwimmer in der Wasserwacht aktiv, legt als DJ auf und erkundet die Umgebung auf dem Motorrad.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.