PHPUnit — Integration & Functional Testing
Unit tests test a single class in isolation. Integration tests verify that multiple classes work correctly together — the service, repository, and database. Functional tests drive the full HTTP stack — send a request, assert the response. A complete test suite has all three layers.
Integration & Functional Testing with PHPUnit
<?php
declare(strict_types=1);
use PHPUnitFrameworkTestCase;
// ── Integration Test — Service + Real In-Memory Repository ────
class PostServiceIntegrationTest extends TestCase {
private PostService $service;
private InMemoryPostRepository $repo;
private EventDispatcher $events;
private array $firedEvents = [];
protected function setUp(): void {
$this->repo = new InMemoryPostRepository();
$this->events = new EventDispatcher();
// Record fired events for assertion
$this->events->listen(PostPublished::class, new class($this->firedEvents) implements EventListenerInterface {
public function __construct(private array &$events) {}
public function handle(object $event): void { $this->events[] = $event; }
});
$this->service = new PostService($this->repo, $this->events);
}
public function test_create_and_immediately_publish(): void {
$post = $this->service->create([
"title" => "Integration Test Post",
"body" => "Hello from the test suite.",
"author_id" => 1,
]);
$this->assertSame("draft", $post->getStatus()->value);
$published = $this->service->publishPost($post->id);
$this->assertSame("published", $published->getStatus()->value);
$this->assertNotNull($published->getPublishedAt());
$this->assertCount(1, $this->firedEvents);
$this->assertInstanceOf(PostPublished::class, $this->firedEvents[0]);
}
public function test_pagination_returns_correct_slice(): void {
for ($i = 1; $i <= 15; $i++) {
$post = $this->service->create(["title" => "Post $i", "body" => "Body", "author_id" => 1]);
$this->service->publishPost($post->id);
}
$page1 = $this->service->listPublished(page: 1, perPage: 10);
$page2 = $this->service->listPublished(page: 2, perPage: 10);
$this->assertCount(10, $page1["items"]);
$this->assertCount(5, $page2["items"]);
$this->assertSame(15, $page1["total"]);
$this->assertSame(2, $page1["last_page"]);
}
public function test_search_returns_only_matching_published(): void {
$match = $this->service->create(["title" => "PHP Closures Deep Dive", "body" => "...", "author_id" => 1]);
$noMatch = $this->service->create(["title" => "JavaScript Promises", "body" => "...", "author_id" => 1]);
$this->service->publishPost($match->id);
$this->service->publishPost($noMatch->id);
$results = $this->service->listPublished(page: 1, perPage: 10, search: "PHP");
$this->assertCount(1, $results["items"]);
$this->assertStringContainsString("PHP", $results["items"][0]->title);
}
}
// ── Functional Test — HTTP layer via PHP built-in server or mocking ───
class PostControllerFunctionalTest extends TestCase {
/** @test */
public function test_post_listing_returns_200_with_json(): void {
// Functional test without a running server — call the controller directly
$repo = new InMemoryPostRepository();
$service = new PostService($repo, new NullEventDispatcher());
$controller = new PostApiController($service, /* auth mock */);
// Seed data
$post = $repo->save(new Post(0, "Test", "test", "Body", PostStatus::Published, new Author("Alice", new Email("a@b.com"))));
// Mock request
$_SERVER["REQUEST_METHOD"] = "GET";
$_SERVER["REQUEST_URI"] = "/api/v1/posts";
$_GET["page"] = "1";
$_GET["per_page"] = "10";
$req = new ApiRequest();
ob_start();
$response = $controller->index($req);
ob_end_clean();
// Although we can't easily assert HTTP output,
// we assert on the response object before send()
$this->assertInstanceOf(ApiResponse::class, $response);
}
}
// ── Data Provider — test multiple inputs ──────────────────────
class SlugGeneratorTest extends TestCase {
/** @dataProvider slugInputProvider */
public function test_generates_correct_slug(string $input, string $expected): void {
$result = SlugGenerator::from($input);
$this->assertSame($expected, $result);
}
public static function slugInputProvider(): array {
return [
"basic" => ["Hello World", "hello-world"],
"with numbers" => ["PHP 8.2 Features", "php-8-2-features"],
"special chars" => ["C++ vs PHP: A Guide!", "c-vs-php-a-guide"],
"multiple spaces" => ["Too Many Spaces", "too-many-spaces"],
"unicode" => ["Ünïcödé tïtlé", "unicode-title"],
"already slug" => ["already-a-slug", "already-a-slug"],
];
}
}Quick Quiz
Tip
Tip
Practice PHPUnit Integration Functional Testing in small, isolated examples before integrating into larger projects. Breaking concepts into small experiments builds genuine understanding faster than reading alone.
PHP processes each request through the server-side engine
Practice Task
Note
Practice Task — (1) Write a working example of PHPUnit Integration Functional Testing from scratch without looking at notes. (2) Modify it to handle an edge case (empty input, null value, or error state). (3) Share your solution in the Priygop community for feedback.
Common Mistake
Warning
A common mistake with PHPUnit Integration Functional Testing is skipping edge case testing — empty inputs, null values, and unexpected data types. Always validate boundary conditions to write robust, production-ready php code.