Lapisan web punya masalah pengujian yang khas: kode yang paling sering berubah justru menempel pada mekanisme yang paling sulit diuji — echo, header(), superglobal $_GET, dan session. Kalau semua logika HTTP melekat langsung pada hal-hal itu, satu-satunya cara mengujinya adalah menjalankan web server sungguhan untuk setiap test, dan suite menjadi lambat serta rapuh. Pada Chapter ini kita membalik strukturnya: handler ditulis sebagai fungsi murni yang menerima request dan mengembalikan response, sehingga bisa diuji tanpa server sama sekali. Setelah pola itu kokoh, kita tutup dengan uji asap yang memang memakai php -S dan curl untuk memverifikasi perekatan yang tak terlihat dari dalam.

Handler Murni Tanpa Web Server #

Ide dasarnya satu kalimat: pisahkan keputusan dari pengiriman. Handler menentukan status, header, dan body; pengirim (web server atau framework) yang bertugas mengirimkannya ke klien. Dalam bentuk paling primitif, handler hanyalah fungsi yang menerima array dan mengembalikan array:

php
<?php

declare(strict_types=1);

// Handler murni: masukan array, keluaran array — tanpa echo, tanpa header()
function sapaHandler(array $request): array
{
    $nama = $request['query']['nama'] ?? 'tamu';

    return [
        'status' => 200,
        'headers' => ['Content-Type' => 'text/plain; charset=utf-8'],
        'body' => "Halo, {$nama}!",
    ];
}

// Emulasi dua "request" berbeda tanpa web server
foreach (
    [
        ['query' => ['nama' => 'Budi']],
        ['query' => []],
    ] as $request
) {
    $response = sapaHandler($request);
    echo $response['status'], ' | ', $response['body'], "\n";
}

Output yang Dihasilkan:

text
200 | Halo, Budi!
200 | Halo, tamu!

Dua baris output tersebut berasal dari dua “request” berbeda yang diproses fungsi yang sama — perhatikan bahwa sapaHandler() sendiri tidak pernah memanggil echo. Karena handler hanya mengembalikan nilai, test dapat menegaskan ketiga komponen respons secara langsung dan berjalan dalam mikrodetik. Sisi pengiriman (mengubah status menjadi http_response_code(), header menjadi header(), body menjadi echo) tinggal satu lapisan tipis yang diuji terpisah pada bagian akhir Chapter ini.

Objek Request dan Response #

Array mentah cukup untuk memulai, tetapi object memberi bentuk yang jelas dan tipe yang bisa diperiksa. Kita naikkan demo di atas menjadi dua value object kecil:

php
<?php

declare(strict_types=1);

final class Request
{
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array $query = [],
        public readonly array $cookies = [],
    ) {
    }
}
php
<?php

declare(strict_types=1);

final class Response
{
    public function __construct(
        public readonly int $status,
        public readonly array $headers,
        public readonly string $body,
    ) {
    }

    public static function json(int $status, array $data): self
    {
        return new self(
            $status,
            ['Content-Type' => 'application/json; charset=utf-8'],
            (string) json_encode($data, JSON_UNESCAPED_UNICODE),
        );
    }
}

Request membawa method, path, query string, dan cookie — persis data yang biasanya datang dari superglobal, hanya saja sekarang bentuknya nilai biasa yang bisa diciptakan test secara bebas. Response menyimpan status, header sebagai map nama => nilai (lebih mudah ditegaskan daripada daftar string), dan body sebagai string; factory Response::json() merangkum json_encode() beserta content-type agar handler tidak mengulanginya terus-menerus. Kedua class ini final dengan property readonly, menandakan bahwa request dan response adalah nilai yang tidak berubah setelah diciptakan.

Router Sederhana dan Handler Produk #

Sekarang handler yang sesungguhnya. ProductHandler::daftar() memvalidasi parameter kategori lalu mengembalikan daftar produk sebagai JSON, sementara class App bertindak sebagai router yang memetakan path ke handler:

php
<?php

declare(strict_types=1);

final class ProductHandler
{
    private const PRODUK = [
        'buku' => [['nama' => 'Belajar PHP Dasar', 'harga' => 95000]],
        'mug' => [['nama' => 'Mug Eksklusif', 'harga' => 25000]],
    ];

    public function daftar(Request $request): Response
    {
        if (!isset($request->query['kategori'])) {
            return Response::json(422, ['error' => 'Parameter kategori wajib diisi']);
        }

        $kategori = (string) $request->query['kategori'];
        $produk = self::PRODUK[$kategori] ?? [];

        return Response::json(200, ['data' => $produk]);
    }
}
php
<?php

declare(strict_types=1);

final class App
{
    public function handle(Request $request): Response
    {
        return match ($request->path) {
            '/api/produk' => (new ProductHandler())->daftar($request),
            '/api/tema' => (new ThemeHandler())->ganti($request),
            default => Response::json(404, ['error' => 'Tidak ditemukan']),
        };
    }
}

Dua hal patut digarisbawahi. Pertama, PRODUK adalah konstanta — data fixture yang sama untuk semua test, bukan koneksi database, sehingga test handler benar-benar fokus pada logika HTTP. Kedua, routing diuji sebagai perilaku: berikan path, lihat respons yang keluar. Kita tidak perlu (dan tidak seharusnya) mengintip tabel routing di dalam App; test pada bagian berikut cukup mengamati bahwa path tak dikenal menghasilkan status 404.

Helper Bergaya Supertest #

Alat uji HTTP populer di ekosistem JavaScript bernama supertest terkenal karena assertion-nya yang lancar: request.get('/produk').expect(200).expect('Content-Type', /json/). Kita bisa mengadopsi rasa itu dengan dua class kecil di tests/support/:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class TestResponse
{
    public function __construct(private readonly Response $response)
    {
    }

    public function assertStatus(int $expected): self
    {
        TestCase::assertSame($expected, $this->response->status);

        return $this;
    }

    public function assertHeader(string $name, string $expected): self
    {
        TestCase::assertArrayHasKey($name, $this->response->headers);
        TestCase::assertSame($expected, $this->response->headers[$name]);

        return $this;
    }

    public function assertJson(array $expected): self
    {
        TestCase::assertSame($expected, json_decode($this->response->body, true));

        return $this;
    }
}
php
<?php

declare(strict_types=1);

final class HttpClientTester
{
    public function __construct(private readonly App $app)
    {
    }

    public function get(string $path, array $query = [], array $cookies = []): TestResponse
    {
        return new TestResponse(
            $this->app->handle(new Request('GET', $path, $query, $cookies)),
        );
    }
}

HttpClientTester memainkan peran “klien”: method get() membangun Request, mengirimkannya ke App, lalu membungkus Response hasilnya dengan TestResponse. Setiap method assert...() di TestResponse mendelegasikan ke assertion statis TestCase, mengembalikan $this agar panggilan bisa dirantai. Test yang memakainya terbaca hampir seperti skenario HTTP:

php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class ProductHandlerTest extends TestCase
{
    public function testDaftarProdukTanpaKategoriDitolak(): void
    {
        $client = new HttpClientTester(new App());

        $client->get('/api/produk')
            ->assertStatus(422)
            ->assertJson(['error' => 'Parameter kategori wajib diisi']);
    }

    public function testDaftarProdukKategoriBuku(): void
    {
        $client = new HttpClientTester(new App());

        $client->get('/api/produk', ['kategori' => 'buku'])
            ->assertStatus(200)
            ->assertJson(['data' => [['nama' => 'Belajar PHP Dasar', 'harga' => 95000]]]);
    }

    public function testRuteTakDikenalMengembalikan404(): void
    {
        $client = new HttpClientTester(new App());

        $client->get('/api/tidak-ada')->assertStatus(404);
    }
}

Rantai assertStatus(200)->assertJson(...) menjatuhkan test tepat di titik yang gagal: status salah terlihat sebelum body dibandingkan. assertJson() memakai json_decode() lalu assertSame terhadap array penuh, sehingga field yang hilang atau bertipe berbeda langsung ketahuan.

State cookie dan session adalah sumber favorit bug HTTP. Solusinya sama seperti database pada Chapter database testing : jangan biarkan handler membaca global; suntikkan sebagai array biasa. Request sudah menyediakan slotnya lewat property $cookies. Berikut handler yang membalik tema tampilan, lengkap dengan cookie yang dipasangnya:

php
<?php

declare(strict_types=1);

final class ThemeHandler
{
    public function ganti(Request $request): Response
    {
        if (!array_key_exists('theme', $request->cookies)) {
            return $this->responsTema('terang');
        }

        $temaBaru = $request->cookies['theme'] === 'terang' ? 'gelap' : 'terang';

        return $this->responsTema($temaBaru);
    }

    private function responsTema(string $tema): Response
    {
        return new Response(
            200,
            [
                'Content-Type' => 'application/json; charset=utf-8',
                'Set-Cookie' => "theme={$tema}; Path=/; HttpOnly",
            ],
            (string) json_encode(['tema' => $tema], JSON_UNESCAPED_UNICODE),
        );
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class ThemeHandlerTest extends TestCase
{
    public function testKunjunganPertamaMemasangCookieTema(): void
    {
        $client = new HttpClientTester(new App());

        $client->get('/api/tema')
            ->assertStatus(200)
            ->assertJson(['tema' => 'terang'])
            ->assertHeader('Set-Cookie', 'theme=terang; Path=/; HttpOnly');
    }

    public function testCookieGelapBerubahKeTerang(): void
    {
        $client = new HttpClientTester(new App());

        $client->get('/api/tema', [], ['theme' => 'gelap'])
            ->assertStatus(200)
            ->assertJson(['tema' => 'terang'])
            ->assertHeader('Set-Cookie', 'theme=terang; Path=/; HttpOnly');
    }
}

Percakapan cookie kini terjadi sepenuhnya lewat nilai yang kelihatan: cookie masuk lewat argumen ketiga get(), cookie keluar ditegaskan lewat assertHeader('Set-Cookie', ...). Tidak ada $_COOKIE dan tidak ada browser nyata, tetapi perilakunya sama persis dengan yang akan dialami klien sungguhan.

Session mengikuti prinsip yang sama dengan satu perbedaan: session berubah selama request berlangsung, jadi ia disuntikkan sebagai array lewat referensi:

php
<?php

declare(strict_types=1);

final class VisitCounter
{
    public function catat(array &$session): int
    {
        $session['kunjungan'] = ($session['kunjungan'] ?? 0) + 1;

        return $session['kunjungan'];
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class VisitCounterTest extends TestCase
{
    public function testKunjunganBertambahDanTersimpanDiSession(): void
    {
        $counter = new VisitCounter();
        $session = [];

        $this->assertSame(1, $counter->catat($session));
        $this->assertSame(2, $counter->catat($session));
        $this->assertSame(['kunjungan' => 2], $session);
    }
}

Test di atas memanggil catat() dua kali dengan array $session yang sama dan melihat hitungan bertambah — perilaku berkelanjutan ala session, tanpa session_start() sama sekali. Untuk dipasang di produksi, lapisan pengirim cukup menyalin $_SESSION ke array, memanggil handler, lalu menuliskan hasilnya kembali. Versi yang menyentuh session bawaan PHP memang hanya bisa berjalan lewat web server sungguhan:

php
<?php

declare(strict_types=1);

// Versi lapisan pengirim — hanya berjalan lewat web server, bukan di test
session_start();

$sessionBaru = $_SESSION;
$counter = new VisitCounter();
$kunjungan = $counter->catat($sessionBaru);
$_SESSION = $sessionBaru;

echo "Kunjungan ke-{$kunjungan}";

Blok di atas tidak bisa diverifikasi otomatis karena session_start() membutuhkan konteks request sungguhan; ketika dicoba lewat php -S di lingkungan penulis, skrip ini mencetak hitungan yang bertambah pada setiap refresh browser.

Emulasi Klien: Membangun Request dari Superglobals #

Di batas terakhir antara kode murni dan dunia nyata, kita butuh satu jembatan: membangun Request dari superglobal PHP. RequestFactory::fromGlobals() adalah tempat satu-satunya $_SERVER, $_GET, dan $_COOKIE disentuh:

php
<?php

declare(strict_types=1);

final class RequestFactory
{
    public static function fromGlobals(): Request
    {
        return new Request(
            $_SERVER['REQUEST_METHOD'] ?? 'GET',
            (string) ($_SERVER['PATH_INFO'] ?? '/'),
            $_GET,
            $_COOKIE,
        );
    }
}
php
<?php

declare(strict_types=1);

use PHPUnit\Framework\TestCase;

final class RequestFactoryTest extends TestCase
{
    protected function tearDown(): void
    {
        $_SERVER = [];
        $_GET = [];
        $_COOKIE = [];
    }

    public function testMembangunRequestDariSuperglobals(): void
    {
        $_SERVER['REQUEST_METHOD'] = 'GET';
        $_SERVER['PATH_INFO'] = '/api/produk';
        $_GET['kategori'] = 'mug';
        $_COOKIE['theme'] = 'gelap';

        $request = RequestFactory::fromGlobals();

        $this->assertSame('GET', $request->method);
        $this->assertSame('/api/produk', $request->path);
        $this->assertSame(['kategori' => 'mug'], $request->query);
        $this->assertSame(['theme' => 'gelap'], $request->cookies);
    }
}

Test ini sengaja memodifikasi superglobal — hak yang hanya dimiliki test — lalu memanggil factory dan menegaskan hasil terjemahannya. Perhatikan tearDown() yang mengosongkan kembali ketiga superglobal: tanpa itu, sisa nilai bisa bocor ke test berikutnya karena superglobal adalah state global sejati.

Menjalankan Seluruh Suite HTTP #

bootstrap.php memuat seluruh class produksi dan helper test sebelum suite berjalan:

php
<?php

declare(strict_types=1);

require __DIR__ . '/src/Request.php';
require __DIR__ . '/src/Response.php';
require __DIR__ . '/src/ProductHandler.php';
require __DIR__ . '/src/ThemeHandler.php';
require __DIR__ . '/src/App.php';
require __DIR__ . '/src/RequestFactory.php';
require __DIR__ . '/src/VisitCounter.php';
require __DIR__ . '/tests/support/TestResponse.php';
require __DIR__ . '/tests/support/HttpClientTester.php';
bash
php /home/z/my-project/bin/phpunit.phar --bootstrap bootstrap.php tests

Berikut output nyata dari perintah tersebut:

text
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.23

.......                                                             7 / 7 (100%)

Time: 00:00.003, Memory: 28.05 MB

OK (7 tests, 20 assertions)

Tujuh test — tiga untuk handler produk dan routing, dua untuk cookie tema, satu untuk session, satu untuk factory superglobal — selesai dalam tiga milidetik tanpa satu pun proses server dihidupkan. Inilah kecepatan unit test yang dinikmati lapisan web hanya karena strukturnya dijaga murni.

Uji Asap dengan Server Sungguhan #

Lapisan pengiriman tetap perlu diverifikasi setidaknya sekali: apakah header(), http_response_code(), dan echo benar-benar menghasilkan HTTP yang sahih. Uji asap (smoke test) semacam ini cukup dua-tiga request, dijalankan dengan web server bawaan PHP:

php
<?php

declare(strict_types=1);

// public/index.php — router sederhana untuk php -S
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($path === '/api/produk') {
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(
        ['data' => [['nama' => 'Belajar PHP Dasar', 'harga' => 95000]]],
        JSON_UNESCAPED_UNICODE,
    );
    return true;
}

http_response_code(404);
echo 'Tidak ditemukan';
return true;

Server dinyalakan dari terminal terpisah dengan php -S, lalu diintip memakai curl:

bash
php -S 127.0.0.1:8099 -t public
bash
curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8099/api/produk
curl -s -w '\nHTTP %{http_code}\n' http://127.0.0.1:8099/api/tidak-ada

Kedua perintah tersebut benar-benar dijalankan pada lingkungan penulis dan menghasilkan keluaran berikut:

text
{"data":[{"nama":"Belajar PHP Dasar","harga":95000}]}
HTTP 200
text
Tidak ditemukan
HTTP 404

Format -w '\nHTTP %{http_code}\n' sengaja dipilih karena hanya mencetak status dan body — header respons asli mengandung Date yang berubah setiap detik sehingga tidak layak ditegaskan. Uji asap seperti ini memverifikasi hal yang tak terlihat dari dalam: header terkirim sebelum body, status 404 benar-benar dikirim, dan JSON tidak terpotong. Pola server-nyata yang lebih lengkap — menyalakan server dari dalam skrip, menjalankan banyak assertion, lalu memadamkannya otomatis — menjadi pokok bahasan Chapter end-to-end testing .

Batas Wajar: Jangan Unit-test Internal Framework #

Pola Chapter ini berlaku untuk aplikasi yang kita kendalikan strukturnya — aplikasi tanpa framework, atau framework buatan sendiri. Saat memakai Laravel atau Symfony, jangan menembus internalnya untuk menguji: jangan menegaskan isi tabel routing, urutan middleware, atau isi container secara langsung, karena semua itu adalah detail implementasi yang berubah antar versi. Gunakan alat uji resminya — Laravel menyediakan HTTP testing bawaan (getJson() beserta assertStatus() dan assertJson()), dan Symfony menyediakan WebTestCase yang menjalankan kernel sungguhan — keduanya membangun request secara programatik seperti HttpClientTester kita, hanya dengan dukungan penuh framework. Prinsipnya tetap sama: uji dari luar lewat request dan respons, bukan dari dalam lewat struktur internal.

Ringkasan #

HTTP testing yang sehat dimulai dari handler murni: request dan response sebagai nilai, cookie dan session sebagai array tersuntik, superglobal hanya disentuh satu factory kecil, dan seluruhnya bisa ditegaskan dengan helper lancar bergaya supertest — semuanya tanpa web server. Lapisan pengiriman tipis yang tersisa diverifikasi lewat uji asap php -S dan curl sesekali, dan saat bekerja di dalam framework kita memakai alat uji resminya alih-alih mengintip internalnya. Dengan seluruh lapisan teruji, kita siap melangkah ke pengujian paling luar dari semua: end-to-end testing yang mengalir seperti pengguna sungguhan.

Referensi #