API yang baik adalah API yang bisa dibuktikan baik: setiap endpoint, aturan validasi, dan status code harus punya pengujian otomatis yang menjaganya. Menguji API secara manual dengan curl mungkin masih masuk akal saat endpoint-nya berjumlah lima, tetapi saat jumlahnya lima puluh dengan aturan rate limiting, idempotency, dan otentikasi, setiap perubahan kecil menjadi pertaruhan besar. Pada chapter ini kita akan membangun strategi pengujian berlapis untuk API PHP: mulai dari handler murni yang diuji dengan assertion polos, test double untuk repositori, hingga database uji SQLite in-memory — semuanya tanpa membutuhkan server web.

Piramida Testing untuk API #

Strategi pengujian API yang sehat berbentuk piramida: banyak pengujian cepat di dasar, sedikit pengujian lambat di puncak. Lapisan dasar menguji handler sebagai fungsi biasa (unit test), lapisan tengah menguji beberapa komponen yang bekerja bersama (integration test), dan puncak menguji aplikasi utuh lewat HTTP sungguhan (end-to-end test). Makin tinggi lapisannya, makin yakin hasilnya mencerminkan dunia nyata — tetapi makin lambat dan makin rapuh terhadap perubahan kecil:

LapisanYang diujiKecepatanJumlah ideal
UnitHandler murni, validasi, algoritmaMilidetikBanyak
IntegrationHandler + database + repositoriPuluhan milidetikSedang
End-to-endRouter + server HTTP + respons penuhDetikSedikit

Komposisi ini penting karena umpan balik yang cepat mengubah cara kita bekerja. Bila seluruh rangkaian unit test berjalan di bawah satu detik, kita akan menjalankannya setiap kali menyimpan berkas — kesalahan ketik dan logika salah tertangkap dalam hitungan detik, bukan setelah deploy. End-to-end test tetap diperlukan untuk memastikan seluruh rangkaian tersambung, tetapi jumlahnya cukup sedikit karena tiap kasusnya mahal.

Menguji Handler sebagai Fungsi Murni #

Kunci pengujian yang menyenangkan adalah handler yang di desain untuk diuji: input masuk sebagai parameter, keluaran berupa array status dan body, dan tidak ada efek samping tersembunyi. Handler seperti ini adalah fungsi murni biasa — tidak butuh superglobal, koneksi database, apalagi server web. Kita bisa menyiapkan kondisi uji dengan array biasa dan memeriksa hasilnya dengan assertion polos:

php
<?php

function handleGetProduct(array $catalog, string $id): array
{
    if (!preg_match('/^[a-z0-9-]+$/', $id)) {
        return [
            'status' => 422,
            'body'   => ['error' => 'invalid_id'],
        ];
    }
    if (!isset($catalog[$id])) {
        return [
            'status' => 404,
            'body'   => ['error' => 'not_found'],
        ];
    }

    return [
        'status' => 200,
        'body'   => ['data' => $catalog[$id]],
    ];
}

function assertSame(mixed $expected, mixed $actual, string $label): void
{
    if ($expected === $actual) {
        echo "OK    $label\n";
    } else {
        echo "GAGAL $label (harapan: " . var_export($expected, true)
            . ", hasil: " . var_export($actual, true) . ")\n";
    }
}

$catalog = [
    'buku-php' => ['nama' => 'Belajar PHP Modern', 'harga' => 95000],
];

assertSame(200, handleGetProduct($catalog, 'buku-php')['status'], 'produk ada -> 200');
assertSame(404, handleGetProduct($catalog, 'tas-kulit')['status'], 'produk tidak ada -> 404');
assertSame(422, handleGetProduct($catalog, 'ID;DROP')['status'], 'id tidak valid -> 422');
assertSame(
    ['data' => ['nama' => 'Belajar PHP Modern', 'harga' => 95000]],
    handleGetProduct($catalog, 'buku-php')['body'],
    'body berisi data produk'
);

Output yang Dihasilkan:

text
OK    produk ada -> 200
OK    produk tidak ada -> 404
OK    id tidak valid -> 422
OK    body berisi data produk

Mari kita bedah bagian-bagian pentingnya:

  1. handleGetProduct(array $catalog, string $id): katalog dioper sebagai parameter, bukan dibaca dari database di dalam handler. Akibatnya, menyiapkan skenario uji sama mudahnya dengan menulis array.
  2. Tiga cabang keputusan: id tidak valid (422), produk tidak ada (404), dan produk ditemukan (200) masing-masing diuji dengan satu assertion — setiap cabang kode punya saksi di rangkaian pengujian.
  3. Fungsi assertSame(): pengganti assertion framework untuk contoh ini; memakai perbandingan ketat (===) sehingga "200" dan 200 tidak dianggap sama.
  4. Assertion keempat: memeriksa struktur body secara penuh, bukan hanya status code — inilah yang menjaga kontrak respons tetap stabil bagi klien.

Test Double untuk Repositori #

Handler nyata tentu perlu membaca data, dan di situlah muncul godaan menyelipkan PDO langsung di dalamnya — keputusan yang membuat handler sulit diuji tanpa database. Solusinya adalah membalik ketergantungan: handler menerima interface ProductRepository, lalu pengujian menyuntikkan implementasi palsu (test double) yang menyimpan data di array. Handler tidak peduli asal data; pengujian tidak butuh database:

php
<?php

interface ProductRepository
{
    public function find(string $id): ?array;
}

function handleShowProduct(ProductRepository $repo, string $id): array
{
    $produk = $repo->find($id);
    if ($produk === null) {
        return ['status' => 404, 'body' => ['error' => 'not_found']];
    }

    return ['status' => 200, 'body' => ['data' => $produk]];
}

class InMemoryProductRepository implements ProductRepository
{
    public function __construct(private array $rows = [])
    {
    }

    public function find(string $id): ?array
    {
        return $this->rows[$id] ?? null;
    }
}

$repo = new InMemoryProductRepository([
    'kopi-gayo' => ['nama' => 'Kopi Gayo 200g', 'harga' => 85000],
]);

$hasil1 = handleShowProduct($repo, 'kopi-gayo');
$hasil2 = handleShowProduct($repo, 'teh-hijau');

echo "kopi-gayo : HTTP {$hasil1['status']}\n";
echo "teh-hijau : HTTP {$hasil2['status']}\n";

Output yang Dihasilkan:

text
kopi-gayo : HTTP 200
teh-hijau : HTTP 404

Interface ProductRepository mendefinisikan kontrak pencarian produk: menerima ID string, mengembalikan array data atau null. Kelas InMemoryProductRepository memenuhi kontrak tersebut dengan array $rows — pembuatannya murah, perilakunya bisa diprediksi penuh, dan skenario “produk tidak ada” tinggal ditulis dengan ID yang memang tidak ada di array. Teknik ini bernama test double, dan variasinya (stub, fake, spy, mock) dibahas tuntas pada chapter Test Doubles .

Database Uji dengan SQLite in Memory #

Test double menjawab handler yang memakai repositori, tetapi kode repositori itu sendiri juga perlu diuji — dan pengujian terbaiknya justru memakai database sungguhan. SQLite dengan DSN sqlite::memory: memberi database lengkap yang hidup hanya di memori proses: tanpa berkas, tanpa server, tanpa pembersihan. Setiap pengujian memanggil fungsi pembuat database sendiri sehingga kondisi awal selalu segar dan pengujian tidak saling bocor:

php
<?php

function buatDbUji(): PDO
{
    $db = new PDO('sqlite::memory:');
    $db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    $db->exec('CREATE TABLE products (id TEXT PRIMARY KEY, nama TEXT, harga INTEGER)');
    $db->exec("INSERT INTO products VALUES ('kopi-gayo', 'Kopi Gayo 200g', 85000)");

    return $db;
}

function hitungProduk(PDO $db): int
{
    return (int) $db->query('SELECT COUNT(*) FROM products')->fetchColumn();
}

$dbA = buatDbUji();
$dbA->exec("INSERT INTO products VALUES ('teh-hijau', 'Teh Hijau 50s', 40000)");
echo "test A melihat " . hitungProduk($dbA) . " produk\n";

$dbB = buatDbUji();
echo "test B melihat " . hitungProduk($dbB) . " produk\n";

Output yang Dihasilkan:

text
test A melihat 2 produk
test B melihat 1 produk

Fungsi buatDbUji() selalu menghasilkan keadaan awal yang identik: skema segar dan satu baris produk. Output membuktikan isolasinya — “test A” yang menambah baris kedua melihat 2 produk, sedangkan “test B” yang memanggil fungsi pembuat lagi melihat 1 produk karena memori :memory: tidak pernah dibagi antar koneksi. Pola “pabrik database per test” ini juga bekerja untuk migrasi: jalankan ulang skema di setiap pengujian, dan struktur database pun ikut teruji. Untuk pendalaman skenario data uji dan transaksi, kunjungi chapter Database Testing .

Testing di Level HTTP #

Lapisan teratas menguji API persis seperti yang dialami klien: permintaan HTTP sungguhan, router sungguhan, header dan body sungguhan. Server bawaan PHP cukup untuk kebutuhan ini — berikut router kecil yang melayani satu endpoint produk:

php
<?php
// router.php — simpan di direktori proyek, lalu jalankan:
// php -S 127.0.0.1:8099 router.php
$method = $_SERVER['REQUEST_METHOD'];
$path   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($method === 'GET' && $path === '/api/products') {
    header('Content-Type: application/json');
    echo json_encode(['data' => [['id' => 'kopi-gayo', 'nama' => 'Kopi Gayo 200g']]]);
    return true;
}

http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'not_found']);

Setelah server berjalan, curl menjadi klien uji kita. Jalankan server di terminal pertama:

bash
php -S 127.0.0.1:8099 router.php

Lalu kirim permintaan dari terminal kedua. Flag -w "%{http_code}" mencetak status code, sedangkan -s -o /dev/null membuang body saat kita hanya ingin memeriksa status:

bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8099/api/products
curl -s http://127.0.0.1:8099/api/products
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8099/api/tidak-ada

Saat dijalankan manual, dua permintaan pertama mengembalikan 200 beserta body JSON daftar produk, dan permintaan ketiga mengembalikan 404 — persis kontrak yang ditulis di router. Menjalankan rangkaian seperti ini di skrip shell memberi pengujian end-to-end yang murah; framework testing HTTP lanjutan di atas PHPUnit dibahas pada chapter HTTP Testing .

Contract Testing #

Piramida di atas menguji bahwa server berperilaku benar menurut versinya sendiri. Contract testing menguji hal yang berbeda: apakah server dan klien masih sepakat tentang bentuk komunikasi mereka. Kontrak API ditulis sebagai spesifikasi formal — paling umum berupa dokumen OpenAPI — yang mendefinisikan endpoint, parameter, dan skema respons. Pengujian kontrak kemudian membandingkan respons nyata server terhadap skema: bila server mengubah tipe field harga dari angka menjadi string tanpa sadar, pengujian kontrak gagal sebelum klien produksi ikut rusak.

Dua arah pengujian kontrak umum dipakai. Arah provider memastikan server memenuhi spesifikasi yang dijanjikannya; arah consumer memastikan klien hanya memakai bagian API yang benar-benar ada. Keduanya menghilangkan kelas bug integrasi yang paling menjengkelkan: masing-masing pihak benar sendiri, tetapi asumsi mereka tidak cocok. Karena kontrak ini hidup di dokumen OpenAPI, pembahasan mendalamnya menunggu chapter OpenAPI Documentation .

Referensi #

Ringkasan #

Pengujian API yang sehat berbentuk piramida: handler murni diuji dengan assertion polos berkat desain yang menerima input dan mengembalikan respons secara eksplisit; test double menggantikan repositori saat fokusnya adalah logika handler; SQLite :memory: memberi database sungguhan yang segar untuk setiap pengujian; dan sedikit end-to-end test lewat php -S serta curl memastikan seluruh rangkaian tersambung. Contract testing di atas dokumen OpenAPI kemudian menjaga kesepakatan server dengan klien-kliennya. Assertion polos pada chapter ini sengaja ditulis tanpa framework agar mekanismenya transparan; pada section Testing dan Quality Assurance , PHPUnit mengotomatiskan semuanya — mulai dari menulis PHPUnit test pertama hingga coverage dan mocking.