Integration Testing — Databases, Filesystems, HTTP Clients, Queues, Cache, and Containers

Write integration tests that exercise real (or realistically simulated) dependencies — a test database with transaction rollback, the real filesystem, HTTP clients against a local mock server, queues, cache backends, and ephemeral containers for external services — and understand when each technique is the right way to get realistic coverage without making the suite slow or flaky.

Unit tests, with their collaborators replaced by test doubles, deliberately cannot catch bugs that live in the seams between your code and the real world — a repository's SQL that's subtly wrong, a filesystem path that doesn't exist in production, an HTTP client that misparses a real API's response shape. Integration tests exist specifically to test those seams, using a real (or a faithfully realistic stand-in for a real) version of the dependency instead of a double. This lesson works through the concrete techniques for the dependencies a PHP application most commonly integrates with: a real test database with per-test isolation via transaction rollback, the real filesystem scoped to a temp directory, HTTP clients tested against a local mock server instead of the real internet, queues and cache backends tested against real (or in-memory) implementations, and ephemeral containers — the Testcontainers pattern — for spinning up a real, disposable database or service just for the test run.

Database integration tests: a real test database, rolled back per test

The gold-standard technique for database integration tests is to run them against a real database — ideally the same engine and version as production (testing against SQLite when production runs PostgreSQL risks missing engine-specific behavior) — wrapped in a transaction that's opened in setUp() and rolled back in tearDown(), so every test starts from a known, clean state and no test's writes leak into the next one, without needing to truncate or reseed tables between every single test (which would be far slower). Frameworks provide this pattern directly (Laravel's RefreshDatabase / DatabaseTransactions traits, Symfony's test transaction helpers); outside a framework, the same pattern is a few lines: begin a transaction in setUp(), inject that same connection into the repository under test, and call rollback() in tearDown().

This gives you real SQL execution, real constraint enforcement (a foreign key violation in your repository code will actually fail in the test, exactly as it would in production), and real query behavior — all the things a mocked repository cannot verify — while still running fast enough (a rolled-back transaction is cheap) to be part of a suite that runs on every CI build, and often even locally during development.

• Use the same database engine and version as production — SQLite-for-tests-but-PostgreSQL-for-production hides real engine differences. • Wrap each test in a transaction (begin in setUp(), rollback in tearDown()) for fast, clean per-test isolation without full truncation. • This catches real bugs unit tests structurally cannot: wrong SQL, missing constraints, incorrect column mappings. • A dedicated test database (never the real development or production database) is non-negotiable — tests must never be able to touch real data.

Filesystem integration tests

Code that reads or writes files — a CSV export, a log writer, an uploaded-image processor — deserves integration tests against the real filesystem rather than a mocked file abstraction, because filesystem behavior (permissions, path resolution, partial writes, directory creation) is exactly the kind of thing that's easy to get subtly wrong and that a mock would simply assume away. The standard technique is to scope every test to a fresh temporary directory: PHP's sys_get_temp_dir() combined with a unique subdirectory name (or PHPUnit's own vfsStream-based virtual filesystem for cases that don't need a truly real disk) gives each test an isolated sandbox, created in setUp() and deleted recursively in tearDown(), so tests never touch real application files and never leave stray files behind after a run.

A common middle ground is vfsStream, a virtual, in-memory filesystem library that implements PHP's stream wrapper interface — code under test calls ordinary functions like file_put_contents() or is_readable() against a vfs:// path, and vfsStream intercepts them entirely in memory, giving realistic filesystem semantics (permissions, directory structure) without ever touching a real disk, which makes tests faster and immune to leftover files from a crashed test run. Real-disk temp directories remain the right choice when a test specifically needs to verify interop with something outside PHP's own stream wrappers (an external CLI tool the code shells out to, for instance).

HTTP client integration tests: local mock servers instead of the real internet

Testing code that calls an external HTTP API creates a dilemma: mocking the HTTP client entirely (a test double for the whole client) means never verifying the actual request is well-formed or that response parsing handles the real service's actual response shape, but making real network calls to a third-party API in every test run is slow, flaky (network failures, rate limits), and can have real side effects or costs against someone else's system. The standard resolution is a local HTTP mock server: a lightweight, in-test server that listens on localhost, is configured per test to return specific canned responses for specific requests, and lets your real HTTP client library (Guzzle, Symfony HttpClient) make a genuine HTTP request — over a real socket, through the real client code, with real serialization/deserialization — just to a fake, local destination instead of the internet.

PHP has good tooling for exactly this: Guzzle's own MockHandler swaps out only the transport layer while leaving the rest of the client's real request-building and response-parsing code in the loop, and dedicated local mock-server libraries (or a tool like WireMock run as a sidecar in CI) go further by actually accepting real HTTP connections on localhost, which is closer to a true integration test since it exercises DNS/connection handling too, not just PHP-level request objects. Either approach avoids the real network entirely while still exercising far more real code than a plain mocked-client unit test would.

• Guzzle's MockHandler — swaps the transport, keeps real request-building/response-parsing code exercised. • A local mock server (WireMock, or a small purpose-built one) — accepts real localhost HTTP connections, closer to a true integration test. • Never make real calls to a third-party's production API from an automated test suite — slow, flaky, and can have real side effects or costs. • Configure the mock per test to return the exact response shapes (including malformed/error ones) you need to verify handling for.

Queues and cache: real or in-memory backends

Queue integrations (RabbitMQ, Amazon SQS, Redis-backed queues) and cache integrations (Redis, Memcached) both benefit from the same two-tier strategy: a fast in-memory or synchronous driver for most tests, and a real backend for a smaller number of tests that specifically verify the real integration. Many queue libraries and frameworks ship a 'sync' driver that runs a queued job immediately, in-process, instead of actually publishing to a broker — useful for testing that the right job gets dispatched with the right payload, without needing a real message broker running. For genuinely verifying serialization over the wire, retry behavior, or dead-letter handling, a real (test) broker connection is worth the extra setup cost for a focused subset of tests.

Cache follows the same pattern: an array-backed or in-memory cache adapter (many PSR-16/PSR-6 implementations ship one specifically for testing) is fast and sufficient for verifying that your code reads and writes the cache correctly, while a smaller set of tests against a real Redis instance (often via a container, discussed next) verifies behavior a pure in-memory stand-in can't — actual TTL expiry timing, real serialization of complex values, and connection-level failure handling.

Testcontainers-style ephemeral containers for realistic external services

For a dependency where an in-memory or mocked stand-in genuinely isn't close enough to the real thing — a specific database engine's exact behavior, a real message broker's real protocol — the Testcontainers pattern gives you the real service without any of the usual downsides of a shared, persistent test environment: your test suite programmatically starts a real Docker container (a real PostgreSQL, a real Redis, a real RabbitMQ) at the start of a test run, waits for it to become ready, runs the tests against it over its exposed port, and tears the container down completely afterward. Each test run gets a completely fresh, isolated instance of the real dependency — no leftover state from a previous run, no contention with other developers or CI jobs sharing one persistent test database, and no risk of a test suite silently depending on data some other process happened to leave behind.

The php-testcontainers library (a Testcontainers-for-PHP implementation, mirroring the pattern popularized in the Java and Node ecosystems) or a simpler docker-compose-in-CI setup both achieve this; the core idea is the same regardless of tooling: treat the external dependency's exact real version as a disposable, code-managed resource rather than a long-lived shared service someone has to remember to reset. This buys the strongest realism available short of testing directly against production infrastructure, at the cost of a slower test run (containers take real seconds to start) — which is exactly why this technique is reserved for a focused set of integration tests, not applied to the whole suite.

• Testcontainers pattern: programmatically start a real Docker container for the dependency, run tests against it, tear it down afterward. • Every test run gets a fresh, isolated real instance — no shared state, no 'someone left bad data in the shared test DB' class of flakiness. • Strongest realism short of testing against production infrastructure itself, at the cost of container startup time. • Reserve for a focused subset of integration tests that specifically need the real engine's real behavior — not the entire suite.

Common integration test setup patterns

Transaction rollback is dramatically faster than truncating/reseeding tables between every test, while still giving full isolation. A temp directory scoped per test (unique name via uniqid()) prevents parallel test runs from colliding on the same files. MockHandler intercepts only the transport layer — Guzzle's real request serialization and response parsing code still runs.

// --- Database: transaction-per-test rollback ---
protected function setUp(): void {
    $this->pdo = new PDO($_ENV['TEST_DATABASE_DSN']);
    $this->pdo->beginTransaction();
}
protected function tearDown(): void {
    $this->pdo->rollBack(); // every write this test made vanishes
}

// --- Filesystem: fresh temp directory per test ---
protected function setUp(): void {
    $this->tempDir = sys_get_temp_dir() . '/test_' . uniqid();
    mkdir($this->tempDir);
}
protected function tearDown(): void {
    array_map('unlink', glob($this->tempDir . '/*'));
    rmdir($this->tempDir);
}

// --- HTTP client: Guzzle MockHandler, real client code exercised ---
$mock = new GuzzleHttp\Handler\MockHandler([
    new GuzzleHttp\Psr7\Response(200, [], json_encode(['status' => 'ok'])),
]);
$client = new GuzzleHttp\Client(['handler' => GuzzleHttp\HandlerStack::create($mock)]);

Code Examples

This test runs real SQL through the real OrderRepository class against a real (test) database — it would catch a bug where the repository's INSERT statement drops the second line item, or where a foreign key constraint on the real schema rejects a write the code assumed would succeed. None of that is visible to a unit test where the repository is mocked. Because the transaction is rolled back in tearDown() rather than committed, this test (and every test in the class) can run any number of times against the same test database without ever needing to manually clean up rows, and without one test's data affecting another.

MockHandler replaces only the network transport, so WeatherApiClient's real request-building and JSON-parsing code runs exactly as it would against the real API — this test would catch a bug in the parsing logic (a wrong JSON key name) that a fully-mocked client-double test could never detect.

Common mistake: Mocking WeatherApiClient itself (rather than only its transport) to directly return a WeatherResult object would skip the actual JSON parsing entirely, silently hiding any bug in how the real API's response shape gets turned into a WeatherResult.

declare(strict_types=1);

final class OrderRepositoryIntegrationTest extends \PHPUnit\Framework\TestCase
{
    private \PDO $pdo;

    protected function setUp(): void
    {
        $this->pdo = new \PDO($_ENV['TEST_DATABASE_DSN'], $_ENV['TEST_DB_USER'], $_ENV['TEST_DB_PASS']);
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
        $this->pdo->beginTransaction();
    }

    protected function tearDown(): void
    {
        $this->pdo->rollBack(); // discard everything this test wrote — next test starts clean
    }

    public function testSavingAnOrderPersistsAllLineItems(): void
    {
        $repository = new OrderRepository($this->pdo);

        $order = new Order([
            new LineItem('Widget', 2, 500),
            new LineItem('Gadget', 1, 1200),
        ]);

        $id = $repository->save($order);
        $reloaded = $repository->find($id);

        $this->assertCount(2, $reloaded->lineItems());
        $this->assertSame(2200, $reloaded->totalInCents());
    }

    public function testFindingAMissingOrderReturnsNull(): void
    {
        $repository = new OrderRepository($this->pdo);

        $this->assertNull($repository->find(999999));

…
declare(strict_types=1);

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;

final class WeatherApiClientIntegrationTest extends \PHPUnit\Framework\TestCase
{
    public function testParsesACompleteSuccessResponse(): void
    {
        $mock = new MockHandler([
            new Response(200, ['Content-Type' => 'application/json'], json_encode([
                'temp_celsius' => 21.5,
                'conditions' => 'partly-cloudy',
            ])),
        ]);
        $guzzle = new Client(['handler' => HandlerStack::create($mock)]);

        $client = new WeatherApiClient($guzzle);
        $result = $client->getCurrentWeather('London');

        $this->assertSame(21.5, $result->temperatureCelsius());
        $this->assertSame('partly-cloudy', $result->conditions());
    }

    public function testThrowsOnA500ResponseFromTheApi(): void
    {
        $mock = new MockHandler([new Response(500, [], 'Internal Server Error')]);
        $guzzle = new Client(['handler' => HandlerStack::create($mock)]);

        $client = new WeatherApiClient($guzzle);

        $this->expectException(WeatherApiUnavailableException::class);
 
…

Where each integration technique sits between 'fast/fake' and 'slow/real'

Pick the point on this spectrum that matches what the specific test actually needs to prove.

Fast, less real                                                    Slow, most real
  <------------------------------------------------------------------------------>

  In-memory fake       Local mock server        Real backend         Ephemeral container
  (array cache,        (Guzzle MockHandler,      (real Redis/         (Testcontainers: real
   sync queue driver)   local WireMock)           test PostgreSQL)     Docker instance, per run)

  Use for: most         Use for: HTTP client       Use for: DB          Use for: dependencies
  cache/queue tests      integration tests          integration          where exact engine
                                                     tests (rolled        behavior genuinely
                                                     back per test)       must be verified

Important Rules

Use a real (test) database of the same engine/version as production, isolated per test via a rolled-back transaction.

Scope filesystem tests to a fresh temp directory (or a virtual filesystem like vfsStream) created and destroyed per test.

Test HTTP client integrations against a local mock server or a transport-level mock (Guzzle's MockHandler) — never the real internet.

Use fast in-memory/sync drivers for most queue and cache tests, reserving real-backend tests for a focused subset.

Reach for ephemeral containers (Testcontainers-style) only for dependencies whose exact real behavior genuinely can't be approximated another way.

Never let an automated test suite read or write a real production or shared development database, filesystem, or third-party account.

How this looks in a real CI pipeline

A typical PHP project's CI spins up a PostgreSQL service container (via GitHub Actions' or GitLab CI's built-in service-container support — functionally the same idea as Testcontainers, configured at the pipeline level instead of from within the test code) that every database integration test runs against, with each test wrapped in a rolled-back transaction. HTTP integrations with third-party APIs are tested against Guzzle's MockHandler for the bulk of cases, with a small, separately-tagged suite of 'live' tests run only manually or on a schedule against the provider's official sandbox environment, specifically to catch a real-world contract change without running on every single push.

protected function setUp(): void { $this->pdo = new PDO('mysql:host=prod-db.internal;dbname=app'); } // tests run real INSERTs/DELETEs against the shared production database protected function setUp(): void { $this->pdo = new PDO($_ENV['TEST_DATABASE_DSN']); // a dedicated test database $this->pdo->beginTransaction(); }

Pointing any test at a real production or shared development database risks permanently corrupting real data the moment a test writes, deletes, or (worse) a rolled-back assumption doesn't hold — tests must run against a dedicated, disposable test database, isolated per test by a transaction that's always rolled back.

// Integration test makes a real HTTP call to the live third-party payment API on every CI run.

// Integration test runs against Guzzle's MockHandler or a local mock server configured with the exact response shapes to verify.

Calling a real third-party API from CI is slow, flaky under network issues or rate limits, can incur real costs or side effects on someone else's system, and makes the test's pass/fail depend on that third party's uptime rather than on your own code's correctness.

Why is a rolled-back transaction the standard technique for database integration tests, rather than truncating tables between every test? A. It's the only way PHPUnit allows database access. B. It gives fast, clean per-test isolation — every write vanishes on rollback — without the overhead of truncating and reseeding tables before each test. C. It prevents the test database from ever needing a schema migration. D. It automatically mocks the database so no real SQL runs.

B. It gives fast, clean per-test isolation — every write vanishes on rollback — without the overhead of truncating and reseeding tables before each test. — Wrapping each test in a transaction that's always rolled back gives every test a clean starting state cheaply and quickly, while still exercising real SQL and real constraints — truncating and reseeding between every test achieves similar isolation but far more slowly.

What does Guzzle's MockHandler actually replace when testing an HTTP client integration? A. The entire HTTP client class, including its request-building and response-parsing logic. B. Only the transport layer (the actual network call) — the client's real request-building and response-parsing code still runs against the canned response. C. PHP's built-in json_decode() function globally. D. The DNS resolution step only.

B. Only the transport layer (the actual network call) — the client's real request-building and response-parsing code still runs against the canned response. — MockHandler intercepts only how the request reaches (or, here, doesn't reach) the network — everything else, including how the client builds the request and parses the response body, still runs for real, which is what makes this closer to an integration test than a fully mocked client would be.

What does the Testcontainers pattern provide that an in-memory fake or a mocked dependency cannot? A. Faster test execution than any other technique. B. A real, disposable instance of the actual external dependency (exact engine and version), fresh and isolated for each test run, without needing a shared persistent test environment. C. Automatic code coverage reporting. D. The ability to skip writing any tests for that dependency at all.

B. A real, disposable instance of the actual external dependency (exact engine and version), fresh and isolated for each test run, without needing a shared persistent test environment. — Testcontainers-style ephemeral containers give you the real dependency's real behavior — not an approximation — spun up fresh and torn down per test run, which is the strongest realism available short of testing directly against production infrastructure, at the cost of slower startup than a fake or mock.

Why should an automated integration test suite never point at a real production or shared development database? A. Because PHPUnit technically forbids it. B. Because tests can write, modify, or delete real data, and a shared, mutable environment also introduces flaky failures caused by other people's or processes' unrelated changes. C. Because production databases don't support PDO connections. D. Because it would make the tests run faster than intended.

B. Because tests can write, modify, or delete real data, and a shared, mutable environment also introduces flaky failures caused by other people's or processes' unrelated changes. — A test suite running against real or shared data risks permanently corrupting it, and even without corruption, a shared mutable environment means tests can fail or pass depending on state left behind by something entirely unrelated — integration tests need a dedicated, disposable test database.

Write an integration test for a CsvExporter class whose export(array $rows, string $path): void method writes an array of associative arrays to a real CSV file at the given path. The test should use a fresh temporary directory (created in setUp(), deleted in tearDown()), call export(), then read the file back and assert its contents (including the header row) are correct.

protected function setUp(): void { $this->tempDir = sys_get_temp_dir() . '/csvtest_' . uniqid(); mkdir($this->tempDir); } protected function tearDown(): void { array_map('unlink', glob($this->tempDir . '/*')); rmdir($this->tempDir); } public function testExportWritesHeaderAndRows(): void { $path = $this->tempDir . '/out.csv'; (new CsvExporter())->export([ ['name' => 'Alice', 'age' => 30], ['name' => 'Bob', 'age' => 25], ], $path); $this->assertFileExists($path); $lines = file($path, FILE_IGNORE_NEW_LINES); $this->assertSame('name,age', $lines[0]); $this->assertSame('Alice,30', $lines[1]); $this->assertSame('Bob,25', $lines[2]); }

Use sys_get_temp_dir() . '/csvtest_' . uniqid() for a unique directory per test run, and fgetcsv() or explode("\n", ...) to verify the written content.