Integration and Functional Testing
Test how components work together for real.
Integration and Functional Testing is a free PHP Academy lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the PHP Academy learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Beyond the Unit
Unit tests prove a class works in isolation with everything mocked. But mocks can lie — the real database, framework router, or HTTP layer behaves differently. Integration tests verify two or more real components wired together; functional tests exercise the application end-to-end through its public entry point (an HTTP request, a console command). This lesson positions and writes both.
The Test Pyramid
A healthy suite is shaped like a pyramid:
- Many unit tests — fast, isolated, run constantly.
- Fewer integration tests — real DB/queue/cache, slower.
- Few functional/E2E tests — full stack, slowest, most brittle.
Inverting this (an 'ice-cream cone' of mostly E2E tests) yields a slow, flaky suite. Push coverage down whenever possible.
Integration: Repository Against a Real DB
An integration test for a repository uses an actual database connection — not a mock — to catch SQL, schema, and mapping bugs. Use a dedicated test database (or an in-memory SQLite that matches your dialect closely enough) and a real PDO.
<?php
use PHPUnit\Framework\TestCase;
final class UserRepositoryTest extends TestCase
{
private \PDO $pdo;
protected function setUp(): void
{
$this->pdo = new \PDO('sqlite::memory:');
$this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)');
}
public function test_persists_and_finds_a_user(): void
{
$repo = new UserRepository($this->pdo);
$id = $repo->create('ada@example.com');
self::assertSame('ada@example.com', $repo->find($id)->email);
}
}
Isolation: Transaction Rollback
Integration tests must not leak state into each other. The fastest pattern is to wrap every test in a transaction and roll it back in tearDown() — the DB returns to a clean slate without re-seeding. (Truncation is the fallback when code under test commits or uses DDL.)
<?php
protected function setUp(): void
{
$this->pdo = TestDb::connection();
$this->pdo->beginTransaction();
}
protected function tearDown(): void
{
$this->pdo->rollBack(); // undo everything this test did
}
Fixtures and Factories
Tests need data. Avoid brittle giant SQL dumps; prefer small factories that build only the rows a test needs, with sensible defaults you override per case. This keeps each test's intent explicit and resilient to schema growth.
<?php
final class UserFactory
{
public static function create(\PDO $pdo, array $overrides = []): int
{
$data = array_merge(['email' => 'user'.uniqid().'@test.dev'], $overrides);
$stmt = $pdo->prepare('INSERT INTO users (email) VALUES (:email)');
$stmt->execute($data);
return (int) $pdo->lastInsertId();
}
}
Functional: Hitting the App Like a Client
A functional test drives the real application kernel/router with a simulated request and asserts on the response — status code, headers, body. Symfony's WebTestCase and Laravel's HTTP test helpers do exactly this without booting a real web server. Below is the Symfony shape.
<?php
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class HealthControllerTest extends WebTestCase
{
public function test_health_endpoint_returns_ok(): void
{
$client = static::createClient();
$client->request('GET', '/health');
self::assertResponseIsSuccessful(); // 2xx
self::assertJson($client->getResponse()->getContent());
}
}
Asserting on the Response
Functional tests assert observable behavior at the boundary: status, JSON shape, side effects in the DB. Below, a POST creates a resource; we check both the HTTP response and that the record actually landed in storage — proving the whole stack cooperated.
<?php
public function test_creating_a_user_persists_it(): void
{
$client = static::createClient();
$client->request('POST', '/users', server: [
'CONTENT_TYPE' => 'application/json',
], content: json_encode(['email' => 'ada@example.com']));
self::assertResponseStatusCodeSame(201);
$repo = static::getContainer()->get(UserRepository::class);
self::assertNotNull($repo->findByEmail('ada@example.com'));
}
Selectively Faking the Edges
Even functional tests should not call real payment providers or send real emails. Replace only the outermost external services — swap them in the test container for fakes/in-memory implementations — while keeping your own code, routing, and DB real. This preserves end-to-end fidelity without flaky network calls.
<?php
public function test_checkout_charges_via_gateway(): void
{
$client = static::createClient();
// Replace the real gateway binding with an in-memory fake:
static::getContainer()->set(PaymentGateway::class, new FakePaymentGateway());
$client->request('POST', '/checkout', content: json_encode(['cart' => 1]));
self::assertResponseIsSuccessful();
}
Separate Suites, Separate Speeds
Split your phpunit.xml into testsuites so the fast unit suite runs on every save and the slow integration/functional suites run on demand or in CI. Group by directory and select with --testsuite.
<phpunit>
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
<testsuite name="functional">
<directory>tests/Functional</directory>
</testsuite>
</testsuites>
</phpunit>Determinism Beats Coverage
The fastest way to lose trust in a suite is flakiness. Make integration/functional tests deterministic:
- Freeze time (inject a clock) instead of calling
time(). - Seed randomness; avoid order-dependent assertions on unsorted queries.
- Reset DB state every test (transaction rollback / truncate).
- Never depend on test execution order.
Match the Test DB to Production
SQLite-in-memory is fast but dialect differences (case sensitivity, JSON functions, foreign-key enforcement, types) can hide bugs that only surface on your real engine. For code that uses engine-specific SQL, run integration tests against the same engine as production — commonly a disposable Docker container in CI — rather than a convenient stand-in.
<?php
// Read connection from environment so CI points at a real Postgres/MySQL container
$dsn = getenv('TEST_DATABASE_URL') ?: 'sqlite::memory:';
$pdo = new \PDO($dsn);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
// In CI, TEST_DATABASE_URL targets the same engine as production.
Quick Check
How should a functional test treat a third-party payment provider?
Recap
You learned to test components together:
- Integration tests use real DBs/services to catch what mocks hide; isolate them with transaction rollback.
- Functional tests drive the real kernel via simulated requests and assert on responses + side effects.
- Factories build minimal data; fake only the outermost external boundaries.
- Split suites by speed and keep slower tests deterministic.
Next: measuring test quality with mutation testing.
Frequently asked questions
Is the “Integration and Functional Testing” lesson free?
Yes — the full text of “Integration and Functional Testing” is free to read here on the web, and the PHP Academy course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the PHP Academy course, upgrade to CoddyKit PRO.
What will I learn in “Integration and Functional Testing”?
Test how components work together for real. You practise PHP Academy with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start PHP Academy?
No prior experience is required. PHP Academy on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Integration and Functional Testing” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this PHP Academy lesson?
Yes. Every PHP Academy lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- The Test-Driven Development Workflow
- Mocking and Stubbing with Mockery
- Integration and Functional Testing
- Mutation Testing with Infection