Wer eine API betreibt, kennt das Grundproblem: Der Endpoint funktioniert lokal, die Unit-Tests sind grün, das Deployment läuft durch — und trotzdem liefert die Produktion einen 500er. Die Ursache ist fast immer dieselbe: Es wurde nur eine Art von Test geschrieben.
API-Tests sind kein monolithisches Konzept. Es gibt mindestens acht verschiedene Methoden, die jeweils ein anderes Risiko abdecken. Wer nur Unit-Tests schreibt, findet keine Integrationsfehler. Wer nur Integrationstests hat, entdeckt keine Performanceprobleme unter Last. Und wer nie mit ungültigen Eingaben testet, lernt die Schwachstellen seiner API erst vom Angreifer kennen.
| Testtyp | Kernfrage | Findet |
|---|---|---|
| Smoke Test | Läuft die API überhaupt? | Totalausfälle, kaputte Deployments |
| Unit Test | Funktioniert die Logik isoliert? | Rechenfehler, Validierungslücken |
| Integration Test | Spielen die Komponenten zusammen? | Routing-, Mapping-, Serialisierungsfehler |
| Contract Test | Hält die API ihr Schema ein? | Breaking Changes an der Schnittstelle |
| E2E Test | Funktioniert der ganze Workflow? | Fehler im Zusammenspiel ganzer Abläufe |
| Load Test | Hält die API unter Last? | N+1 Queries, Connection-Limits, Memory Leaks |
| Security Test | Ist die API gegen Angriffe geschützt? | BOLA, Injection, fehlende Rate Limits |
| Fuzz Test | Überlebt die API beliebige Eingaben? | Unbehandelte Exceptions, Crashes bei Zufallsdaten |
| Snapshot Test | Hat sich die Response-Struktur geändert? | Unbeabsichtigte Schema-Änderungen |
| Regression Test | Ist der Bug wirklich behoben? | Wiederkehrende, bereits behobene Fehler |
Smoke Test
Ein Smoke Test beantwortet genau eine Frage: Läuft die API überhaupt?
Der Begriff stammt aus der Elektrotechnik. Wenn ein neues Gerät eingeschaltet wird und anfängt zu rauchen, braucht man keine weiteren Tests mehr — das Grundproblem ist offensichtlich. In der Software bedeutet das: Bevor aufwendige Testsuiten laufen, prüft ein Smoke Test die absolute Basisfunktionalität.
Ein typischer API-Smoke-Test:
- Erreiche ich den Health-Endpoint? (
GET /health→ 200) - Antwortet die API überhaupt mit JSON?
- Stimmt die Authentifizierung grundsätzlich? (Token wird akzeptiert)
- Ist die Datenbank erreichbar? (Ein simpler Read-Endpoint funktioniert)
class ApiSmokeTest extends WebTestCase
{
/**
* @dataProvider publicEndpointProvider
*/
public function testPublicEndpointsReturn200(string $url): void
{
$client = static::createClient();
$client->request('GET', $url);
self::assertResponseIsSuccessful(
sprintf('Endpoint %s returned %d', $url, $client->getResponse()->getStatusCode())
);
}
public static function publicEndpointProvider(): \Generator
{
yield 'health' => ['/api/health'];
yield 'product list' => ['/api/products'];
yield 'categories' => ['/api/categories'];
}
}
Der entscheidende Punkt: Smoke Tests prüfen nicht, ob die Antwort inhaltlich korrekt ist. Sie prüfen nur, ob die API antwortet und nicht sofort crasht. Damit sind sie der schnellste Indikator nach einem Deployment. Wenn der Smoke Test fehlschlägt, ist alles andere irrelevant.
Wann sinnvoll: Nach jedem Deployment als erste Prüfung. In CI/CD-Pipelines als Gate vor den aufwendigeren Tests. Als Monitoring in Produktion (Health Checks).
Unit Test
Unit Tests isolieren eine einzelne Klasse oder Methode und testen sie ohne externe Abhängigkeiten. Datenbanken, HTTP-Clients, Dateisysteme und Message Queues werden durch Mocks oder Stubs ersetzt.
Im API-Kontext heißt das: Der Controller wird nicht getestet, sondern die Services dahinter.
class PriceCalculatorTest extends TestCase
{
public function testAppliesDiscountCorrectly(): void
{
$calculator = new PriceCalculator();
$result = $calculator->applyDiscount(
basePrice: 10000,
discountPercent: 15,
);
self::assertSame(8500, $result);
}
public function testRejectsNegativeDiscount(): void
{
$calculator = new PriceCalculator();
$this->expectException(InvalidArgumentException::class);
$calculator->applyDiscount(basePrice: 10000, discountPercent: -5);
}
}
Unit Tests sind schnell (Millisekunden pro Test), deterministisch und laufen offline. Aber sie haben einen blinden Fleck: Sie beweisen, dass die einzelne Komponente korrekt funktioniert — nicht, dass die Komponenten zusammen funktionieren.
Ein PriceCalculator, der korrekt rechnet, hilft wenig, wenn der Controller das Ergebnis falsch serialisiert oder der Discount-Wert aus der Datenbank einen anderen Typ hat als erwartet.
Wann sinnvoll: Für jede Klasse mit Geschäftslogik. Für Validierungsregeln, Berechnungen, Transformationen.
Integration Test
Integration Tests prüfen, ob mehrere Komponenten korrekt zusammenspielen. Im API-Kontext bedeutet das: Ein HTTP-Request wird an die Anwendung geschickt, durchläuft Controller, Service, Repository und Datenbank — und die Response wird geprüft.
class ProductApiTest extends WebTestCase
{
public function testCreateProductReturns201(): void
{
$client = static::createClient();
$client->request('POST', '/api/products', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'name' => 'Testprodukt',
'price' => 2999,
'category' => 'electronics',
]));
self::assertResponseStatusCodeSame(201);
$response = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('id', $response);
self::assertSame('Testprodukt', $response['name']);
}
public function testCreateProductWithMissingNameReturns422(): void
{
$client = static::createClient();
$client->request('POST', '/api/products', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'price' => 2999,
]));
self::assertResponseStatusCodeSame(422);
}
}
Der Unterschied zum Unit Test: Hier läuft der echte Symfony-Kernel, die echte Datenbank (typischerweise eine Test-DB) und der echte Router. Damit finden Integration Tests Fehler, die Unit Tests prinzipbedingt nicht finden können:
- Falsche Route-Konfiguration
- Fehlende Service-Definitionen
- Doctrine-Mapping-Fehler
- Serialisierungsprobleme
Wann sinnvoll: Für jeden API-Endpoint mindestens ein Happy-Path-Test und ein Error-Path-Test.
Contract Test
Contract Tests verifizieren, dass eine API ihr Versprechen einhält — die Struktur der Responses, die erwarteten Felder, die Datentypen. Der „Contract" ist dabei die Schnittstellenspezifikation, typischerweise eine OpenAPI/Swagger-Definition.
Das Problem, das Contract Tests lösen: Ein Integrationtest prüft, ob name in der Response vorhanden ist. Aber was passiert, wenn jemand name in productName umbenennt? Der Integrationtest wird angepasst, die API-Clients der Konsumenten brechen. Niemand hat gemerkt, dass der Contract gebrochen wurde.
class ProductContractTest extends WebTestCase
{
public function testProductResponseMatchesContract(): void
{
$client = static::createClient();
$client->request('GET', '/api/products/1');
$response = json_decode($client->getResponse()->getContent(), true);
// Strukturelle Validierung — nicht Werte, sondern Schema
self::assertArrayHasKey('id', $response);
self::assertArrayHasKey('name', $response);
self::assertArrayHasKey('price', $response);
self::assertArrayHasKey('currency', $response);
self::assertArrayHasKey('createdAt', $response);
// Typ-Validierung
self::assertIsInt($response['id']);
self::assertIsString($response['name']);
self::assertIsInt($response['price']);
self::assertIsString($response['currency']);
self::assertMatchesRegularExpression(
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/',
$response['createdAt']
);
}
}
Für komplexere Szenarien — etwa wenn mehrere Teams unabhängig APIs konsumieren — gibt es dedizierte Tools wie Pact. Dort definiert der Consumer, welche Felder er erwartet, und der Provider verifiziert bei jedem Build, dass er diesen Contract erfüllt. Bricht der Provider den Contract, schlägt sein Build fehl — bevor der Consumer überhaupt betroffen ist.
Wann sinnvoll: Sobald eine API externe Konsumenten hat. Sobald mehrere Teams gegen dieselbe API entwickeln. Bei öffentlichen APIs ist es Pflicht.
End-to-End Test (E2E)
E2E-Tests simulieren einen vollständigen Benutzerablauf über die API. Nicht einzelne Endpoints, sondern ganze Workflows: Registrierung → Login → Produkt anlegen → Bestellung aufgeben → Bestätigung prüfen.
class OrderWorkflowTest extends WebTestCase
{
public function testCompleteOrderFlow(): void
{
$client = static::createClient();
// 1. Login
$client->request('POST', '/api/login', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'test@example.com',
'password' => 'secret',
]));
self::assertResponseIsSuccessful();
$token = json_decode($client->getResponse()->getContent(), true)['token'];
// 2. Produkt in Warenkorb
$client->request('POST', '/api/cart/items', [], [], [
'CONTENT_TYPE' => 'application/json',
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
], json_encode([
'productId' => 42,
'quantity' => 2,
]));
self::assertResponseStatusCodeSame(201);
// 3. Bestellung abschließen
$client->request('POST', '/api/orders', [], [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
self::assertResponseStatusCodeSame(201);
$order = json_decode($client->getResponse()->getContent(), true);
self::assertSame('confirmed', $order['status']);
self::assertCount(1, $order['items']);
}
}
E2E-Tests sind die teuersten in der Pyramide: langsam, fragil bei UI-Änderungen und aufwendig zu warten. Aber sie finden eine Klasse von Fehlern, die kein anderer Testtyp findet — etwa dass der Warenkorb nach dem Login nicht korrekt initialisiert wird oder dass die Bestellung durch eine Race Condition doppelt angelegt wird.
Wann sinnvoll: Für kritische Business-Flows (Checkout, Registrierung, Bezahlung). Sparsam einsetzen — wenige, aber die wichtigsten Pfade.
Load Test / Performance Test
Load Tests beantworten die Frage: Wie verhält sich die API unter realistischer Last? Ein Endpoint, der mit einem Request einwandfrei funktioniert, kann bei 500 gleichzeitigen Requests in die Knie gehen.
Das gängigste Tool ist k6 (von Grafana Labs), das Testszenarien in JavaScript definiert:
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // Hochfahren auf 50 VUs
{ duration: '1m', target: 50 }, // 1 Minute halten
{ duration: '10s', target: 0 }, // Herunterfahren
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% der Requests unter 500ms
http_req_failed: ['rate<0.01'], // Weniger als 1% Fehler
},
};
export default function ()
{
const res = http.get('https://api.example.com/products');
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
sleep(1);
}
Was Load Tests aufdecken:
| Problem | Symptom |
|---|---|
| N+1 Queries | Response-Zeit steigt linear mit Datenmenge |
| Fehlende Indizes | Queries werden bei Last exponentiell langsamer |
| Connection Pool erschöpft | Plötzliche Timeouts ab Schwellwert |
| Memory Leaks | Steigende Speichernutzung über die Testdauer |
| Fehlende Rate Limits | API akzeptiert unbegrenzt Requests |
Andere Tools im Ökosystem: JMeter (GUI-basiert, Java), Gatling (Scala), Locust (Python). k6 hat sich als Standard etabliert, weil es als CLI-Tool in CI/CD-Pipelines läuft und die Ergebnisse direkt als Metriken exportiert.
Wann sinnvoll: Vor jedem Production Release mit erwartetem Traffic. Nach Datenbankänderungen. Regelmäßig als Teil der CI-Pipeline (mit reduzierter Last).
Security Test
Security Tests prüfen, ob die API gegen gängige Angriffsmuster geschützt ist. Die OWASP API Security Top 10 sind der Referenzrahmen.
Drei Kategorien fallen in der Praxis am häufigsten auf:
1. Authorization Testing
Kann ein Benutzer auf Ressourcen zugreifen, die ihm nicht gehören? Das ist Broken Object Level Authorization (BOLA) — die häufigste API-Schwachstelle.
class AuthorizationSecurityTest extends WebTestCase
{
public function testUserCannotAccessOtherUsersOrders(): void
{
$client = static::createClient();
// Login als User A
$this->loginAs($client, 'user-a@example.com');
// Versuch, Order von User B abzurufen
$client->request('GET', '/api/orders/999');
self::assertResponseStatusCodeSame(403);
}
public function testDeletedTokenCannotBeReused(): void
{
$client = static::createClient();
$token = $this->obtainToken($client, 'user@example.com');
// Logout (Token invalidieren)
$client->request('POST', '/api/logout', [], [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
// Versuch, invalidierten Token wiederzuverwenden
$client->request('GET', '/api/profile', [], [], [
'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
]);
self::assertResponseStatusCodeSame(401);
}
}
2. Input Validation Testing
SQL Injection, NoSQL Injection, Command Injection — jeder Eingabevektor muss getestet werden.
/**
* @dataProvider maliciousInputProvider
*/
public function testEndpointRejectsMaliciousInput(string $field, string $payload): void
{
$client = static::createClient();
$client->request('POST', '/api/products', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([$field => $payload]));
// Darf nicht 200 oder 500 sein — 400/422 ist korrekt
$status = $client->getResponse()->getStatusCode();
self::assertContains($status, [400, 422],
sprintf('Payload "%s" in field "%s" should be rejected', $payload, $field)
);
}
public static function maliciousInputProvider(): \Generator
{
yield 'SQL injection' => ['name', "'; DROP TABLE products; --"];
yield 'XSS script tag' => ['name', '<script>alert("xss")</script>'];
yield 'Command injection' => ['name', '$(rm -rf /)'];
yield 'Oversized input' => ['name', str_repeat('A', 100000)];
yield 'Null byte' => ['name', "test\x00injection"];
}
3. Rate Limiting
public function testRateLimitBlocksExcessiveRequests(): void
{
$client = static::createClient();
for ($i = 0; $i < 120; $i++) {
$client->request('POST', '/api/login', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'test@example.com',
'password' => 'wrong-password',
]));
}
self::assertResponseStatusCodeSame(429);
}
Für automatisiertes Security Testing gibt es Tools wie OWASP ZAP (Proxy-basiert, findet Schwachstellen automatisch) und Burp Suite (kommerziell, mächtiger).
Wann sinnvoll: Vor jedem Release. Für jeden Endpoint mit Authentifizierung. Für jedes Eingabefeld, das Nutzerdaten akzeptiert.
Fuzz Test
Fuzz Testing bombardiert die API mit zufälligen, unerwarteten und absichtlich fehlerhaften Eingaben. Das Ziel: Fehler finden, die kein Entwickler bewusst testen würde, weil niemand auf die Idee käme, diese Eingabe zu schicken.
class ProductApiFuzzTest extends WebTestCase
{
/**
* @dataProvider fuzzPayloadProvider
*/
public function testEndpointHandlesUnexpectedInput(mixed $payload): void
{
$client = static::createClient();
$client->request('POST', '/api/products', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($payload));
$status = $client->getResponse()->getStatusCode();
// Akzeptabel: 400, 422 (Validierungsfehler)
// Inakzeptabel: 500 (unbehandelter Fehler)
self::assertNotSame(
500,
$status,
sprintf('Endpoint returned 500 for payload: %s', json_encode($payload))
);
}
public static function fuzzPayloadProvider(): \Generator
{
// Leere Werte
yield 'null' => [null];
yield 'empty object' => [[]];
yield 'empty string' => [''];
// Falsche Typen
yield 'string instead of int' => [['price' => 'not-a-number']];
yield 'array instead of string' => [['name' => ['nested', 'array']]];
yield 'boolean instead of string' => [['name' => true]];
yield 'float instead of int' => [['price' => 29.99]];
// Grenzwerte
yield 'negative price' => [['name' => 'Test', 'price' => -1]];
yield 'zero price' => [['name' => 'Test', 'price' => 0]];
yield 'max int' => [['name' => 'Test', 'price' => PHP_INT_MAX]];
yield 'very long string' => [['name' => str_repeat('X', 65536)]];
// Unicode-Spezialfälle
yield 'emoji name' => [['name' => '🚀🔥💻', 'price' => 100]];
yield 'RTL text' => [['name' => 'مرحبا', 'price' => 100]];
yield 'zero-width chars' => [['name' => "Test\u{200B}\u{200C}\u{200D}", 'price' => 100]];
// Unerwartete Felder
yield 'extra fields' => [['name' => 'Test', 'price' => 100, 'admin' => true, 'role' => 'superadmin']];
yield 'nested injection' => [['name' => 'Test', '__proto__' => ['admin' => true]]];
}
}
Die Kernregel beim Fuzz Testing: Kein Input darf einen 500er erzeugen. Die API darf jede Eingabe ablehnen (400, 422), aber sie darf niemals unkontrolliert abstürzen. Ein 500er bedeutet: Es gibt einen Code-Pfad, der nicht validiert — und genau diese Pfade nutzen Angreifer.
Dedizierte Fuzzing-Tools wie RESTler (von Microsoft Research) generieren automatisch Tausende von Requests basierend auf der OpenAPI-Spezifikation und finden Kombinationen, die kein Mensch von Hand testen würde.
Wann sinnvoll: Bei APIs mit öffentlichem Zugang. Bei Endpoints, die komplexe Eingaben akzeptieren (JSON-Objekte, Datei-Uploads, verschachtelte Strukturen).
Snapshot Test
Snapshot Tests speichern die API-Response als Referenzdatei und vergleichen bei jedem Testlauf die aktuelle Response mit dem gespeicherten Snapshot. Jede Abweichung erzeugt einen Testfehler.
class ProductSnapshotTest extends WebTestCase
{
public function testProductListMatchesSnapshot(): void
{
$client = static::createClient();
$client->request('GET', '/api/products?limit=3');
$response = $client->getResponse()->getContent();
$snapshotFile = __DIR__ . '/snapshots/product_list.json';
if (!file_exists($snapshotFile)) {
// Erster Lauf: Snapshot erstellen
file_put_contents($snapshotFile, $response);
self::markTestIncomplete('Snapshot created — re-run to verify.');
return;
}
$expected = file_get_contents($snapshotFile);
// Dynamische Felder vor Vergleich normalisieren
$normalize = function (string $json): array {
$data = json_decode($json, true);
array_walk_recursive($data, function (&$value, string $key): void {
if (in_array($key, ['createdAt', 'updatedAt', 'id'], true)) {
$value = '<<DYNAMIC>>';
}
});
return $data;
};
self::assertSame($normalize($expected), $normalize($response));
}
}
Snapshot Tests haben einen spezifischen Sweet Spot: Sie finden unbeabsichtigte Änderungen an der API-Struktur. Wenn jemand ein Feld umbenennt, ein Feld entfernt oder die Verschachtelung ändert, schlägt der Snapshot-Test an. Das macht sie zum Sicherheitsnetz gegen versehentliche Breaking Changes.
Die Kehrseite: Dynamische Felder (Timestamps, IDs, generierte Token) müssen vor dem Vergleich normalisiert werden. Und wenn sich die API absichtlich ändert, müssen die Snapshots aktualisiert werden — das kann bei vielen Endpoints Pflege bedeuten.
Wann sinnvoll: Für APIs mit stabilem Schema, bei denen Breaking Changes verhindert werden sollen. Besonders nützlich bei automatisch generierten Responses (Serializer-Output).
Regression Test
Regression Tests sind keine eigene Methode, sondern ein Zweck: Sie stellen sicher, dass ein behobener Bug nicht wieder auftaucht. Jeder Bug-Fix bekommt einen Test, der genau das fehlerhafte Verhalten reproduziert.
/**
* Regression: Preisberechnung gab bei 33% Discount auf 100€ den Wert 66€
* statt 67€ zurück (Rundungsfehler durch float-Division).
*
* @see https://github.com/example/project/issues/247
*/
public function testDiscountRoundingIssue247(): void
{
$calculator = new PriceCalculator();
$result = $calculator->applyDiscount(
basePrice: 10000,
discountPercent: 33,
);
// Muss 6700 sein (67,00 €), nicht 6600
self::assertSame(6700, $result);
}
Der Wert von Regression Tests steigt über die Lebensdauer eines Projekts. Jeder behobene Bug wird zum Testcase, und die Sammlung wächst zu einem Sicherheitsnetz, das genau die Stellen abdeckt, die in der Praxis problematisch waren.
Wann sinnvoll: Bei jedem Bug-Fix. Immer.
Die Testpyramide für APIs
Nicht jede Testmethode verdient gleich viel Aufwand. Die Testpyramide gibt die Richtung vor:
| Testtyp | Anzahl | Geschwindigkeit | Wartungsaufwand |
|---|---|---|---|
| Smoke | 5–15 | unter 1s | minimal |
| Unit | Hunderte | unter 10s | niedrig |
| Integration | 20–100 pro API | 30s–5min | mittel |
| Contract | Pro Endpoint | 10s–1min | mittel |
| E2E | 5–15 | 1–10min | hoch |
| Load | 3–10 Szenarien | 2–15min | niedrig |
| Security | 10–30 | 30s–2min | mittel |
| Fuzz | 50–200 Inputs | 1–5min | niedrig |
| Snapshot | Pro Endpoint | unter 30s | mittel |
| Regression | wächst mit Bugs | variiert | minimal |
Mein Fazit
Ein einzelner Testtyp reicht nie aus. Aber die Kombination aus vier Typen deckt 90% der realen Fehler ab:
- Smoke Tests als Deployment-Gate — scheitern sie, geht nichts live
- Integration Tests für jeden Endpoint — Happy Path und mindestens ein Error Path
- Contract Tests bei APIs mit externen Konsumenten — Schema-Brüche fallen sofort auf
- Fuzz Tests für öffentliche Endpoints — kein Input darf einen 500er erzeugen
Load und Security Tests kommen dazu, wenn die API öffentlich erreichbar ist. E2E-Tests für die zwei bis drei wichtigsten Business-Flows. Regression Tests sowieso bei jedem Bug-Fix.
Der häufigste Fehler: Nur Unit Tests schreiben und sich damit sicher fühlen. Unit Tests beweisen, dass die Zahnräder einzeln funktionieren — nicht, dass die Maschine läuft.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.