Unit test membuktikan satu potongan logika; integration test membuktikan beberapa potongan menempel dengan benar; HTTP test membuktikan lapisan web berperilaku semestinya. Namun semua itu masih menyimak aplikasi dari dalam — lewat panggilan fungsi dan objek yang kita ciptakan sendiri. End-to-end testing (E2e) menguji dari luar: aplikasi dinyalakan utuh — server, router, database, JSON — lalu digerakkan seperti pengguna sungguhan melalui HTTP. Pada Chapter ini kita membangun skrip E2e yang benar-benar menghidupkan web server bawaan PHP, menembaknya dengan curl, menegaskan responsnya, lalu memadamkannya kembali, semuanya otomatis dan berulang.

Posisi E2e dalam Piramida Pengujian #

Semakin ke luar sebuah test menyentuh sistem, semakin nyata hal yang dibuktikannya — dan semakin mahal harganya. Test E2e menjalankan seluruh tumpukan (full stack), sehingga satu skenarionya bisa seratus kali lebih lambat daripada satu unit test. Karena itu posisinya di puncak piramida: jumlahnya sedikit, tetapi setiap satunya memverifikasi perjalanan yang paling dikhawatirkan gagal, misalnya “pengguna memesan lalu pesanannya muncul di daftar”.

JenjangKecepatan per testApa yang dibuktikanJumlah wajar
Unitmikrodetiksatu fungsi/class terisolasiratusan hingga ribuan
Integrationmilidetikbeberapa komponen bekerja bersama, misalnya query databasepuluhan hingga ratusan
End-to-enddetikperjalanan utuh pada sistem terpasangbelasan

Aturan praktisnya: bug yang bisa ditangkap di jenjang lebih dalam harus ditangkap di sana. E2e bukan tempat menegaskan diskon 10% untuk 27 kombinasi harga — itu pekerjaan unit test dengan data provider. E2e menjawab satu pertanyaan besar: “apakah semua bagian yang sudah terbukti benar sendiri-sendiri benar juga ketika disatukan?”

Aplikasi yang Akan Diuji #

Toko Mini adalah aplikasi API kecil lengkap dengan database SQLite: endpoint /api/health untuk cek kesehatan, POST /api/pesanan untuk membuat pesanan, dan GET /api/pesanan untuk melihat daftarnya. Seluruh logika HTTP mengalir lewat superglobal $_SERVER, header(), dan php://input — persis aplikasi PHP sungguhan, tanpa satu pun lapisan yang diejek. Kode berikut adalah public/index.php, front controller yang akan digerakkan skrip E2e kita:

php
<?php

declare(strict_types=1);

// public/index.php — front controller Toko Mini.
// Dihidupkan web server bawaan PHP: php -S 127.0.0.1:8127 public/index.php

function koneksiDatabase(): PDO
{
    $path = getenv('APP_DB') ?: sys_get_temp_dir() . '/toko-mini-e2e.sqlite';

    return new PDO('sqlite:' . $path, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
}

function pasangSkema(PDO $pdo): void
{
    $pdo->exec('CREATE TABLE IF NOT EXISTS pesanan (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        pelanggan TEXT NOT NULL,
        menu TEXT NOT NULL,
        totalHarga INTEGER NOT NULL
    )');
}

function jsonResponse(int $status, array $data): void
{
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
}

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

if ($path === '/api/health') {
    jsonResponse(200, ['status' => 'ok']);

    return true;
}

if ($path === '/api/pesanan') {
    $pdo = koneksiDatabase();
    pasangSkema($pdo);

    if ($method === 'POST') {
        $body = json_decode((string) file_get_contents('php://input'), true);

        if (!is_array($body)) {
            jsonResponse(422, ['error' => 'Body JSON tidak valid']);

            return true;
        }

        foreach (['pelanggan', 'menu'] as $field) {
            if (!isset($body[$field]) || !is_string($body[$field]) || $body[$field] === '') {
                jsonResponse(422, ['error' => "Field {$field} wajib diisi"]);

                return true;
            }
        }

        $stmt = $pdo->prepare(
            'INSERT INTO pesanan (pelanggan, menu, totalHarga) VALUES (?, ?, ?)',
        );
        $stmt->execute([$body['pelanggan'], $body['menu'], 25000]);

        jsonResponse(201, ['id' => (int) $pdo->lastInsertId()]);

        return true;
    }

    $daftar = $pdo->query(
        'SELECT id, pelanggan, menu, totalHarga FROM pesanan ORDER BY id',
    )->fetchAll();

    jsonResponse(200, ['data' => $daftar]);

    return true;
}

http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo 'Tidak ditemukan';

return true;

Ada tiga keputusan yang relevan untuk pengujian. Pertama, lokasi berkas SQLite diambil dari variabel lingkungan APP_DB — skrip E2e dengan bebas mengarahkannya ke direktori sementara tanpa menyentuh kode aplikasi. Kedua, pasangSkema() memakai CREATE TABLE IF NOT EXISTS sehingga berkas database boleh dihapus dan dibuat ulang setiap kali suite berjalan. Ketiga, /api/health sengaja tidak menyentuh database sama sekali — endpoint ini akan kita pakai untuk mendeteksi bahwa server sudah siap, dan ia harus tetap murah meski database sedang bermasalah.

Skrip E2e dengan Server Sungguhan #

Inilah bintang Chapter ini: e2e.php, sebuah skrip mandiri yang menghidupkan public/index.php lewat php -S, menunggunya siap, menjalankan empat skenario HTTP, lalu memadamkannya. Simpan skrip berikut di direktori chapter, lalu jalankan dengan php e2e.php:

php
<?php

declare(strict_types=1);

// e2e.php — skrip end-to-end: menghidupkan server sungguhan,
// menembaknya lewat HTTP, lalu memadamkannya lagi.

final class Lari
{
    public static int $gagal = 0;
}

function http(string $method, string $path, ?string $body = null): array
{
    $ch = curl_init('http://127.0.0.1:8127' . $path);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);

    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
    }

    $respons = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return [$status, $respons === false ? '' : (string) $respons];
}

function cek(string $label, int $harapanStatus, string $harapanBody, array $aktual): void
{
    [$status, $body] = $aktual;

    if ($status === $harapanStatus && $body === $harapanBody) {
        echo "PASS $label -> $status $body\n";

        return;
    }

    Lari::$gagal++;
    echo "FAIL $label -> $status $body\n";
}

// 1. Mulai dari database bersih di direktori sementara
$dbPath = sys_get_temp_dir() . '/toko-mini-e2e.sqlite';
@unlink($dbPath);
putenv('APP_DB=' . $dbPath);

// 2. Hidupkan web server bawaan PHP sebagai proses anak
$server = proc_open(
    [PHP_BINARY, '-S', '127.0.0.1:8127', 'public/index.php'],
    [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']],
    $pipes,
);

// Jaring pengaman: apa pun akhir skrip ini, server ikut dipadamkan
register_shutdown_function(static function () use ($server): void {
    if (is_resource($server)) {
        proc_terminate($server);
        proc_close($server);
    }
});

// 3. Tunggu server siap dengan polling — bukan sleep tetap
for ($i = 0; $i < 50; $i++) {
    if (http('GET', '/api/health')[0] === 200) {
        break;
    }

    usleep(100000);
}

// 4. Skenario perjalanan pengguna, ujung ke ujung
cek('GET /api/health', 200, '{"status":"ok"}', http('GET', '/api/health'));
cek('POST /api/pesanan', 201, '{"id":1}', http(
    'POST',
    '/api/pesanan',
    '{"pelanggan":"Budi","menu":"Nasi Goreng Spesial"}',
));
cek(
    'GET /api/pesanan',
    200,
    '{"data":[{"id":1,"pelanggan":"Budi","menu":"Nasi Goreng Spesial","totalHarga":25000}]}',
    http('GET', '/api/pesanan'),
);
cek('GET /api/tidak-ada', 404, 'Tidak ditemukan', http('GET', '/api/tidak-ada'));

// 5. Padamkan server dan bersihkan berkas sementara
proc_terminate($server);
proc_close($server);
@unlink($dbPath);

if (Lari::$gagal > 0) {
    echo Lari::$gagal, " skenario gagal\n";
    exit(1);
}

echo "OK (4 skenario e2e lulus)\n";

Output yang Dihasilkan:

text
PASS GET /api/health -> 200 {"status":"ok"}
PASS POST /api/pesanan -> 201 {"id":1}
PASS GET /api/pesanan -> 200 {"data":[{"id":1,"pelanggan":"Budi","menu":"Nasi Goreng Spesial","totalHarga":25000}]}
PASS GET /api/tidak-ada -> 404 Tidak ditemukan
OK (4 skenario e2e lulus)

Mari kita bedah skrip ini langkah demi langkah:

  1. Helper http(): membungkus ekstensi curl menjadi satu fungsi yang mengembalikan pasangan [$status, $body]. CURLOPT_RETURNTRANSFER membuat body ikut dikembalikan alih-alih langsung dicetak, dan CURLINFO_RESPONSE_CODE membaca status line respons.
  2. Helper cek(): assertion sederhana — jika status dan body persis sama dengan harapan, cetak PASS; jika tidak, catat ke Lari::$gagal. Menegaskan body secara utuh (bukan sekadar assertStringContainsString) membuat test peka terhadap field yang hilang atau bertipe berubah.
  3. Langkah 1 — keadaan awal bersih: berkas SQLite lama dihapus dan APP_DB diarahkan ke /tmp. Tanpa langkah ini, id pesanan dari run sebelumnya membuat harapan {"id":1} meleset.
  4. Langkah 2 — server sebagai proses anak: proc_open() menjalankan php -S 127.0.0.1:8127 public/index.php. PHP_BINARY dipakai agar skrip tetap benar di PHP manapun yang menjalankannya. Output server dibuang ke /dev/null supaya log request tidak mencemari hasil test.
  5. Jaring pengaman: register_shutdown_function() memadamkan server bahkan ketika skrip mati di tengah jalan. Tanpa jaring ini, satu exit() tak terduga meninggalkan proses yatim yang mengunci port.
  6. Langkah 3 — polling kesiapan: loop mencoba /api/health hingga 50 kali dengan jeda 100 ms. Server biasanya siap dalam beberapa ratus milidetik; loop berhenti begitu mendapat 200, jadi total durasinya adaptif.
  7. Langkah 4 — empat skenario: cek kesehatan, buat pesanan (201 + id), lihat daftar (200 + data yang baru dibuat), dan rute tak dikenal (404). Urutannya mengikuti cerita pengguna, bukan urutan berkas.
  8. Langkah 5 — pembersihan: server diterminasi, proc_close() menunggu prosesnya benar-benar mati, dan berkas SQLite dihapus. Skrip keluar dengan kode 1 jika ada skenario gagal — sinyal yang nanti bisa dibaca pipeline CI.

Membawa Skenario ke Suite PHPUnit #

Skrip di atas sudah otomatis, tetapi ia hidup di luar suite PHPUnit. Pola berikut membawanya ke dalam: server dihidupkan sekali di setUpBeforeClass(), dua test menjalankan skenario, dan tearDownAfterClass() memadamkan semuanya — struktur yang sama dengan test yang memakai database , hanya “fixture”-nya berupa proses server:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class PesananE2eTest extends TestCase
{
    private static $server;
    private static string $dbPath;

    public static function setUpBeforeClass(): void
    {
        self::$dbPath = sys_get_temp_dir() . '/toko-mini-phpunit.sqlite';
        @unlink(self::$dbPath);
        putenv('APP_DB=' . self::$dbPath);

        self::$server = proc_open(
            [PHP_BINARY, '-S', '127.0.0.1:8127', __DIR__ . '/../public/index.php'],
            [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']],
            $pipes,
        );

        for ($i = 0; $i < 50; $i++) {
            if (self::http('GET', '/api/health')[0] === 200) {
                return;
            }

            usleep(100000);
        }

        self::fail('Server tidak siap setelah 5 detik');
    }

    public static function tearDownAfterClass(): void
    {
        if (is_resource(self::$server)) {
            proc_terminate(self::$server);
            proc_close(self::$server);
        }

        @unlink(self::$dbPath);
    }

    public function testPesanLaluLihatDaftar(): void
    {
        [$status, $body] = self::http(
            'POST',
            '/api/pesanan',
            '{"pelanggan":"Siti","menu":"Es Teh Manis"}',
        );

        $this->assertSame(201, $status);
        $this->assertSame(['id' => 1], json_decode($body, true));

        [$status, $body] = self::http('GET', '/api/pesanan');

        $this->assertSame(200, $status);
        $this->assertSame(
            ['data' => [['id' => 1, 'pelanggan' => 'Siti', 'menu' => 'Es Teh Manis', 'totalHarga' => 25000]]],
            json_decode($body, true),
        );
    }

    public function testRuteTakDikenalMenghasilkan404(): void
    {
        [$status, $body] = self::http('GET', '/api/tidak-ada');

        $this->assertSame(404, $status);
        $this->assertSame('Tidak ditemukan', $body);
    }

    private static function http(string $method, string $path, ?string $body = null): array
    {
        $ch = curl_init('http://127.0.0.1:8127' . $path);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);

        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
            curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        }

        $respons = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        return [$status, $respons === false ? '' : (string) $respons];
    }
}

Dari direktori chapter, suite dijalankan seperti biasa:

bash
php /home/z/my-project/bin/phpunit.phar tests

Berikut output nyata dari perintah tersebut:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

..                                                                  2 / 2 (100%)

Time: 00:00.208, Memory: 28.05 MB

OK (2 tests, 6 assertions)

Dua test selesai seperlima detik — hampir seluruhnya habis untuk menghidupkan server. Perhatikan bahwa test pertama memanggil POST lalu GET dalam satu method: keduanya adalah satu cerita “pesan lalu lihat”, dan memecahnya menjadi dua method justru menuntut berbagi keadaan antar test. Assertion assertSame pada hasil json_decode() memverifikasi tipe data secara persisi — id benar-benar integer, bukan string "1".

Automasi Browser #

E2e kita menguji lewat HTTP, tetapi pengguna sesungguhnya memakai browser: mengisi form, menekan tombol, menunggu animasi selesai. Mengotomatiskan lapisan itu membutuhkan browser automation — alat yang menjalankan browser sungguhan (Chromium, Firefox, WebKit) dan mengendalikannya dari kode. Di industri, alat yang mendominasi saat ini adalah Playwright; karena Playwright berjalan di ekosistem Node.js, test-nya ditulis dalam JavaScript, terpisah dari suite PHP namun hidup di repositori yang sama:

javascript
// tests/e2e/pesan.spec.js — Playwright berjalan di Node.js, bukan PHP
const { test, expect } = require('@playwright/test');

test('pesan lalu lihat daftar', async ({ page }) => {
  await page.goto('http://127.0.0.1:8080/pesan');
  await page.fill('#pelanggan', 'Budi');
  await page.fill('#menu', 'Nasi Goreng Spesial');
  await page.click('text=Pesan Sekarang');
  await expect(page.locator('.daftar-pesanan')).toContainText('Nasi Goreng Spesial');
});

Pola umumnya: server PHP dinyalakan (oleh task runner Playwright atau skrip seperti e2e.php kita), lalu test browser menggerakkan halaman dan menegaskan apa yang terlihat. Komunitas PHP juga punya jalur tanpa pindah bahasa — Symfony Panther dan Laravel Dusk mengendalikan browser dari dalam test PHPUnit. Prinsip seleksinya sederhana: jika yang perlu dibuktikan adalah API, uji lewat HTTP seperti skrip kita; jika yang perlu dibuktikan adalah antarmuka di browser, investasikan browser automation. Keduanya tetap di puncak piramida: sedikit, lambat, dan berharga.

Paritas Lingkungan #

E2e hanya bermakna jika lingkungan test menyerupai produksi — inilah yang disebut environment parity. Semakin lebar jurangnya, semakin banyak bug yang lolos di test lalu meledak di produksi, dan sebaliknya. Minimal, tiga hal ini harus disamakan:

  • Versi PHP dan ekstensinya — perilaku strict_types, deprecation, hingga ekstensi yang tersedia bisa berbeda antar versi.
  • Konfigurasi runtime — error_reporting, memory_limit, dan pengaturan OPcache yang berbeda mengubah hasil nyata.
  • Cara data di-seed — produksi memakai data pengguna; test harus memakai data tetap yang dibuat ulang setiap run, bukan sisa run sebelumnya.

Aplikasi kita menunjukkan paritas dalam skala kecil: SQLite yang sama, JSON yang sama, HTTP yang sama. Yang sengaja tidak disamakan hanyalah lokasi berkas database — dan itu pun diatur lewat APP_DB, variabel lingkungan yang di produksi akan menunjuk ke server database sesungguhnya.

Mengelola Flakiness #

Test yang kadang lulus kadang gagal tanpa perubahan kode disebut flaky, dan E2e adalah jenjang yang paling rentan padanya: jaringan, proses, dan kecepatan mesin semuanya ikut bermain. Flakiness merusak kepercayaan — tim yang terbiasa dengan “coba jalankan lagi, pasti lulus” akan berhenti memedulikan lampu merah. Kebiasaan-kebiasaan ini menjaganya tetap deterministik:

  • Tunggu dengan polling, bukan sleep tetap — seperti loop kesiapan server kita.
  • Mulai setiap run dari keadaan bersih — hapus dan buat ulang database fixture.
  • Jangan menegaskan nilai yang berubah — header Date respons, timestamp, atau id acak; angka {"id":1} kita hanya deterministik karena database selalu dimulai kosong.
  • Satu port per suite — dua suite E2e berjalan paralel di port yang sama akan saling menembak; beri masing-masing port tetap yang berbeda.
  • Gagal itu sinyal, bukan gangguan — saat test flaky terjadi, cari akar masalahnya; karantina (tandai dan pisahkan) hanya sementara, dan retry otomatis adalah pilihan terakhir karena menyembunyikan penyakit.

Ringkasan #

End-to-end testing menutup babak pengujian dari luar: aplikasi dinyalakan utuh dan digerakkan lewat HTTP sungguhan, dengan server sebagai proses anak yang dihidupkan dan dipadamkan otomatis, keadaan awal yang selalu bersih, dan skenario yang mengikuti perjalanan pengguna. Kita sudah melihat polanya — polling, jaring pengaman shutdown, cleanup menyeluruh — beserta kebiasaan menjaga paritas lingkungan dan melawan flakiness. Dengan seluruh jenjang piramida terpasang, pertanyaan berikutnya muncul alami: seberapa banyak kode yang benar-benar disentuh oleh semua test itu? Pertanyaan itulah yang dijawab oleh code coverage pada Chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

e2e.php

Unduh berkas
php
<?php

declare(strict_types=1);

// e2e.php — skrip end-to-end: menghidupkan server sungguhan,
// menembaknya lewat HTTP, lalu memadamkannya lagi.

final class Lari
{
    public static int $gagal = 0;
}

function http(string $method, string $path, ?string $body = null): array
{
    $ch = curl_init('http://127.0.0.1:8127' . $path);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);

    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
        curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
    }

    $respons = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return [$status, $respons === false ? '' : (string) $respons];
}

function cek(string $label, int $harapanStatus, string $harapanBody, array $aktual): void
{
    [$status, $body] = $aktual;

    if ($status === $harapanStatus && $body === $harapanBody) {
        echo "PASS $label -> $status $body\n";

        return;
    }

    Lari::$gagal++;
    echo "FAIL $label -> $status $body\n";
}

// 1. Mulai dari database bersih di direktori sementara
$dbPath = sys_get_temp_dir() . '/toko-mini-e2e.sqlite';
@unlink($dbPath);
putenv('APP_DB=' . $dbPath);

// 2. Hidupkan web server bawaan PHP sebagai proses anak
$server = proc_open(
    [PHP_BINARY, '-S', '127.0.0.1:8127', 'public/index.php'],
    [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']],
    $pipes,
);

// Jaring pengaman: apa pun akhir skrip ini, server ikut dipadamkan
register_shutdown_function(static function () use ($server): void {
    if (is_resource($server)) {
        proc_terminate($server);
        proc_close($server);
    }
});

// 3. Tunggu server siap dengan polling — bukan sleep tetap
for ($i = 0; $i < 50; $i++) {
    if (http('GET', '/api/health')[0] === 200) {
        break;
    }

    usleep(100000);
}

// 4. Skenario perjalanan pengguna, ujung ke ujung
cek('GET /api/health', 200, '{"status":"ok"}', http('GET', '/api/health'));
cek('POST /api/pesanan', 201, '{"id":1}', http(
    'POST',
    '/api/pesanan',
    '{"pelanggan":"Budi","menu":"Nasi Goreng Spesial"}',
));
cek(
    'GET /api/pesanan',
    200,
    '{"data":[{"id":1,"pelanggan":"Budi","menu":"Nasi Goreng Spesial","totalHarga":25000}]}',
    http('GET', '/api/pesanan'),
);
cek('GET /api/tidak-ada', 404, 'Tidak ditemukan', http('GET', '/api/tidak-ada'));

// 5. Padamkan server dan bersihkan berkas sementara
proc_terminate($server);
proc_close($server);
@unlink($dbPath);

if (Lari::$gagal > 0) {
    echo Lari::$gagal, " skenario gagal\n";
    exit(1);
}

echo "OK (4 skenario e2e lulus)\n";

tests/PesananE2eTest.php

Unduh berkas
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class PesananE2eTest extends TestCase
{
    private static $server;
    private static string $dbPath;

    public static function setUpBeforeClass(): void
    {
        self::$dbPath = sys_get_temp_dir() . '/toko-mini-phpunit.sqlite';
        @unlink(self::$dbPath);
        putenv('APP_DB=' . self::$dbPath);

        self::$server = proc_open(
            [PHP_BINARY, '-S', '127.0.0.1:8127', __DIR__ . '/../public/index.php'],
            [1 => ['file', '/dev/null', 'w'], 2 => ['file', '/dev/null', 'w']],
            $pipes,
        );

        for ($i = 0; $i < 50; $i++) {
            if (self::http('GET', '/api/health')[0] === 200) {
                return;
            }

            usleep(100000);
        }

        self::fail('Server tidak siap setelah 5 detik');
    }

    public static function tearDownAfterClass(): void
    {
        if (is_resource(self::$server)) {
            proc_terminate(self::$server);
            proc_close(self::$server);
        }

        @unlink(self::$dbPath);
    }

    public function testPesanLaluLihatDaftar(): void
    {
        [$status, $body] = self::http(
            'POST',
            '/api/pesanan',
            '{"pelanggan":"Siti","menu":"Es Teh Manis"}',
        );

        $this->assertSame(201, $status);
        $this->assertSame(['id' => 1], json_decode($body, true));

        [$status, $body] = self::http('GET', '/api/pesanan');

        $this->assertSame(200, $status);
        $this->assertSame(
            ['data' => [['id' => 1, 'pelanggan' => 'Siti', 'menu' => 'Es Teh Manis', 'totalHarga' => 25000]]],
            json_decode($body, true),
        );
    }

    public function testRuteTakDikenalMenghasilkan404(): void
    {
        [$status, $body] = self::http('GET', '/api/tidak-ada');

        $this->assertSame(404, $status);
        $this->assertSame('Tidak ditemukan', $body);
    }

    private static function http(string $method, string $path, ?string $body = null): array
    {
        $ch = curl_init('http://127.0.0.1:8127' . $path);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);

        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
            curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
        }

        $respons = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        return [$status, $respons === false ? '' : (string) $respons];
    }
}