PHP Integration Testing: Strategies and Tools Guide

PHP Integration Testing: Strategies and Tools Guide

Integration tests verify that PHP components work together: your service classes with a real database, your HTTP controllers with real routing, your external API clients against real or stubbed endpoints. This guide covers database integration testing with transactions, HTTP layer testing, testing with real queues and caches, and integrating with CI.

Key Takeaways

Integration tests use real infrastructure, not mocks. Unit tests mock the database. Integration tests use SQLite in-memory or a test MySQL database. The goal is to catch bugs that exist in the interaction between components.

Database transactions are the fastest reset mechanism. Begin a transaction in setUp, rollback in tearDown. Your data changes don't persist between tests, and you don't need to truncate tables.

HTTP integration tests use Guzzle or Symfony's BrowserKit. For APIs, make real HTTP requests against a locally running app (or use in-process testing in Symfony/Laravel).

SQLite in-memory is fast; real MySQL is accurate. SQLite runs 10× faster but has SQL dialect differences (no FULL OUTER JOIN, no JSON_CONTAINS, etc.). Use SQLite for speed in CI; MySQL for accuracy when your queries rely on MySQL-specific behavior.

Integration test scope: one real component at a time. A database integration test uses a real DB but still mocks external HTTP APIs. Don't make all dependencies real simultaneously — that's an E2E test.

Integration Tests vs Unit Tests

Unit tests verify logic by mocking all dependencies. Integration tests verify that components work together with real dependencies.

Unit test:
  OrderService ←→ MockRepository   (mock returns fake data)

Integration test:
  OrderService ←→ RealRepository ←→ Real Database

What integration tests catch that unit tests miss:

  • Wrong SQL queries — a WHERE clause that returns no rows in production
  • ORM configuration errors — relationships mapped incorrectly
  • Transaction edge cases — commit/rollback behavior
  • Schema mismatches — column name or type changes not reflected in code
  • Query performance — N+1 problems that don't appear with a single mock object

Setting Up a Test Database

Option 1: SQLite In-Memory (Fast)

// phpunit.xml
<php>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
</php>

For plain PHP with PDO:

abstract class DatabaseTestCase extends TestCase
{
    protected static PDO $pdo;

    public static function setUpBeforeClass(): void
    {
        static::$pdo = new PDO('sqlite::memory:');
        static::$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        static::$pdo->exec(file_get_contents(__DIR__ . '/../schema.sql'));
    }

    protected function setUp(): void
    {
        static::$pdo->beginTransaction();
    }

    protected function tearDown(): void
    {
        static::$pdo->rollBack();
    }
}

Every test wraps its changes in a transaction and rolls back. The schema runs once.

Option 2: MySQL Test Database

More accurate than SQLite — your test database runs the same engine as production:

// .env.testing
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapp_test
DB_USERNAME=test
DB_PASSWORD=test

Create the test database:

CREATE DATABASE myapp_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL ON myapp_test.* TO 'test'@'localhost' IDENTIFIED BY 'test';

Reset between test runs:

# Drop and recreate before each test run
mysql -u <span class="hljs-built_in">test -ptest -e <span class="hljs-string">"DROP DATABASE myapp_test; CREATE DATABASE myapp_test;"
php vendor/bin/phinx migrate -e testing

Option 3: Testcontainers (Docker in CI)

Testcontainers PHP spins up a real MySQL container during tests:

composer require testcontainers/testcontainers
use Testcontainers\Container\GenericContainer;
use Testcontainers\Wait\WaitForTcpPort;

class ContainerDatabaseTest extends TestCase
{
    private static GenericContainer $container;
    private static PDO $pdo;

    public static function setUpBeforeClass(): void
    {
        static::$container = GenericContainer::make('mysql:8.0')
            ->withEnv('MYSQL_ROOT_PASSWORD', 'root')
            ->withEnv('MYSQL_DATABASE', 'testdb')
            ->withExposedPorts(3306)
            ->withWaitStrategy(WaitForTcpPort::make(3306))
            ->start();

        $port = static::$container->getMappedPort(3306);
        static::$pdo = new PDO("mysql:host=127.0.0.1;port={$port};dbname=testdb", 'root', 'root');
        // run migrations...
    }

    public static function tearDownAfterClass(): void
    {
        static::$container->stop();
    }
}

Database Integration Tests: Patterns

Testing a Repository

class UserRepositoryTest extends DatabaseTestCase
{
    private UserRepository $repo;

    protected function setUp(): void
    {
        parent::setUp();
        $this->repo = new UserRepository(static::$pdo);
    }

    public function test_find_by_email_returns_user(): void
    {
        static::$pdo->exec("INSERT INTO users (id, email, name) VALUES (1, 'alice@example.com', 'Alice')");

        $user = $this->repo->findByEmail('alice@example.com');

        $this->assertNotNull($user);
        $this->assertEquals('Alice', $user->name);
    }

    public function test_find_by_email_returns_null_for_unknown(): void
    {
        $user = $this->repo->findByEmail('nobody@example.com');
        $this->assertNull($user);
    }

    public function test_save_persists_new_user(): void
    {
        $user = new User(null, 'bob@example.com', 'Bob');
        $saved = $this->repo->save($user);

        $this->assertNotNull($saved->id);

        // Verify it's actually in the database
        $stmt = static::$pdo->prepare("SELECT * FROM users WHERE id = ?");
        $stmt->execute([$saved->id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        $this->assertEquals('bob@example.com', $row['email']);
    }

    public function test_save_updates_existing_user(): void
    {
        static::$pdo->exec("INSERT INTO users (id, email, name) VALUES (1, 'alice@example.com', 'Alice')");

        $user = new User(1, 'alice@example.com', 'Alice Updated');
        $this->repo->save($user);

        $stmt = static::$pdo->prepare("SELECT name FROM users WHERE id = 1");
        $stmt->execute();
        $name = $stmt->fetchColumn();

        $this->assertEquals('Alice Updated', $name);
    }
}

Testing a Service with Real Database

class OrderServiceIntegrationTest extends DatabaseTestCase
{
    private OrderService $service;
    private MockInterface $mockEmail; // only mock external dependencies

    protected function setUp(): void
    {
        parent::setUp();
        $this->mockEmail = Mockery::mock(EmailService::class);
        $this->mockEmail->shouldReceive('sendConfirmation')->andReturn(true);

        $repo = new OrderRepository(static::$pdo);
        $productRepo = new ProductRepository(static::$pdo);
        $this->service = new OrderService($repo, $productRepo, $this->mockEmail);

        // Seed product data
        static::$pdo->exec("INSERT INTO products (id, name, price, stock) VALUES (1, 'Widget', 9.99, 100)");
    }

    protected function tearDown(): void
    {
        Mockery::close();
        parent::tearDown();
    }

    public function test_place_order_reduces_product_stock(): void
    {
        $order = $this->service->placeOrder([
            'customer_email' => 'buyer@example.com',
            'items' => [['product_id' => 1, 'quantity' => 3]],
        ]);

        $this->assertNotNull($order->id);

        // Check stock was reduced
        $stmt = static::$pdo->prepare("SELECT stock FROM products WHERE id = 1");
        $stmt->execute();
        $stock = $stmt->fetchColumn();

        $this->assertEquals(97, $stock); // 100 - 3
    }

    public function test_place_order_rolls_back_on_payment_failure(): void
    {
        $this->mockEmail->shouldReceive('sendConfirmation')->never();

        $mockPayment = Mockery::mock(PaymentGateway::class);
        $mockPayment->shouldReceive('charge')->andThrow(new PaymentException('Card declined'));

        $service = new OrderService(
            new OrderRepository(static::$pdo),
            new ProductRepository(static::$pdo),
            $this->mockEmail,
            $mockPayment
        );

        $this->expectException(PaymentException::class);
        $service->placeOrder([
            'customer_email' => 'buyer@example.com',
            'items' => [['product_id' => 1, 'quantity' => 5]],
        ]);

        // Stock should be unchanged — transaction rolled back
        $stmt = static::$pdo->prepare("SELECT stock FROM products WHERE id = 1");
        $stmt->execute();
        $this->assertEquals(100, $stmt->fetchColumn());
    }
}

HTTP Integration Testing

Testing with Symfony's HttpKernel

Symfony's KernelBrowser makes real HTTP requests to your Symfony app in-process:

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

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

        $client->request('GET', '/api/products/1', [], [], [
            'HTTP_ACCEPT' => 'application/json',
        ]);

        $this->assertResponseIsSuccessful();
        $this->assertResponseFormatSame('json');

        $data = json_decode($client->getResponse()->getContent(), true);
        $this->assertEquals(1, $data['id']);
    }

    public function test_create_product_requires_authentication(): void
    {
        $client = static::createClient();

        $client->request('POST', '/api/products', [], [], [
            'CONTENT_TYPE' => 'application/json',
        ], json_encode(['name' => 'Test', 'price' => 9.99]));

        $this->assertResponseStatusCodeSame(401);
    }
}

Testing APIs with Guzzle

For testing an external API or your own running server:

use GuzzleHttp\Client;

class ProductApiTest extends TestCase
{
    private Client $client;

    protected function setUp(): void
    {
        $this->client = new Client([
            'base_uri' => 'http://localhost:8000',
            'http_errors' => false, // don't throw on 4xx/5xx
        ]);
    }

    public function test_get_products_returns_array(): void
    {
        $response = $this->client->get('/api/products');

        $this->assertEquals(200, $response->getStatusCode());

        $data = json_decode($response->getBody(), true);
        $this->assertIsArray($data['data']);
    }

    public function test_create_product_validates_input(): void
    {
        $response = $this->client->post('/api/products', [
            'json' => ['price' => 9.99], // missing name
        ]);

        $this->assertEquals(422, $response->getStatusCode());

        $errors = json_decode($response->getBody(), true);
        $this->assertArrayHasKey('name', $errors['errors']);
    }
}

Mocking HTTP Calls with Guzzle Mock Handler

When your code calls external APIs, use Guzzle's MockHandler:

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

class WeatherServiceTest extends TestCase
{
    public function test_get_weather_parses_api_response(): void
    {
        $mockHandler = new MockHandler([
            new Response(200, [], json_encode([
                'main' => ['temp' => 22.5],
                'weather' => [['description' => 'clear sky']],
            ])),
        ]);

        $client = new Client(['handler' => HandlerStack::create($mockHandler)]);
        $service = new WeatherService($client);

        $weather = $service->getWeather('London');

        $this->assertEquals(22.5, $weather->temperature);
        $this->assertEquals('clear sky', $weather->description);
    }

    public function test_get_weather_throws_on_api_error(): void
    {
        $mockHandler = new MockHandler([
            new Response(503, [], 'Service Unavailable'),
        ]);

        $client = new Client(['handler' => HandlerStack::create($mockHandler)]);
        $service = new WeatherService($client);

        $this->expectException(WeatherApiException::class);
        $service->getWeather('London');
    }
}

Redis Integration Testing

For testing code that uses Redis (caching, queues, sessions):

class CacheServiceTest extends TestCase
{
    private Redis $redis;
    private CacheService $cache;

    protected function setUp(): void
    {
        $this->redis = new Redis();
        $this->redis->connect('127.0.0.1', 6379);
        $this->redis->select(15); // Use database 15 for tests

        $this->cache = new CacheService($this->redis);
    }

    protected function tearDown(): void
    {
        $this->redis->flushDB(); // Clear test database after each test
        $this->redis->close();
    }

    public function test_set_and_get_returns_stored_value(): void
    {
        $this->cache->set('user:1', ['name' => 'Alice'], ttl: 300);

        $value = $this->cache->get('user:1');

        $this->assertEquals(['name' => 'Alice'], $value);
    }

    public function test_expired_key_returns_null(): void
    {
        $this->cache->set('temp:key', 'value', ttl: 1);
        sleep(2);

        $value = $this->cache->get('temp:key');

        $this->assertNull($value);
    }
}

Fixture Management

Database Seeders for Tests

class TestSeeder
{
    public static function seed(PDO $pdo): void
    {
        $pdo->exec("INSERT INTO categories (id, name) VALUES (1, 'Electronics'), (2, 'Books')");
        $pdo->exec("INSERT INTO products (id, name, price, category_id) VALUES
            (1, 'Laptop', 999.99, 1),
            (2, 'PHP Manual', 29.99, 2),
            (3, 'Mouse', 24.99, 1)
        ");
    }
}

class ProductQueryTest extends DatabaseTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        TestSeeder::seed(static::$pdo);
    }

    public function test_filter_by_category_returns_correct_products(): void
    {
        $repo = new ProductRepository(static::$pdo);
        $products = $repo->findByCategory(1);

        $this->assertCount(2, $products); // Laptop + Mouse, not PHP Manual
    }
}

Factory Pattern for Test Data

class UserFactory
{
    private static int $sequence = 0;

    public static function create(PDO $pdo, array $overrides = []): array
    {
        static::$sequence++;
        $defaults = [
            'email' => "user{$sequence}@example.com",
            'name'  => "Test User {$sequence}",
            'role'  => 'user',
        ];
        $data = array_merge($defaults, $overrides);

        $stmt = $pdo->prepare("INSERT INTO users (email, name, role) VALUES (?, ?, ?)");
        $stmt->execute(array_values($data));
        $data['id'] = $pdo->lastInsertId();

        return $data;
    }
}

CI/CD Integration

GitHub Actions with MySQL

name: PHP Integration Tests

on: [push, pull_request]

jobs:
  integration-tests:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-retries=5
      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: pdo_mysql, redis

      - run: composer install --no-interaction

      - name: Run database migrations
        env:
          DB_HOST: 127.0.0.1
          DB_DATABASE: testdb
          DB_USERNAME: root
          DB_PASSWORD: root
        run: php vendor/bin/phinx migrate

      - name: Run integration tests
        env:
          DB_HOST: 127.0.0.1
          DB_DATABASE: testdb
          DB_USERNAME: root
          DB_PASSWORD: root
          REDIS_HOST: 127.0.0.1
        run: ./vendor/bin/phpunit --testsuite Integration

Integration vs Unit: What to Test Where

Scenario Unit Test Integration Test
Business logic calculation
SQL query returns correct rows
ORM relationship loading
HTTP status code for valid input ✓ (mock HTTP) ✓ (real HTTP)
Redis TTL behavior
Email content generation
Email actually sent ✓ (with MailHog)
External API response parsing ✓ (mock Guzzle)
Full checkout flow

Beyond Integration: Live Monitoring with HelpMeTest

Integration tests verify your PHP components work together. But they don't run in a real browser, don't test your CDN, and don't catch issues specific to your production infrastructure.

HelpMeTest bridges that gap by running Robot Framework + Playwright tests against your live PHP application — testing the full stack from a real browser perspective, 24/7. It catches JavaScript errors, broken redirects, and third-party integration failures that no PHP integration test can find.

Free tier: 10 tests with 5-minute monitoring intervals. Pro: $100/month, unlimited tests.

Read more