Integration Testing in PHP
Introduction to Integration Testing
Integration testing focuses on verifying that different components, modules, or services work correctly when combined. Unlike unit tests that test isolated components, integration tests validate the interactions between multiple parts of your application.
Types of Integration Testing
Database Integration Testing
<?php
use PHPUnit\Framework\TestCase;
class UserRepositoryIntegrationTest extends TestCase
{
private $pdo;
private $userRepository;
protected function setUp(): void
{
// Use test database
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create test schema
$this->createTestSchema();
$this->userRepository = new UserRepository($this->pdo);
}
private function createTestSchema(): void
{
$this->pdo->exec('
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
');
}
public function testCreateAndRetrieveUser(): void
{
// Create user
$userData = [
'name' => 'John Doe',
'email' => '[email protected]'
];
$userId = $this->userRepository->create($userData);
// Retrieve and verify
$user = $this->userRepository->findById($userId);
$this->assertNotNull($user);
$this->assertEquals('John Doe', $user['name']);
$this->assertEquals('[email protected]', $user['email']);
}
public function testUserEmailUniqueness(): void
{
// Create first user
$this->userRepository->create([
'name' => 'John Doe',
'email' => '[email protected]'
]);
// Attempt to create user with same email
$this->expectException(PDOException::class);
$this->userRepository->create([
'name' => 'Jane Doe',
'email' => '[email protected]'
]);
}
public function testTransactionalBehavior(): void
{
$this->pdo->beginTransaction();
try {
$this->userRepository->create([
'name' => 'Test User',
'email' => '[email protected]'
]);
// Simulate error
throw new Exception('Test error');
} catch (Exception $e) {
$this->pdo->rollBack();
}
// Verify user was not created
$users = $this->userRepository->findAll();
$this->assertCount(0, $users);
}
}
?>
API Integration Testing
<?php
use PHPUnit\Framework\TestCase;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
class PaymentServiceIntegrationTest extends TestCase
{
private $client;
private $paymentService;
protected function setUp(): void
{
$this->client = new Client([
'base_uri' => 'https://api.example.com/v1/',
'timeout' => 30
]);
$this->paymentService = new PaymentService($this->client);
}
public function testProcessPaymentSuccess(): void
{
// Mock successful payment response
$mock = new MockHandler([
new Response(200, [], json_encode([
'id' => 'txn_123456',
'status' => 'completed',
'amount' => 1000
]))
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
$paymentService = new PaymentService($client);
$result = $paymentService->processPayment([
'amount' => 1000,
'currency' => 'USD',
'card_token' => 'tok_123456'
]);
$this->assertTrue($result['success']);
$this->assertEquals('txn_123456', $result['transaction_id']);
}
public function testProcessPaymentFailure(): void
{
// Mock failed payment response
$mock = new MockHandler([
new Response(400, [], json_encode([
'error' => 'insufficient_funds',
'message' => 'Insufficient funds'
]))
]);
$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);
$paymentService = new PaymentService($client);
$result = $paymentService->processPayment([
'amount' => 1000,
'currency' => 'USD',
'card_token' => 'tok_invalid'
]);
$this->assertFalse($result['success']);
$this->assertEquals('insufficient_funds', $result['error']);
}
/**
* @group integration
* @group external
*/
public function testRealApiConnection(): void
{
// Skip if not in integration test environment
if (!getenv('RUN_INTEGRATION_TESTS')) {
$this->markTestSkipped('Integration tests disabled');
}
// Test with real API (use test credentials)
$paymentService = new PaymentService(new Client([
'base_uri' => getenv('PAYMENT_API_URL'),
'headers' => [
'Authorization' => 'Bearer ' . getenv('TEST_API_KEY')
]
]));
$result = $paymentService->validateApiConnection();
$this->assertTrue($result);
}
}
?>
Service Integration Testing
<?php
use PHPUnit\Framework\TestCase;
class OrderProcessingIntegrationTest extends TestCase
{
private $container;
private $orderService;
private $inventoryService;
private $paymentService;
private $emailService;
protected function setUp(): void
{
// Set up dependency injection container
$this->container = new Container();
// Configure test services
$this->setupTestServices();
$this->orderService = $this->container->get(OrderService::class);
$this->inventoryService = $this->container->get(InventoryService::class);
$this->paymentService = $this->container->get(PaymentService::class);
$this->emailService = $this->container->get(EmailService::class);
}
private function setupTestServices(): void
{
// Use test database
$this->container->set('db', function() {
$pdo = new PDO('sqlite::memory:');
$this->createTestSchema($pdo);
return $pdo;
});
// Mock external services
$this->container->set(PaymentGateway::class, function() {
return new MockPaymentGateway();
});
$this->container->set(EmailProvider::class, function() {
return new MockEmailProvider();
});
}
public function testCompleteOrderWorkflow(): void
{
// Setup test data
$product = $this->createTestProduct();
$customer = $this->createTestCustomer();
// Add inventory
$this->inventoryService->addStock($product['id'], 10);
// Create order
$orderData = [
'customer_id' => $customer['id'],
'items' => [
[
'product_id' => $product['id'],
'quantity' => 2,
'price' => 29.99
]
],
'payment_method' => 'credit_card',
'payment_token' => 'tok_test_123'
];
$order = $this->orderService->processOrder($orderData);
// Verify order was created
$this->assertNotNull($order['id']);
$this->assertEquals('completed', $order['status']);
// Verify inventory was updated
$stock = $this->inventoryService->getStock($product['id']);
$this->assertEquals(8, $stock); // 10 - 2
// Verify payment was processed
$payment = $this->paymentService->getPayment($order['payment_id']);
$this->assertEquals('completed', $payment['status']);
// Verify email was sent
$emails = $this->emailService->getSentEmails();
$this->assertCount(1, $emails);
$this->assertEquals($customer['email'], $emails[0]['to']);
$this->assertStringContainsString('Order Confirmation', $emails[0]['subject']);
}
public function testOrderFailsWhenInsufficientStock(): void
{
$product = $this->createTestProduct();
$customer = $this->createTestCustomer();
// Add limited inventory
$this->inventoryService->addStock($product['id'], 1);
$orderData = [
'customer_id' => $customer['id'],
'items' => [
[
'product_id' => $product['id'],
'quantity' => 5, // More than available
'price' => 29.99
]
]
];
$this->expectException(InsufficientStockException::class);
$this->orderService->processOrder($orderData);
}
public function testOrderRollbackOnPaymentFailure(): void
{
$product = $this->createTestProduct();
$customer = $this->createTestCustomer();
$this->inventoryService->addStock($product['id'], 10);
$orderData = [
'customer_id' => $customer['id'],
'items' => [
[
'product_id' => $product['id'],
'quantity' => 2,
'price' => 29.99
]
],
'payment_method' => 'credit_card',
'payment_token' => 'tok_will_fail'
];
try {
$this->orderService->processOrder($orderData);
$this->fail('Expected PaymentException was not thrown');
} catch (PaymentException $e) {
// Verify inventory was rolled back
$stock = $this->inventoryService->getStock($product['id']);
$this->assertEquals(10, $stock); // Should be unchanged
// Verify no order was created
$orders = $this->orderService->getOrdersByCustomer($customer['id']);
$this->assertCount(0, $orders);
}
}
private function createTestProduct(): array
{
return [
'id' => 1,
'name' => 'Test Product',
'price' => 29.99,
'sku' => 'TEST-001'
];
}
private function createTestCustomer(): array
{
return [
'id' => 1,
'name' => 'John Doe',
'email' => '[email protected]'
];
}
}
?>
Testing Strategies
Test Data Management
<?php
class DatabaseTestCase extends PHPUnit\Framework\TestCase
{
protected $pdo;
protected $fixtures = [];
protected function setUp(): void
{
$this->pdo = $this->createTestDatabase();
$this->loadFixtures();
}
protected function tearDown(): void
{
$this->cleanupDatabase();
}
private function createTestDatabase(): PDO
{
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Create schema
$schema = file_get_contents(__DIR__ . '/fixtures/schema.sql');
$pdo->exec($schema);
return $pdo;
}
private function loadFixtures(): void
{
foreach ($this->fixtures as $table => $data) {
$this->insertFixtureData($table, $data);
}
}
private function insertFixtureData(string $table, array $data): void
{
foreach ($data as $row) {
$columns = implode(', ', array_keys($row));
$placeholders = ':' . implode(', :', array_keys($row));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($row);
}
}
private function cleanupDatabase(): void
{
// Clear all tables
$tables = $this->pdo->query("SELECT name FROM sqlite_master WHERE type='table'")
->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) {
$this->pdo->exec("DELETE FROM {$table}");
}
}
protected function assertDatabaseHas(string $table, array $criteria): void
{
$conditions = [];
$values = [];
foreach ($criteria as $column => $value) {
$conditions[] = "{$column} = ?";
$values[] = $value;
}
$sql = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $conditions);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($values);
$count = $stmt->fetchColumn();
$this->assertGreaterThan(0, $count,
"Failed asserting that table '{$table}' contains matching record");
}
protected function assertDatabaseCount(string $table, int $expectedCount): void
{
$stmt = $this->pdo->query("SELECT COUNT(*) FROM {$table}");
$actualCount = $stmt->fetchColumn();
$this->assertEquals($expectedCount, $actualCount,
"Failed asserting that table '{$table}' has {$expectedCount} records");
}
}
class UserIntegrationTest extends DatabaseTestCase
{
protected $fixtures = [
'users' => [
['id' => 1, 'name' => 'John Doe', 'email' => '[email protected]'],
['id' => 2, 'name' => 'Jane Smith', 'email' => '[email protected]']
]
];
public function testUserCreation(): void
{
$userService = new UserService($this->pdo);
$userData = [
'name' => 'Bob Johnson',
'email' => '[email protected]'
];
$user = $userService->createUser($userData);
$this->assertDatabaseHas('users', [
'name' => 'Bob Johnson',
'email' => '[email protected]'
]);
$this->assertDatabaseCount('users', 3); // 2 fixtures + 1 new
}
}
?>
Environment Configuration
<?php
class TestEnvironment
{
public static function setup(): void
{
// Set test environment variables
putenv('APP_ENV=testing');
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
putenv('CACHE_DRIVER=array');
putenv('QUEUE_CONNECTION=sync');
putenv('MAIL_MAILER=array');
}
public static function isIntegrationTestEnabled(): bool
{
return getenv('RUN_INTEGRATION_TESTS') === 'true';
}
public static function skipIfIntegrationDisabled(): void
{
if (!self::isIntegrationTestEnabled()) {
throw new PHPUnit\Framework\SkippedTestError(
'Integration tests are disabled'
);
}
}
}
abstract class IntegrationTestCase extends PHPUnit\Framework\TestCase
{
protected function setUp(): void
{
TestEnvironment::skipIfIntegrationDisabled();
TestEnvironment::setup();
parent::setUp();
}
}
?>
Container Testing
<?php
use Psr\Container\ContainerInterface;
class ContainerIntegrationTest extends PHPUnit\Framework\TestCase
{
private $container;
protected function setUp(): void
{
$this->container = $this->createTestContainer();
}
private function createTestContainer(): ContainerInterface
{
$container = new Container();
// Configure services
$container->set('config', [
'database' => [
'dsn' => 'sqlite::memory:',
'options' => [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
]
]);
$container->set(PDO::class, function($c) {
$config = $c->get('config')['database'];
return new PDO($config['dsn'], null, null, $config['options']);
});
$container->set(UserRepository::class, function($c) {
return new UserRepository($c->get(PDO::class));
});
$container->set(UserService::class, function($c) {
return new UserService($c->get(UserRepository::class));
});
return $container;
}
public function testServiceResolution(): void
{
$userService = $this->container->get(UserService::class);
$this->assertInstanceOf(UserService::class, $userService);
}
public function testServiceDependencies(): void
{
$userService = $this->container->get(UserService::class);
$userRepository = $this->container->get(UserRepository::class);
// Verify that the same repository instance is injected
$reflection = new ReflectionClass($userService);
$property = $reflection->getProperty('repository');
$property->setAccessible(true);
$this->assertSame($userRepository, $property->getValue($userService));
}
public function testSingletonBehavior(): void
{
$service1 = $this->container->get(UserService::class);
$service2 = $this->container->get(UserService::class);
$this->assertSame($service1, $service2);
}
}
?>
CI/CD Integration
GitHub Actions Configuration
# .github/workflows/integration-tests.yml
name: Integration Tests
on: [push, pull_request]
jobs:
integration:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: test_db
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
redis:
image: redis:alpine
ports:
- 6379:6379
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mbstring, dom, fileinfo, mysql, redis
coverage: xdebug
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Setup environment
run: |
cp .env.testing .env
php artisan key:generate
- name: Run database migrations
run: php artisan migrate --force
- name: Run integration tests
env:
RUN_INTEGRATION_TESTS: true
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: test_db
DB_USERNAME: root
DB_PASSWORD: password
REDIS_HOST: 127.0.0.1
REDIS_PORT: 6379
run: vendor/bin/phpunit --group integration
Test Configuration
<?php
// phpunit.xml configuration for integration tests
?>
<phpunit>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory suffix="Test.php">./tests/Integration</directory>
</testsuite>
</testsuites>
<groups>
<include>
<group>integration</group>
</include>
<exclude>
<group>external</group>
</exclude>
</groups>
<php>
<env name="APP_ENV" value="testing"/>
<env name="RUN_INTEGRATION_TESTS" value="true"/>
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="test_db"/>
</php>
</phpunit>
Integration testing ensures that your application components work together correctly, providing confidence in your system's overall functionality and helping catch issues that unit tests might miss.