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 DatabaseWhat integration tests catch that unit tests miss:
- Wrong SQL queries — a
WHEREclause 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=testCreate 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 testingOption 3: Testcontainers (Docker in CI)
Testcontainers PHP spins up a real MySQL container during tests:
composer require testcontainers/testcontainersuse 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 IntegrationIntegration 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.