Framework mini kita sekarang terdiri atas router, middleware, container, template engine, dan exception handler — semuanya kode yang kita tulis sendiri. Setiap perubahan kecil pada satu komponen bisa diam-diam merusak komponen lain, dan mencobanya lewat browser satu per satu bukanlah strategi yang bisa diskalakan. Pada chapter ini kita menguji framework itu sendiri: membangun kerangka assertion sederhana, menulis suite pengujian untuk tiap komponen, membuat test client yang mengirim request palsu menembus seluruh stack, lalu membekukan respons yang sudah benar dengan teknik golden master.
Kerangka Assertion Mini #
Inti setiap alat pengujian hanyalah perbandingan: apakah nilai aktual sama dengan nilai yang kita harapkan? PHPUnit menjadikan ide ini menjadi alat yang kaya fitur, tetapi untuk memahami mekanismenya kita cukup menulis kelas MiniTest dengan satu method assertion dan pelaporan ringkas:
<?php
final class MiniTest
{
private int $passed = 0;
/** @var list<string> */
private array $failures = [];
public function assertSame(string $label, mixed $expected, mixed $actual): void
{
if ($expected === $actual) {
$this->passed++;
} else {
$this->failures[] = $label;
}
}
public function summary(): string
{
$result = "Lulus: {$this->passed}, Gagal: " . count($this->failures);
foreach ($this->failures as $label) {
$result .= "\n GAGAL: $label";
}
return $result;
}
}
$t = new MiniTest();
$t->assertSame("penjumlahan dasar", 3, 1 + 2);
$t->assertSame("tipe data ikut diperiksa", "42", 42);
echo $t->summary(), "\n";Output yang Dihasilkan:
Lulus: 1, Gagal: 1
GAGAL: tipe data ikut diperiksaDua assertion di akhir sengaja dibuat satu lulus dan satu gagal: 1 + 2 memang 3, tetapi string "42" tidak identik dengan integer 42. Perbandingan memakai operator === — nilai dan tipe data harus sama — karena inilah yang membuat pengujian bisa dipercaya; perbandingan == yang longgar justru menyembunyikan bug tipe data. Assertion yang gagal tidak menghentikan eksekusi, melainkan dicatat ke daftar failures sehingga satu kali jalan melaporkan seluruh masalah sekaligus.
Terjemahan ke PHPUnit #
Sebelum menulis suite lengkap, mari lihat bagaimana pengujian yang sama ditulis dalam PHPUnit. Kita definisikan kelas turunan TestCase, dan setiap method berawalan test menjadi satu pengujian mandiri:
<?php
use PHPUnit\Framework\TestCase;
final class RouterTest extends TestCase
{
public function testRouteDenganSatuParameter(): void
{
$this->assertSame(
["id" => "42"],
matchRoute("/produk/{id}", "/produk/42")
);
}
public function testPathAsingDitolak(): void
{
$this->assertNull(matchRoute("/produk/{id}", "/login"));
}
}Strukturnya identik dengan MiniTest: label menjadi nama method, nilai harapan dan nilai aktual menjadi argumen $this->assertSame(). Perbedaannya ada pada otomatisasi — PHPUnit menjalankan semua method test*, menghitung waktu, dan melaporkan hasilnya dalam format yang bisa dibaca mesin. Memahami versi mini membuat kita tidak lagi memperlakukan PHPUnit sebagai kotak hitam.
Satu Suite untuk Semua Komponen #
Sekarang kita rangkai seluruh komponen framework — router dari chapter routing parameters , middleware dari chapter middleware , container dari chapter dependency container — ke dalam satu berkas suite. Karena seluruh komponen adalah class dan function murni tanpa ketergantungan pada server web, suite ini bisa berjalan langsung di CLI:
<?php
// tests/framework_test.php — satu berkas, seluruh komponen framework diuji
final class MiniTest
{
private int $passed = 0;
/** @var list<string> */
private array $failures = [];
public function assertSame(string $label, mixed $expected, mixed $actual): void
{
if ($expected === $actual) {
$this->passed++;
} else {
$this->failures[] = $label;
}
}
public function summary(): string
{
$result = "Lulus: {$this->passed}, Gagal: " . count($this->failures);
foreach ($this->failures as $label) {
$result .= "\n GAGAL: $label";
}
return $result;
}
}
function matchRoute(string $pattern, string $path): ?array
{
$regex = preg_replace_callback(
'#\{(\w+)\}#',
fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
$pattern
);
if (preg_match('#^' . $regex . '$#', $path, $matches) !== 1) {
return null;
}
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
}
function layer(callable $middleware, callable $next): callable
{
return function (string $request) use ($middleware, $next): string {
return $middleware($request, $next);
};
}
final class MiniContainer
{
/** @var array<string, callable> */
private array $factories = [];
/** @var array<string, object> */
private array $instances = [];
public function set(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
}
public function get(string $id): object
{
return $this->instances[$id] ??= ($this->factories[$id])($this);
}
}
final class FileLogger
{
public function log(string $message): string
{
return "LOG: $message";
}
}
final class OrderService
{
public function __construct(public FileLogger $logger)
{
}
}
final class TestClient
{
/** @param array<string, callable(array<string,string>): string> $routes */
public function __construct(private array $routes)
{
}
/** @return array{status: int, body: string} */
public function get(string $path): array
{
foreach ($this->routes as $pattern => $action) {
$regex = preg_replace_callback(
'#\{(\w+)\}#',
fn (array $m): string => '(?P<' . $m[1] . '>[^/]+)',
$pattern
);
if (preg_match('#^' . $regex . '$#', $path, $matches) === 1) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return ["status" => 200, "body" => $action($params)];
}
}
return ["status" => 404, "body" => "Halaman tidak ditemukan"];
}
}
$t = new MiniTest();
// --- Router ---
$t->assertSame(
"route dengan satu parameter",
["id" => "42"],
matchRoute("/produk/{id}", "/produk/42")
);
$t->assertSame(
"path asing ditolak router",
null,
matchRoute("/produk/{id}", "/login")
);
$t->assertSame(
"dua parameter terisi berurutan",
["tahun" => "2026", "slug" => "belajar-php"],
matchRoute("/blog/{tahun}/{slug}", "/blog/2026/belajar-php")
);
// --- Middleware ---
$core = fn (string $request): string => "core($request)";
$upper = fn (string $request, callable $next): string => strtoupper($next($request)) . "+upper";
$tag = fn (string $request, callable $next): string => "[" . $next($request) . "]";
$pipeline = layer($tag, layer($upper, $core));
$t->assertSame(
"pipeline membungkus core dari luar ke dalam",
"[CORE(REQ)+upper]",
$pipeline("req")
);
// --- Container ---
$container = new MiniContainer();
$container->set(FileLogger::class, fn (): FileLogger => new FileLogger());
$container->set(
OrderService::class,
fn (MiniContainer $c): OrderService => new OrderService($c->get(FileLogger::class))
);
$t->assertSame(
"instance container dibagikan (singleton)",
true,
$container->get(FileLogger::class) === $container->get(FileLogger::class)
);
$t->assertSame(
"dependensi ter-inject otomatis",
"LOG: pesanan dibuat",
$container->get(OrderService::class)->logger->log("pesanan dibuat")
);
// --- Test client: request penuh menembus stack ---
$client = new TestClient([
"/produk/{id}" => fn (array $params): string => "Detail produk #{$params['id']}",
"/tentang" => fn (array $params): string => "Tentang Kami",
]);
$t->assertSame(
"request valid menghasilkan respons 200",
["status" => 200, "body" => "Detail produk #42"],
$client->get("/produk/42")
);
$t->assertSame(
"route tak dikenal menjadi respons 404",
["status" => 404, "body" => "Halaman tidak ditemukan"],
$client->get("/tidak-ada")
);
echo $t->summary(), "\n";Output yang Dihasilkan:
Lulus: 8, Gagal: 0Suite ini layak dibaca per kelompok assertion:
- Router: tiga assertion memeriksa satu parameter, penolakan path asing, dan dua parameter berurutan. Semua memakai bentuk input–output murni, sehingga router tidak perlu web server untuk teruji.
- Middleware: fungsi
layer()menyusun pipeline —$tagmembungkus$upperyang membungkus$core. Assertion memastikan urutan pembungkusan benar: request masuk dari luar, core dieksekusi, hasil dibungkus balik dari dalam ([CORE(REQ)+upper]). - Container: dua sifat penting diuji. Operator identitas
===membuktikan instanceFileLoggersama persis (dibagikan, bukan dibuat ulang), dan assertion kedua membuktikan factoryOrderServicebenar-benar menerima logger dari container. - Test client: inilah pengujian integrasi mini — request palsu
/produk/42menembus pencocokan route sampai action dan kembali sebagai respons utuhstatus + body, termasuk kasus 404.
TestClient di suite inilah lawan main kita yang paling berharga: satu kelas kecil yang menyimulasikan request HTTP tanpa jaringan. Laravel punya fitur serupa bernama HTTP tests, dan Symfony menyediakan WebTestCase — keduanya bekerja dengan prinsip yang sama persis.
Golden Master: Membekukan Respons yang Benar #
Assertion per komponen memeriksa potongan, tetapi bug paling menyakitkan justru muncul dari gabungan komponen: format respons yang berubah, header yang hilang, spasi yang bergeser. Teknik golden master (juga disebut snapshot testing) mengatasinya dengan cara membalik logika: simpan seluruh respons yang saat ini dianggap benar, lalu setiap perubahan kode dibandingkan terhadap salinan beku itu:
<?php
// Respons "emas" — dibekukan saat fitur pertama kali dianggap benar
$goldenResponse = "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nDetail produk #42";
// Respons aktual yang dihasilkan TestClient hari ini
$actualResponse = "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nDetail produk #" . 42;
echo $actualResponse === $goldenResponse
? "Snapshot cocok — tidak ada regresi\n"
: "Snapshot berubah — periksa perubahan!\n";
// Skenario: seseorang mengubah format body tanpa sengaja
$actualResponse = "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nProduk #42";
echo $actualResponse === $goldenResponse
? "Snapshot cocok — tidak ada regresi\n"
: "Snapshot berubah — periksa perubahan!\n";Output yang Dihasilkan:
Snapshot cocok — tidak ada regresi
Snapshot berubah — periksa perubahan!Perbandingan pertama cocok karena body masih "Detail produk #42"; perbandingan kedua gagal karena seseorang mengubah awalan body menjadi "Produk #42" — perubahan kecil yang lolos dari tinjauan kode, tetapi tidak bisa lolos dari snapshot. Dalam praktik nyata, salinan emas disimpan sebagai berkas di direktori tests/snapshots/, dan saat ia berubah memang disengaja, kita memperbaruinya secara sadar — snapshot yang gagal menjadi pemicu percakapan, bukan sekadar lampu merah.
Menguji Template Rendering #
Template engine dari chapter template rendering juga komponen yang wajib teruji — kesalahan kecil di regex placeholder bisa membuat seluruh halaman kosong tanpa error. Karena fungsi render kita murni mengembalikan string, pengujian tinggal membandingkan string:
<?php
function renderTemplate(string $template, array $data): string
{
return preg_replace_callback(
'#\{\{(\w+)\}\}#',
fn (array $m): string => (string) ($data[$m[1]] ?? ''),
$template
);
}
$hasil = renderTemplate("<h1>{{judul}}</h1><p>Oleh {{penulis}}</p>", [
"judul" => "Belajar Framework PHP",
"penulis" => "Budi",
]);
$tes = [
"judul dan penulis ter-render" => $hasil === "<h1>Belajar Framework PHP</h1><p>Oleh Budi</p>",
"placeholder tanpa data dihapus" => renderTemplate("Halo, {{nama}}", []) === "Halo, ",
];
foreach ($tes as $label => $lulus) {
echo ($lulus ? "PASS" : "FAIL") . " — $label\n";
}Output yang Dihasilkan:
PASS — judul dan penulis ter-render
PASS — placeholder tanpa data dihapusDua kasus diuji sekaligus: penggantian normal dua placeholder, dan edge case placeholder yang datanya tidak tersedia. Kasus kedua sama pentingnya dengan yang pertama — perilaku pada input tak terduga justru tempat bug paling sering bersembunyi. Assertion terhadap string hasil render juga otomatis menjaga kita dari kerentanan XSS: jika suatu saat render mulai meng-escape karakter < dan >, pengujian ini yang pertama memberi tahu.
Menjalankan Pengujian dan Cakupan Kode #
Seluruh pengujian di atas dirancang untuk berjalan dari terminal, tanpa server web dan tanpa jaringan:
# Jalankan suite buatan sendiri
php tests/framework_test.php
# Pada proyek nyata, gunakan PHPUnit
php phpunit.phar --bootstrap vendor/autoload.php tests/
# Cakupan kode (membutuhkan ekstensi Xdebug atau PCOV)
php phpunit.phar --coverage-text tests/Perintah pertama cukup untuk siklus harian kita — cepat dan tanpa dependensi. Perintah kedua adalah bentuk yang akan Anda temui di proyek nyata, biasanya dijalankan otomatis oleh CI setiap ada push. Perintah ketiga mengukur code coverage: persentase baris framework yang benar-benar dieksekusi oleh pengujian. Coverage 100% bukan jaminan bebas bug — kode yang dieksekusi belum tentu di-assert benar — tetapi baris yang tidak pernah tersentuh pengujian adalah wilayah buta yang patut dicurigai.
Ringkasan #
Framework mini kini punya jaring pengujian sendiri: assertion === sebagai fondasi, suite per komponen untuk router, middleware, dan container, TestClient sebagai pengujian integrasi yang menembus seluruh stack, golden master untuk membekukan respons, dan pengujian string untuk template. Kita mengetahui framework ini bekerja bukan karena mencobanya sekali di browser, tetapi karena mesin yang bisa memeriksanya berulang kali dalam hitungan detik. Dengan semua lapisan lengkap dan teruji, chapter berikutnya menutup section ini dengan refleksi jujur: batasan framework buatan sendiri
dan kapan saatnya beralih ke framework matang.