DEV Community

Cover image for Testing Use Cases Without a Database (and Why Your Tests Are Slow)
Gabriel Anhaia
Gabriel Anhaia

Posted on

Testing Use Cases Without a Database (and Why Your Tests Are Slow)


Open a four-year-old Laravel codebase. Look at tests/. You will find two folders. Feature/ has 400 files. Unit/ has six, and four of them test value objects.

The Feature tests each boot the framework, run migrations against a MySQL container, write a row, hit a controller, assert, and roll back. On a warm laptop the suite takes four minutes. On CI it takes eleven. Nobody runs it on save. Nobody runs it on commit. They run it on push, and only because the hook is wired.

A Symfony codebase the same age looks the same with different filenames. WebTestCase for the wide stuff, KernelTestCase for the rest. Same story, same wall-clock.

The pyramid is upside down. The team has stopped calling it a pyramid. They call it the wedge.

The wedge is not a PHP problem. It is a coupling problem. When your OrderService reaches into the Eloquent model directly, the only honest way to test it is to boot enough framework to make Eloquent work. That is most of the framework. The code shape picked the wedge for you.

One use case, one port, one in-memory adapter, one contract test. A test file that finishes before the cursor blinks. And an honest list of what you give up.

The three test layers a hexagonal app actually needs

Before any code, the mental model. A port-and-adapter application has three test layers, each tied to a layer of the architecture.

The unit layer talks to ports through interfaces, backed by in-memory implementations. The unit test wires a use case to the in-memory adapters and exercises behavior. No database, no HTTP, no queue. The whole file runs in milliseconds. A few hundred of these finish in under two seconds.

The contract layer is one abstract test class per port. Every adapter that implements the port subclasses it. The in-memory one and the Doctrine one run the same assertions against different wiring. The Doctrine version runs against an in-memory SQLite database, so it stays fast. The contract test is the port's executable specification.

The integration layer boots a real HTTP stack against a real database. Five tests, maybe ten. Their job is to prove the wiring works end-to-end. Not to cover behavior. Behavior is covered upstairs.

Notice what is missing: the "feature test that hits a controller, writes to a real database, and asserts on JSON." That test claims to be valuable. It tests four things at once, so when it fails you do not know which one broke. You stop writing those. One thing per test, with the cheapest test that proves it.

Three test layers — unit, contract, integration — versus the wedge of feature tests most PHP suites end up with

The use case and its port

Here is the system under test. One verb, one method, on PHP 8.3 with readonly classes and declare(strict_types=1).

<?php
declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;

final readonly class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
    ) {}

    public function execute(PlaceOrderInput $in): PlaceOrderOutput
    {
        if ($in->customerId === '') {
            throw new \InvalidArgumentException(
                'customerId is required.',
            );
        }
        if ($in->totalCents <= 0) {
            throw new \InvalidArgumentException(
                'totalCents must be positive.',
            );
        }

        $id = OrderId::generate();
        $order = new Order(
            $id,
            $in->customerId,
            $in->totalCents,
            $in->currency,
        );
        $order->place();

        $this->orders->save($order);

        return new PlaceOrderOutput(
            $id->value,
            $order->status()->value,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The port is a plain interface, declared in the domain layer, in the language of the domain:

<?php
declare(strict_types=1);

namespace App\Domain\Order;

interface OrderRepository
{
    public function save(Order $order): void;

    public function find(OrderId $id): ?Order;
}
Enter fullscreen mode Exit fullscreen mode

Two methods. One returns void on success and throws on a real error. One returns a domain object or null. The interface is the signature. The meaning is missing.

The in-memory adapter is not a mock

The pattern that makes fast tests work is the in-memory adapter, and it is undersold in PHP testing.

A mock is a runtime artifact. You configure it inline in the test. It expects exactly these calls, in this order, with these arguments. It is brittle. It leaks the implementation of the system under test into the test code. When you rename a private method, an unrelated test breaks.

An in-memory adapter is a production-quality implementation of the port that happens to store state in an array. It lives at src/Infrastructure/Persistence/InMemory/. It satisfies the same interface the Doctrine version does. The use case cannot tell which one is wired in.

<?php
declare(strict_types=1);

namespace App\Infrastructure\Persistence\InMemory;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;

final class InMemoryOrderRepository implements OrderRepository
{
    /** @var array<string, Order> */
    private array $byId = [];

    public function save(Order $order): void
    {
        $this->byId[$order->id->value] = $order;
    }

    public function find(OrderId $id): ?Order
    {
        return $this->byId[$id->value] ?? null;
    }

    public function count(): int
    {
        return count($this->byId);
    }
}
Enter fullscreen mode Exit fullscreen mode

Forty lines. Reusable across every unit test in the suite. The dependency rule treats it exactly like it treats the Doctrine version: an outbound adapter behind a port. When a new use case lands, you do not write a new mock. You wire the use case to the in-memory repository that already exists, and you write the test.

A unit test that finishes before the cursor blinks

PHPUnit 11, attribute-based metadata, no framework boot.

<?php
declare(strict_types=1);

namespace Tests\Unit\Application\Order;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Domain\Order\OrderStatus;
use App\Infrastructure\Persistence\InMemory\InMemoryOrderRepository;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class PlaceOrderTest extends TestCase
{
    #[Test]
    public function it_places_a_valid_order(): void
    {
        $orders = new InMemoryOrderRepository();
        $useCase = new PlaceOrder($orders);

        $out = $useCase->execute(new PlaceOrderInput(
            customerId: 'cust-1',
            totalCents: 4500,
            currency: 'EUR',
        ));

        self::assertNotSame('', $out->orderId);
        self::assertSame(
            OrderStatus::Placed->value,
            $out->status,
        );
        self::assertSame(1, $orders->count());
    }

    #[Test]
    #[DataProvider('invalidInputs')]
    public function it_rejects_invalid_input(
        string $customerId,
        int $totalCents,
        string $message,
    ): void {
        $useCase = new PlaceOrder(
            new InMemoryOrderRepository(),
        );

        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage($message);

        $useCase->execute(new PlaceOrderInput(
            $customerId,
            $totalCents,
            'EUR',
        ));
    }

    public static function invalidInputs(): array
    {
        return [
            'empty customer' => ['', 4500, 'customerId is required.'],
            'zero total'     => ['cust-1', 0, 'totalCents must be positive.'],
            'negative total' => ['cust-1', -1, 'totalCents must be positive.'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

No RefreshDatabase. No WebTestCase. No container boot. PHPUnit's autoloader instantiates two plain objects and runs five assertions. On a 2024 laptop, the whole file finishes in single-digit milliseconds. Three hundred tests of this shape complete in a couple of seconds.

A team I talked to had the equivalent behavior covered by Laravel feature tests in that hundreds-of-milliseconds range each. Moving the same logic to unit tests against in-memory ports cut their PHPUnit suite from minutes to seconds, in their reporting. The behavior coverage did not change. The test boundary did. Your numbers will depend on hardware, PHP version, and how much of the wedge you actually move.

Contract tests: where the port stops lying

This is where the port-and-adapter split pays off a second time.

A PHP interface tells you the signatures. It does not tell you whether find returns null for an unknown id or throws. It does not tell you whether save on the same id replaces or appends. PHPDoc helps, but PHPDoc does not run. Two engineers write two adapters six months apart, and the in-memory version behaves one way and the Doctrine version behaves another, and you discover the gap on a Friday at 17:50.

A contract test is one abstract TestCase that describes the port's promises. Every adapter that implements the port is a concrete subclass that returns its instance from a template method.

<?php
declare(strict_types=1);

namespace Tests\Contract\Order;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\OrderStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

abstract class OrderRepositoryContractTest extends TestCase
{
    abstract protected function repository(): OrderRepository;

    #[Test]
    public function find_returns_null_for_unknown_id(): void
    {
        self::assertNull(
            $this->repository()->find(OrderId::generate()),
        );
    }

    #[Test]
    public function saved_order_can_be_retrieved(): void
    {
        $repo = $this->repository();
        $id = OrderId::generate();
        $order = new Order($id, 'cust-1', 1500, 'EUR');
        $order->place();

        $repo->save($order);

        $found = $repo->find($id);
        self::assertNotNull($found);
        self::assertSame(1500, $found->totalCents());
        self::assertSame(OrderStatus::Placed, $found->status());
    }

    #[Test]
    public function save_with_same_id_replaces_not_appends(): void
    {
        $repo = $this->repository();
        $id = OrderId::generate();

        $first = new Order($id, 'cust-1', 1500, 'EUR');
        $first->place();
        $repo->save($first);

        $second = new Order($id, 'cust-1', 9999, 'EUR');
        $second->place();
        $repo->save($second);

        $found = $repo->find($id);
        self::assertNotNull($found);
        self::assertSame(9999, $found->totalCents());
    }
}
Enter fullscreen mode Exit fullscreen mode

Each adapter ships a fifteen-line subclass:

<?php
declare(strict_types=1);

namespace Tests\Contract\Order;

use App\Domain\Order\OrderRepository;
use App\Infrastructure\Persistence\InMemory\InMemoryOrderRepository;

final class InMemoryOrderRepositoryTest extends OrderRepositoryContractTest
{
    protected function repository(): OrderRepository
    {
        return new InMemoryOrderRepository();
    }
}
Enter fullscreen mode Exit fullscreen mode
<?php
declare(strict_types=1);

namespace Tests\Contract\Order;

use App\Domain\Order\OrderRepository;
use App\Infrastructure\Persistence\Doctrine\DoctrineOrderRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;

final class DoctrineOrderRepositoryTest extends OrderRepositoryContractTest
{
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->em = TestEntityManagerFactory::sqliteInMemory();
        $tool = new SchemaTool($this->em);
        $tool->createSchema($this->em->getMetadataFactory()->getAllMetadata());
    }

    protected function repository(): OrderRepository
    {
        return new DoctrineOrderRepository($this->em);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run both. They pass against the same assertions. The day a Redis-backed read-model adapter shows up for the same port, you write another fifteen-line subclass. The contract suite catches every divergence on the first PR.

Writing the abstract test forces a conversation PHP teams almost never have. What does the port actually promise? "Returns an order or null" is a sentence. Turning that sentence into an executable assertion forces a decision about ids that do not exist, uniqueness on a second write, idempotency. The answer is the test. Once written, the assertion stays green or the build breaks.

That is what "interfaces in the language of the domain" actually means in practice.

SQLite is the free win nobody takes

The Doctrine contract test ran against an in-memory SQLite database. That is the move that keeps it cheap.

PDO ships with SQLite in every supported PHP build. Doctrine speaks SQLite natively. Booting a fresh EntityManager against :memory:, creating the schema, running a test, and tearing down lands in the low-millisecond range on a typical 2024 laptop (no formal benchmark). That is comfortably faster than the same test against a MySQL container, and faster again against a remote MySQL — enough that you stop noticing test latency on save.

<?php
declare(strict_types=1);

namespace Tests\Contract\Order;

use Doctrine\ORM\EntityManager;
use Doctrine\ORM\ORMSetup;
use Doctrine\DBAL\DriverManager;

final class TestEntityManagerFactory
{
    public static function sqliteInMemory(): EntityManager
    {
        $config = ORMSetup::createAttributeMetadataConfiguration(
            paths: [__DIR__ . '/../../src/Domain'],
            isDevMode: true,
        );

        $conn = DriverManager::getConnection([
            'driver' => 'pdo_sqlite',
            'memory' => true,
        ], $config);

        return new EntityManager($conn, $config);
    }
}
Enter fullscreen mode Exit fullscreen mode

For most repository contracts (save, find by id, query by status), SQLite is identical to MySQL. For the cases where it is not, the contract asserts what both engines agree on, and the MySQL-only edge cases live in the integration layer where a container boot is fine.

In-memory SQLite versus a MySQL container for the same Doctrine contract test, with order-of-magnitude latency on the y-axis

What you give up

The trade is honest, and writing the post without naming it would be misleading.

You give up dialect coverage in the fast loop. SQLite is dynamically typed; MySQL is strict. SQLite's DATETIME is text. SQLite is more permissive about transaction nesting. A query that uses MySQL JSON functions, full-text indexes, or vendor-specific window-function syntax cannot run on SQLite. Those cases need a real MySQL in the integration layer.

You give up cross-row consistency assertions. "When this row is inserted, the trigger fires and updates that summary table." If the production database is doing work your in-memory adapter does not model, your unit tests will pass while production behaves differently. Push that assertion down to integration.

You give up the framework's free wiring assertions. Laravel's feature test proves the route file, the middleware stack, the controller resolution, the validator, the model binding, the response macro, and the SQL all work together. The unit test does not. You replace that coverage with a small number of integration tests, not zero. Keep three to five per use case. The discipline is to stop asking integration tests to cover behavior. Their job is wiring.

You spend a one-time refactor cost. The use case has to be a thing. The port has to be a thing. If your current code is a controller method that news up an Eloquent query inline, you cannot unit-test it cheaply. There is no use case to test. The migration path is incremental: extract one use case, define its ports, write the in-memory adapters, move tests over, delete the slow feature test only when the unit plus contract pair covers the same surface.

Stop using feature tests for everything

The reason teams reach for RefreshDatabase for every behavior is convenience. The framework already has the helper. The model is already wired to a connection. The container is already booted. So an "OrderService unit test" becomes a feature test that POSTs to /api/orders, because that is what the framework rewards.

That convenience compounds the wrong way. A nine-minute suite gets run pre-push. A two-second suite gets run on save. Engineers who get a green bar three times a minute write different code than engineers who get one every nine minutes. The architecture is upstream of that. Pick the architecture and the test boundary follows.

Ask, for every new test: what would break if I ran this only against in-memory adapters? Most of the time, nothing. Move it down.


If this was useful

The full pattern lives in Decoupled PHP: use cases as the application layer, ports declared in the domain, adapters at the edges, contract tests as the executable spec for every port. Same playbook in Laravel and Symfony — they end up as adapters, not protagonists. The book also walks the legacy refactor: how you peel a wedge-shaped suite apart use case by use case without a freeze week.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)