You've shipped a Lumen service. It's small, fast, takes JSON in, returns JSON out, and the boss is happy. Then a teammate refactors the pricing rules, deploys on a Friday, and on Monday morning support is on fire because half the orders came back with a total of zero.

You knew this would happen eventually. Lumen feels so light that adding tests almost feels like overkill. But "almost" is doing a lot of work in that sentence.

Lumen testing is a strange middle ground. It's the Laravel testing helpers, but stripped down. The docs are thinner. There's no Pest config waiting for you, no tests/Feature and tests/Unit scaffolding sitting in the repo from day one. So most Lumen projects end up with one of two extremes: zero tests, or a tangled tests/ folder where feature tests hit a live MySQL, unit tests new their dependencies, and nobody trusts the suite enough to wait for it.

The way out is to be deliberate about three layers: feature tests against real HTTP routes, database tests that exercise persistence, and mocked-service tests that replace anything you don't own. Let's walk through each one and then look at how they sit together.

Setting Up The Test Environment

Lumen ships with a tests/ folder and a phpunit.xml at the repo root. The default config works for a hello-world, but for a real API you'll want it to look more like this:

XML phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="vendor/autoload.php"
         colors="true"
         processIsolation="false"
         stopOnFailure="false">
    <testsuites>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_KEY" value="base64:test-key-only-used-in-tests"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>

A few things to notice. APP_ENV=testing is what flips Lumen out of production behaviour. CACHE_DRIVER=array makes every cache read and write happen in memory, so one test can't accidentally see another test's cached data. QUEUE_CONNECTION=sync runs queued jobs inline. That's useful when you're testing an endpoint that dispatches a job and you want to assert on the job's side effects. And DB_CONNECTION=sqlite with :memory: is the cheat code: every test run gets a fresh SQLite database that lives in RAM, vanishes at the end of the run, and costs you nothing.

The in-memory SQLite trick has a catch. If your migrations or models use MySQL-only features, like JSON columns with ->where('payload->status', ...), generated columns, full-text indexes, or INSERT ... ON DUPLICATE KEY UPDATE, those won't behave identically in SQLite. For most CRUD-shaped microservices you're fine. For anything exotic, point your test DB at a real MySQL instance running locally or in CI.

The other piece is tests/TestCase.php. Lumen's default looks something like this, and it's worth understanding what each line does:

PHP tests/TestCase.php
<?php

use Laravel\Lumen\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
    public function createApplication()
    {
        return require __DIR__ . '/../bootstrap/app.php';
    }
}

createApplication() is the hook Lumen calls before every test. It boots a fresh app instance from bootstrap/app.php, which means each test starts with the same container state, the same service providers registered, and the same middleware stack. That's important. It's what stops one test from polluting the next via a singleton you forgot about.

Feature Tests Are Where Lumen Earns Its Keep

Lumen is a microframework optimized for JSON APIs. Feature tests, the ones that fire a real HTTP request through your routes, controllers, middleware, and serializers, are where you get the highest signal-to-noise. You're testing the thing the way a client actually uses it.

Here's the shape of a feature test that creates an order through your API:

PHP tests/Feature/CreateOrderTest.php
<?php

namespace Tests\Feature;

use Laravel\Lumen\Testing\DatabaseMigrations;
use TestCase;

class CreateOrderTest extends TestCase
{
    use DatabaseMigrations;

    public function test_it_creates_an_order_with_valid_payload(): void
    {
        $payload = [
            'customer_id' => 42,
            'currency'    => 'USD',
            'items'       => [
                ['sku' => 'WIDGET-1', 'quantity' => 2, 'unit_cents' => 1500],
                ['sku' => 'WIDGET-2', 'quantity' => 1, 'unit_cents' => 3000],
            ],
        ];

        $this->json('POST', '/api/orders', $payload)
             ->seeStatusCode(201)
             ->seeJsonStructure([
                 'id',
                 'status',
                 'total_cents',
                 'items' => [
                     ['sku', 'quantity', 'unit_cents'],
                 ],
             ])
             ->seeJson(['total_cents' => 6000, 'status' => 'pending']);
    }
}

Read that test out loud and it's exactly what a product owner would say: "when I send these items, I should get back a 201 with an order in the pending state, and the total should be 6000 cents." No mocks, no clever isolation, no test doubles. It exercises the router, the middleware, the validator, the controller, the service, the Eloquent models, and the database, all in one shot.

The $this->json() helper sends a request with a Content-Type: application/json header and JSON-encodes the body. There are also $this->get(), $this->post(), $this->put(), $this->patch(), and $this->delete() for the lower-level shapes. They return $this, so you can chain assertions:

  • seeStatusCode(int): asserts the HTTP response code.
  • seeJson(array): asserts the response JSON contains the given key/value pairs (subset match).
  • seeJsonEquals(array): asserts the response JSON equals the given array exactly.
  • seeJsonStructure(array): asserts the shape of the JSON without caring about values.
  • dontSeeJson(array): asserts the response does not contain the given pairs.

For asserting on the raw response, $this->response is the underlying Illuminate\Http\Response after the most recent call. So $this->response->getStatusCode(), $this->response->headers->get('X-Some-Header'), and json_decode($this->response->getContent(), true) are always available when the chained helpers aren't enough.

Testing The Sad Path Is Just As Important

Half the value of a feature suite is in the tests that exercise failure. Validation, auth, missing resources, business-rule violations. These are the things that get refactored into bugs.

PHP tests/Feature/CreateOrderValidationTest.php
public function test_it_rejects_an_order_with_no_items(): void
{
    $this->json('POST', '/api/orders', [
        'customer_id' => 42,
        'currency'    => 'USD',
        'items'       => [],
    ])
        ->seeStatusCode(422)
        ->seeJsonStructure(['error', 'fields' => ['items']]);
}

public function test_it_rejects_a_negative_quantity(): void
{
    $this->json('POST', '/api/orders', [
        'customer_id' => 42,
        'currency'    => 'USD',
        'items'       => [['sku' => 'X', 'quantity' => -1, 'unit_cents' => 100]],
    ])
        ->seeStatusCode(422)
        ->seeJson(['error' => 'validation_failed']);
}

The instinct is to write one test per endpoint and call it done. Resist it. One happy-path test plus three to five sad-path tests per endpoint is a much better budget. Every sad-path test is a small contract: "if the input looks like this, the response looks like that." And contracts are what survive refactors.

Three-lane diagram titled &#39;What Each Lumen Test Type Touches&#39; showing which request-flow stages each test type exercises: feature tests cover the full chain, database tests skip HTTP, and mocking-service tests substitute a mocked service

Database Tests: Migrations Versus Transactions

Feature tests will eventually slow down. The first ten are instant; the hundredth feels noticeable; the five hundredth is where engineers start --filter-ing instead of running the whole suite. The single biggest cost is database setup, and Lumen gives you two traits to control how it happens.

DatabaseMigrations runs your full migration set before every test and rolls it back after. That guarantees a clean slate, but each migration costs CPU. For a service with five tables, this is fine. For a service with eighty migrations accumulated over three years, this is going to dominate your test runtime.

DatabaseTransactions is the alternative. It opens a transaction at the start of each test and rolls it back at the end. The schema only gets built once per suite (you run migrations manually before the suite starts), and every test sees a clean database without paying the migration cost again.

PHP tests/Feature/OrderRepositoryTest.php
<?php

namespace Tests\Feature;

use Laravel\Lumen\Testing\DatabaseTransactions;
use TestCase;

class OrderRepositoryTest extends TestCase
{
    use DatabaseTransactions;

    public function test_it_persists_an_order_with_its_items(): void
    {
        $order = app('App\\Repositories\\OrderRepository')->create([
            'customer_id' => 42,
            'currency'    => 'USD',
            'items'       => [
                ['sku' => 'A', 'quantity' => 1, 'unit_cents' => 1000],
            ],
        ]);

        $this->seeInDatabase('orders', [
            'id'          => $order->id,
            'customer_id' => 42,
            'status'      => 'pending',
        ]);

        $this->seeInDatabase('order_items', [
            'order_id' => $order->id,
            'sku'      => 'A',
        ]);
    }
}

seeInDatabase($table, $constraints) is a Lumen assertion that runs a real SELECT against your test database and fails if no row matches. There's also notSeeInDatabase() for the negative case. These two assertions are the bread and butter of database tests. You set up a state, you call a method, and you confirm the database now looks the way you expect.

A small tradeoff with DatabaseTransactions: it only works if every piece of code under test uses the same connection. Code that opens a separate connection, or commits inside a job, will leak data past the rollback boundary. If you find yourself debugging "why is data from yesterday's test showing up in today's run," that's usually the culprit. Fall back to DatabaseMigrations for those tests.

Seeding Test Data Without A Factory Library

Lumen doesn't ship with Eloquent factories the way full Laravel does. Wiring up Laravel's class-based factories on Lumen models takes effort that isn't always worth it for a small service. A simpler pattern is a private helper on the test class:

PHP tests/Feature/OrderListTest.php
public function test_it_lists_orders_for_a_customer(): void
{
    $this->makeOrder(['customer_id' => 1, 'total_cents' => 1000]);
    $this->makeOrder(['customer_id' => 1, 'total_cents' => 2500]);
    $this->makeOrder(['customer_id' => 2, 'total_cents' => 9999]);

    $this->json('GET', '/api/orders?customer_id=1')
         ->seeStatusCode(200)
         ->seeJsonStructure(['data' => [['id', 'total_cents']]]);

    $body = json_decode($this->response->getContent(), true);
    $this->assertCount(2, $body['data']);
}

private function makeOrder(array $overrides = []): \App\Models\Order
{
    return \App\Models\Order::create(array_merge([
        'customer_id' => 1,
        'currency'    => 'USD',
        'status'      => 'pending',
        'total_cents' => 1000,
    ], $overrides));
}

Not as elegant as a real factory, but it gets you 80% of the way there with five lines of code. When the helper grows past a handful of overrides, that's the signal to extract a real factory. Most microservices never reach that point.

Mocking Services Without Breaking The Contract

The third layer is where most Lumen test suites either shine or quietly die. Your service almost certainly talks to something it doesn't own: a payment gateway, an email API, an upstream microservice, a feature-flag service. You don't want those real calls happening in tests, and you don't want to write integration tests that go through real network paths just to verify your controller does the right thing.

Mockery is bundled with Lumen by default. It's PHPUnit's mocking partner, and Lumen's TestCase base class even calls Mockery::close() for you between tests. The pattern is to build a fake of the interface, bind it into the container in place of the real implementation, and then assert that your code called it correctly.

Imagine your service has an interface like this:

PHP app/Services/PaymentGateway.php
namespace App\Services;

interface PaymentGateway
{
    public function charge(int $cents, string $currency, string $token): string;
}

And a production binding in bootstrap/app.php:

PHP bootstrap/app.php
$app->bind(\App\Services\PaymentGateway::class, \App\Services\StripeGateway::class);

The test wants to verify that when an order is placed, the gateway gets called with the right amount, without actually hitting Stripe:

PHP tests/Feature/CheckoutTest.php
<?php

namespace Tests\Feature;

use App\Services\PaymentGateway;
use Laravel\Lumen\Testing\DatabaseTransactions;
use Mockery;
use TestCase;

class CheckoutTest extends TestCase
{
    use DatabaseTransactions;

    public function test_it_charges_the_payment_gateway_for_the_order_total(): void
    {
        $gateway = Mockery::mock(PaymentGateway::class);
        $gateway->shouldReceive('charge')
                ->once()
                ->with(6000, 'USD', 'tok_test_123')
                ->andReturn('ch_fake_abc');

        $this->app->instance(PaymentGateway::class, $gateway);

        $this->json('POST', '/api/checkout', [
            'order_id' => $this->makeOrder(['total_cents' => 6000])->id,
            'token'    => 'tok_test_123',
        ])
             ->seeStatusCode(200)
             ->seeJson(['charge_id' => 'ch_fake_abc']);
    }
}

Three things doing the work here. Mockery::mock(PaymentGateway::class) builds a stand-in object that implements the interface but has no real behaviour. shouldReceive('charge')->once()->with(...) declares the expectation: "this method should be called exactly once, with exactly these arguments." And $this->app->instance(...) is the swap. Lumen's container will hand out our mock instead of the real StripeGateway for the duration of this test.

If the controller calls charge() with the wrong amount, the test fails with a clear message. If it doesn't call charge() at all, the test fails. If it calls charge() twice, the test fails. The contract is in the test, in plain sight.

When To Use A Spy Instead Of A Mock

Mocks fail fast when expectations don't match. That's great when you want a hard contract, but it's noisy when you only care about whether something was called, not exactly how. Spies are the looser cousin. They record calls and let you assert on them at the end.

PHP tests/Feature/OrderEventsTest.php
public function test_it_dispatches_an_order_placed_event(): void
{
    $dispatcher = Mockery::spy(\App\Services\EventDispatcher::class);
    $this->app->instance(\App\Services\EventDispatcher::class, $dispatcher);

    $this->json('POST', '/api/orders', $this->validPayload());

    $dispatcher->shouldHaveReceived('dispatch')
               ->once()
               ->with(Mockery::on(fn ($event) => $event->name === 'order.placed'));
}

A spy doesn't fail mid-test if something unexpected happens. It only fails when you assert on it at the end. That's the right shape for "side effects" like events, log lines, and metrics, where you care that something happened but not the exact ceremony around it.

Don't Mock What You Don't Own The Interface For

The trap with mocking is the "any port in a storm" reflex. Some piece of code is hard to test, so you mock it. Six months later, that mock is the only documentation of what the real thing does, the real thing has drifted, and your tests are green while production burns.

The rule that's saved me the most: only mock interfaces you control. If you're talking to Stripe, you don't mock the Stripe SDK directly. You wrap it in a PaymentGateway interface you wrote, and you mock that. Now your test is testing your service's contract with your abstraction, and if Stripe changes, you only have one place to fix it. The real Stripe SDK gets its own integration test: slow, occasional, run in CI on a schedule, but real.

Same applies to upstream microservices. Wrap the HTTP client in a BillingClient interface. Mock the interface in feature tests. Run one integration test against the real upstream service on a cron, and accept that it'll occasionally page someone.

The Three Tests, In One Suite

A useful Lumen suite isn't a single layer. It's the three working together, each pulling its weight:

A feature test says "when a real HTTP request hits this route with this payload, the system as a whole behaves like this." It's the one a product owner could read and nod at. It exercises everything end-to-end, runs against the in-memory database, and gives you the highest confidence per line of test code.

A database test says "when this method runs, the database looks like this." It's narrower than a feature test (it skips the HTTP layer), but it lets you catch persistence bugs at a layer where the assertions are crisp. seeInDatabase and notSeeInDatabase are the verbs here.

A mocking-service test says "when this code runs, it talked to its collaborator in this exact way." It's the test that pins down a contract with something you don't own. Mock the interface you wrote, not the SDK you imported.

The ratio that tends to work for a Lumen microservice is roughly 70% feature, 20% database, 10% mocked-service. Don't take that too literally, though. The right shape depends on how much of your code is HTTP-facing versus how much is pure logic or persistence orchestration.

One last thing. Lumen is, by Laravel's own admission, less actively developed than the full framework. The team has nudged people toward Laravel for new projects. That doesn't mean your Lumen service is going anywhere. It means your test suite is more important, not less. A well-tested Lumen service can live in production for years, get upgraded carefully, and survive long after the team has moved on. A poorly-tested one will quietly accumulate bugs until someone decides to rewrite it in Laravel, and that rewrite will go badly because there were never tests to define what the service was supposed to do in the first place.

Tests are the documentation that doesn't drift. Write them like you mean it.