Skip to content

API Testing: From Smoke Tests to Fuzz Testing — All Methods Explained

Published on May 29, 2026 | approx. 7 min read |

Anyone who operates an API knows the core problem: the endpoint works locally, the unit tests are green, the deployment goes through — and yet production returns a 500. The root cause is almost always the same: only one type of test was written.

API tests are not a monolithic concept. There are at least eight distinct methods, each covering a different risk. Writing only unit tests won't catch integration errors. Having only integration tests won't reveal performance problems under load. And never testing with invalid input means learning about your API's vulnerabilities from an attacker.

Test type Core question Catches
Smoke Test Is the API running at all? Total outages, broken deployments
Unit Test Does the logic work in isolation? Calculation errors, validation gaps
Integration Test Do the components work together? Routing, mapping, serialization errors
Contract Test Does the API honor its schema? Breaking changes at the interface
E2E Test Does the full workflow function? Errors in end-to-end flows
Load Test Does the API hold under load? N+1 queries, connection limits, memory leaks
Security Test Is the API protected against attacks? BOLA, injection, missing rate limits
Fuzz Test Does the API survive arbitrary input? Unhandled exceptions, crashes from random data
Snapshot Test Has the response structure changed? Unintended schema changes
Regression Test Is the bug actually fixed? Recurring, previously fixed bugs

Smoke Test

A smoke test answers exactly one question: Is the API running at all?

The term comes from electrical engineering. When a new device is powered on and starts smoking, no further tests are needed — the fundamental problem is obvious. In software, this means: before expensive test suites run, a smoke test verifies absolute baseline functionality.

A typical API smoke test:

  • Can I reach the health endpoint? (GET /health → 200)
  • Does the API respond with JSON at all?
  • Does authentication fundamentally work? (Token is accepted)
  • Is the database reachable? (A simple read endpoint works)
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'];
    }
}

The key point: smoke tests do not verify whether the response is correct in content. They only check whether the API responds and doesn't immediately crash. This makes them the fastest indicator after a deployment. If the smoke test fails, everything else is irrelevant.

When useful: After every deployment as the first check. In CI/CD pipelines as a gate before more expensive tests. As production monitoring (health checks).

Unit Test

Unit tests isolate a single class or method and test it without external dependencies. Databases, HTTP clients, file systems, and message queues are replaced by mocks or stubs.

In the API context, this means: the controller is not tested, but the services behind it.

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 are fast (milliseconds per test), deterministic, and run offline. But they have a blind spot: they prove that a single component works correctly — not that the components work together.

A PriceCalculator that computes correctly is of little help when the controller serializes the result incorrectly or the discount value from the database has a different type than expected.

When useful: For every class containing business logic. For validation rules, calculations, transformations.

Integration Test

Integration tests verify whether multiple components work correctly together. In the API context, this means: an HTTP request is sent to the application, passes through controller, service, repository, and database — and the response is verified.

class ProductApiTest extends WebTestCase
{
    public function testCreateProductReturns201(): void
    {
        $client = static::createClient();
        $client->request('POST', '/api/products', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode([
            'name' => 'Test Product',
            'price' => 2999,
            'category' => 'electronics',
        ]));

        self::assertResponseStatusCodeSame(201);

        $response = json_decode($client->getResponse()->getContent(), true);
        self::assertArrayHasKey('id', $response);
        self::assertSame('Test Product', $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);
    }
}

The difference from unit tests: here the real Symfony kernel runs, the real database (typically a test DB), and the real router. This lets integration tests find errors that unit tests cannot find by design:

  • Incorrect route configuration
  • Missing service definitions
  • Doctrine mapping errors
  • Serialization problems

When useful: For every API endpoint, at least one happy-path test and one error-path test.

Contract Test

Contract tests verify that an API keeps its promise — the response structure, the expected fields, the data types. The "contract" is the interface specification, typically an OpenAPI/Swagger definition.

The problem contract tests solve: an integration test checks whether name exists in the response. But what happens when someone renames name to productName? The integration test gets updated, but the API clients of the consumers break. Nobody noticed the contract was broken.

class ProductContractTest extends WebTestCase
{
    public function testProductResponseMatchesContract(): void
    {
        $client = static::createClient();
        $client->request('GET', '/api/products/1');

        $response = json_decode($client->getResponse()->getContent(), true);

        // Structural validation — not values, but schema
        self::assertArrayHasKey('id', $response);
        self::assertArrayHasKey('name', $response);
        self::assertArrayHasKey('price', $response);
        self::assertArrayHasKey('currency', $response);
        self::assertArrayHasKey('createdAt', $response);

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

For more complex scenarios — such as when multiple teams independently consume APIs — there are dedicated tools like Pact. The consumer defines which fields it expects, and the provider verifies on every build that it fulfills this contract. If the provider breaks the contract, its build fails — before the consumer is even affected.

When useful: Once an API has external consumers. When multiple teams develop against the same API. For public APIs, it's mandatory.

End-to-End Test (E2E)

E2E tests simulate a complete user workflow through the API. Not individual endpoints, but entire flows: registration → login → create product → place order → verify confirmation.

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. Add product to cart
        $client->request('POST', '/api/cart/items', [], [], [
            'CONTENT_TYPE' => 'application/json',
            'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
        ], json_encode([
            'productId' => 42,
            'quantity' => 2,
        ]));
        self::assertResponseStatusCodeSame(201);

        // 3. Complete order
        $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 are the most expensive in the pyramid: slow, fragile when the UI changes, and costly to maintain. But they find a class of bugs no other test type can find — such as the cart not being correctly initialized after login, or an order being created twice due to a race condition.

When useful: For critical business flows (checkout, registration, payment). Use sparingly — few, but covering the most important paths.

Load Test / Performance Test

Load tests answer the question: How does the API behave under realistic load? An endpoint that works flawlessly with one request can buckle under 500 concurrent requests.

The most common tool is k6 (by Grafana Labs), which defines test scenarios in JavaScript:

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    stages: [
        { duration: '30s', target: 50 },   // Ramp up to 50 VUs
        { duration: '1m', target: 50 },     // Hold for 1 minute
        { duration: '10s', target: 0 },     // Ramp down
    ],
    thresholds: {
        http_req_duration: ['p(95)<500'],   // 95% of requests under 500ms
        http_req_failed: ['rate<0.01'],     // Less than 1% errors
    },
};

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

What load tests reveal:

Problem Symptom
N+1 queries Response time increases linearly with data volume
Missing indexes Queries become exponentially slower under load
Connection pool exhausted Sudden timeouts past a threshold
Memory leaks Increasing memory usage over test duration
Missing rate limits API accepts unlimited requests

Other tools in the ecosystem: JMeter (GUI-based, Java), Gatling (Scala), Locust (Python). k6 has become the standard because it runs as a CLI tool in CI/CD pipelines and exports results directly as metrics.

When useful: Before every production release with expected traffic. After database changes. Regularly as part of the CI pipeline (with reduced load).

Security Test

Security tests verify whether the API is protected against common attack patterns. The OWASP API Security Top 10 is the reference framework.

Three categories appear most frequently in practice:

1. Authorization Testing

Can a user access resources that don't belong to them? This is Broken Object Level Authorization (BOLA) — the most common API vulnerability.

class AuthorizationSecurityTest extends WebTestCase
{
    public function testUserCannotAccessOtherUsersOrders(): void
    {
        $client = static::createClient();

        // Login as User A
        $this->loginAs($client, 'user-a@example.com');

        // Attempt to retrieve User B's order
        $client->request('GET', '/api/orders/999');

        self::assertResponseStatusCodeSame(403);
    }

    public function testDeletedTokenCannotBeReused(): void
    {
        $client = static::createClient();
        $token = $this->obtainToken($client, 'user@example.com');

        // Logout (invalidate token)
        $client->request('POST', '/api/logout', [], [], [
            'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
        ]);

        // Attempt to reuse invalidated token
        $client->request('GET', '/api/profile', [], [], [
            'HTTP_AUTHORIZATION' => 'Bearer ' . $token,
        ]);

        self::assertResponseStatusCodeSame(401);
    }
}

2. Input Validation Testing

SQL injection, NoSQL injection, command injection — every input vector must be tested.

/**
 * @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]));

    // Must not be 200 or 500 — 400/422 is correct
    $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);
}

For automated security testing, tools like OWASP ZAP (proxy-based, finds vulnerabilities automatically) and Burp Suite (commercial, more powerful) are available.

When useful: Before every release. For every endpoint with authentication. For every input field accepting user data.

Fuzz Test

Fuzz testing bombards the API with random, unexpected, and deliberately malformed inputs. The goal: find bugs no developer would consciously test for, because nobody would think to send that input.

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

        // Acceptable: 400, 422 (validation error)
        // Unacceptable: 500 (unhandled error)
        self::assertNotSame(
            500,
            $status,
            sprintf('Endpoint returned 500 for payload: %s', json_encode($payload))
        );
    }

    public static function fuzzPayloadProvider(): \Generator
    {
        // Empty values
        yield 'null' => [null];
        yield 'empty object' => [[]];
        yield 'empty string' => [''];

        // Wrong types
        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]];

        // Boundary values
        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 edge cases
        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]];

        // Unexpected fields
        yield 'extra fields' => [['name' => 'Test', 'price' => 100, 'admin' => true, 'role' => 'superadmin']];
        yield 'nested injection' => [['name' => 'Test', '__proto__' => ['admin' => true]]];
    }
}

The core rule in fuzz testing: No input may produce a 500. The API may reject any input (400, 422), but it must never crash in an uncontrolled manner. A 500 means: there's a code path that doesn't validate — and exactly these paths are exploited by attackers.

Dedicated fuzzing tools like RESTler (by Microsoft Research) automatically generate thousands of requests based on the OpenAPI specification and find combinations no human would test manually.

When useful: For APIs with public access. For endpoints accepting complex inputs (JSON objects, file uploads, nested structures).

Snapshot Test

Snapshot tests store the API response as a reference file and compare the current response against the stored snapshot on every test run. Any deviation produces a test failure.

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)) {
            // First run: create snapshot
            file_put_contents($snapshotFile, $response);
            self::markTestIncomplete('Snapshot created — re-run to verify.');
            return;
        }

        $expected = file_get_contents($snapshotFile);

        // Normalize dynamic fields before comparison
        $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 have a specific sweet spot: they catch unintended changes to the API structure. When someone renames a field, removes a field, or changes the nesting, the snapshot test catches it. This makes them a safety net against accidental breaking changes.

The downside: dynamic fields (timestamps, IDs, generated tokens) must be normalized before comparison. And when the API changes intentionally, the snapshots need updating — which can mean maintenance effort across many endpoints.

When useful: For APIs with a stable schema where breaking changes must be prevented. Especially useful for automatically generated responses (serializer output).

Regression Test

Regression tests are not a standalone method, but a purpose: they ensure a fixed bug doesn't resurface. Every bug fix gets a test that reproduces exactly the faulty behavior.

/**
 * Regression: Price calculation returned 66€ for 33% discount on 100€
 * instead of 67€ (rounding error from float division).
 *
 * @see https://github.com/example/project/issues/247
 */
public function testDiscountRoundingIssue247(): void
{
    $calculator = new PriceCalculator();

    $result = $calculator->applyDiscount(
        basePrice: 10000,
        discountPercent: 33,
    );

    // Must be 6700 (67.00€), not 6600
    self::assertSame(6700, $result);
}

The value of regression tests grows over the lifetime of a project. Every fixed bug becomes a test case, and the collection grows into a safety net covering exactly the spots that proved problematic in practice.

When useful: With every bug fix. Always.

The Test Pyramid for APIs

Not every test method deserves equal effort. The test pyramid provides direction:

Test type Count Speed Maintenance effort
Smoke 5–15 under 1s minimal
Unit hundreds under 10s low
Integration 20–100 per API 30s–5min medium
Contract per endpoint 10s–1min medium
E2E 5–15 1–10min high
Load 3–10 scenarios 2–15min low
Security 10–30 30s–2min medium
Fuzz 50–200 inputs 1–5min low
Snapshot per endpoint under 30s medium
Regression grows with bugs varies minimal

My Verdict

A single test type is never enough. But the combination of four types covers 90% of real-world failures:

  1. Smoke tests as a deployment gate — if they fail, nothing goes live
  2. Integration tests for every endpoint — happy path and at least one error path
  3. Contract tests for APIs with external consumers — schema breaks are caught immediately
  4. Fuzz tests for public endpoints — no input may produce a 500

Load and security tests come on top when the API is publicly accessible. E2E tests for the two or three most important business flows. Regression tests with every bug fix anyway.

The most common mistake: writing only unit tests and feeling safe. Unit tests prove the gears work individually — not that the machine runs.

Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Comments

Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.