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
- Einführung und Grundlagen
- Installation und Konfiguration
- Der erste Unit Test — Schritt für Schritt
- Assertions — Alle Prüfmethoden im Überblick
- Data Providers — Parametrisierte Tests
- Test Doubles: Mocks, Stubs und Fakes
- Exception Testing
- Service-Objekte testen
- Datenbank-Tests
- API-Mocking — Pseudo-Endpoints erstellen
- Microservices testen
- Symfony-Integration im Detail
- Code Coverage und Qualitätsmetriken
- CI/CD-Integration
- Best Practices und Projektstruktur
- 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.phpwird zutests/Unit/Service/CalculatorTest.php
Die wichtigsten Regeln
- Ein Test, ein Szenario: Jeder Test prüft genau EINE Sache. Nicht drei Assertions für drei verschiedene Szenarien in einem Test.
- Tests müssen isoliert sein: Kein Test darf von einem anderen abhängen. Die Reihenfolge darf keine Rolle spielen.
- Keine Logik im Test: Tests sollen einfach und linear sein. Keine if-else, keine Schleifen (außer in Data Providers).
- Gegen Interfaces programmieren: Wenn ein Service
TaxServiceInterfacestattTaxServiceerwartet, kann man ihn trivial mocken. - Testdaten sind explizit: Man schreibt die Werte direkt in den Test. Der Test soll für sich allein verständlich sein.
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.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.