Selama lima bagian, setiap perilaku API kita buktikan dengan curl manual: jalankan server, kirim request, lihat responsenya, lalu tutup terminal. Bukti semacam itu tidak bisa diulang setiap kali kode berubah — dan justru di situlah nilainya. Pada Bagian 6 kita mengubah seluruh bukti itu menjadi test suite PHPUnit: satu perintah yang membangun aplikasi sungguhan (router, repository, validator, token), mengirim request kepadanya, lalu memeriksa setiap response. Kuncinya ada pada keputusan arsitektur Bagian 2: karena Request dan Response adalah objek nilai yang tidak menyentuh superglobal maupun fungsi header(), seluruh alur HTTP bisa diuji tanpa membuka satu pun socket.
Teknik Inti: Request Tiruan Tanpa Web Server #
Sebuah request HTTP, dari sudut pandang handler, hanyalah data: method, path, query, body, dan header. Kelas Request::fromArray() dari Bagian 2 membangun data itu dari array biasa, Router::dispatch() menjalankannya melewati seluruh pipeline, dan hasilnya adalah objek Response yang bisa diperiksa property-nya. Rangkaian tiga langkah itulah seluruh mesin pengujian kita:
<?php
require __DIR__ . '/tests/bootstrap.php';
use App\Request;
// Aplikasi uji dari bootstrap: database :memory: segar + seluruh endpoint.
$app = buatAplikasi();
$router = $app['router'];
// Request dibangun sebagai array biasa — tanpa socket, tanpa superglobal.
$login = $router->dispatch(Request::fromArray([
'method' => 'POST',
'path' => '/api/login',
'body' => ['email' => '[email protected]', 'sandi' => 'sandi-budi-2026'],
]));
$token = json_decode($login->body(), true)['token'];
$tanpaToken = $router->dispatch(Request::fromArray([
'method' => 'GET',
'path' => '/api/tugas',
]));
$denganToken = $router->dispatch(Request::fromArray([
'method' => 'GET',
'path' => '/api/tugas',
'headers' => ['Authorization' => "Bearer $token"],
]));
printf("login : %d (token panjang %d)\n", $login->status(), strlen($token));
printf("tanpa token : %d %s\n", $tanpaToken->status(), $tanpaToken->body());
printf("dengan token : %d %s\n", $denganToken->status(), $denganToken->body());Output yang Dihasilkan:
login : 200 (token panjang 64)
tanpa token : 401 {"error":{"code":"UNAUTHORIZED","message":"Kirim token lewat header Authorization: Bearer <token>."}}
dengan token : 200 [{"id":1,"judul":"Belajar PHP dasar","status":"selesai","dibuat_pada":"2026-01-05 08:00:00"},{"id":2,"judul":"Merancang REST API","status":"proses","dibuat_pada":"2026-01-10 09:30:00"},{"id":3,"judul":"Menulis dokumentasi API","status":"belum","dibuat_pada":"2026-01-12 14:15:00"}]Tiga baris hasil tersebut adalah alur Bagian 5 yang dulu dibuktikan lewat curl, kini berjalan dalam satu proses PHP biasa. Perhatikan bahwa kolom dibuat_pada pada seed data berwaktu tetap membuat output ini deterministik — token acak tidak perlu dicetak utuh, cukup panjangnya. Dalam test sungguhan kita tidak mencetak apa pun; kita mengganti printf() dengan assertion yang gagal keras bila nilai tidak cocok. Pendekatan ini setara dengan integration test pada framework besar: seluruh tumpukan aplikasi ikut berjalan, hanya lapisan transport HTTP yang diganti pemanggilan langsung.
tests/bootstrap.php: Pabrik Aplikasi Uji #
Berkas bootstrap bertugas memuat kelas-kelas proyek dan menyediakan buatAplikasi() — fungsi yang membangun database :memory: segar, menjalankan skema, membuat pengguna demo, lalu memasang seluruh endpoint persis seperti front controller:
<?php
declare(strict_types=1);
/**
* Bootstrap test suite REST API Manajemen Tugas.
*
* Tugas berkas ini: memuat seluruh kelas proyek, lalu menyediakan pabrik
* aplikasi uji buatAplikasi() — database :memory: yang segar setiap kali
* dipanggil, dengan router yang memasang endpoint persis seperti front
* controller. Test tidak perlu web server: request dibangun dengan
* Request::fromArray(), di-dispatch router, dan response diperiksa
* langsung sebagai objek.
*
* Catatan struktur: pada proyek nyata seluruh berkas src/ berada dalam satu
* direktori dan dimuat otomatis oleh autoloader Composer. Pada bundle
* halaman ini kelas-kelas tersebar di folder bagian-bagian sebelumnya,
* sehingga kita memuatnya eksplisit dengan path relatif dari tests/.
*/
require __DIR__ . '/../../bagian-2-routing-dan-request/src/Request.php';
require __DIR__ . '/../../bagian-2-routing-dan-request/src/Response.php';
require __DIR__ . '/../../bagian-2-routing-dan-request/src/Router.php';
require __DIR__ . '/../../bagian-3-database-dan-repository/src/Database.php';
require __DIR__ . '/../../bagian-3-database-dan-repository/src/TaskRepository.php';
require __DIR__ . '/../../bagian-4-validation-dan-error-responses/src/Validator.php';
require __DIR__ . '/../../bagian-4-validation-dan-error-responses/src/ErrorHandler.php';
require __DIR__ . '/../../bagian-5-authentication-dan-authorization/src/TokenAuth.php';
require __DIR__ . '/../../bagian-5-authentication-dan-authorization/src/UserRepository.php';
use App\Database;
use App\ErrorHandler;
use App\Request;
use App\Response;
use App\Router;
use App\TaskRepository;
use App\TokenAuth;
use App\UserRepository;
use App\Validator;
// Test harus hermetis: buang TASK_API_DB dari lingkungan supaya
// Database::buat() selalu jatuh ke sqlite::memory:, apapun isi shell
// yang menjalankan phpunit.
putenv('TASK_API_DB');
const SKEMA_TUGAS = __DIR__ . '/../../bagian-3-database-dan-repository/database/schema.sql';
/**
* Bangun satu aplikasi uji yang utuh.
*
* Setiap panggilan menghasilkan database baru (in-memory) + router baru,
* sehingga setiap test mulai dari kondisi awal yang sama: tiga seed tugas
* dan satu pengguna demo.
*
* @return array{router: Router, auth: TokenAuth}
*/
function buatAplikasi(): array
{
$pdo = Database::buat();
Database::migrasi($pdo, SKEMA_TUGAS);
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
kedaluwarsa TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$repo = new TaskRepository($pdo);
$users = new UserRepository($pdo);
$auth = new TokenAuth($pdo);
// Pengguna tetap untuk alur login (sandi demo, bukan rahasia).
$users->buatUser('[email protected]', 'sandi-budi-2026', 'Budi');
$router = new Router();
// --- Route publik -----------------------------------------------------
$router->add('GET', '/api/kesehatan', static fn (Request $r): Response => Response::json(['status' => 'ok']));
$router->add('POST', '/api/login', static function (Request $r) use ($users, $auth): Response {
$baris = $users->cariEmail((string) $r->body('email', ''));
if ($baris === null || !password_verify((string) $r->body('sandi', ''), $baris['password_hash'])) {
return Response::json(ErrorHandler::payload([
'code' => 'UNAUTHORIZED',
'message' => 'Email atau sandi salah.',
]), 401);
}
return Response::json(['token' => $auth->issue((int) $baris['id'])]);
});
// --- Route terlindungi ------------------------------------------------
$router->add('POST', '/api/logout', static function (Request $r) use ($auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$auth->revoke(substr((string) $r->header('Authorization'), strlen('Bearer ')));
return Response::noContent();
});
$router->add('GET', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
return Response::json($repo->semua());
});
$router->add('GET', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$tugas = $repo->cariId((int) $params['id']);
if ($tugas === null) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::json($tugas);
});
$router->add('POST', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$errors = (new Validator())
->required('judul')
->min('judul', 3)
->max('judul', 100)
->in('status', ['belum', 'proses', 'selesai'])
->validate($r->body());
if ($errors !== []) {
return Response::json(ErrorHandler::payload($errors), 422);
}
return Response::json($repo->buat($r->body()), 201);
});
$router->add('PUT', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$errors = (new Validator())
->min('judul', 3)
->max('judul', 100)
->in('status', ['belum', 'proses', 'selesai'])
->validate($r->body());
if ($errors !== []) {
return Response::json(ErrorHandler::payload($errors), 422);
}
$tugas = $repo->perbarui((int) $params['id'], $r->body());
if ($tugas === null) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::json($tugas);
});
$router->add('DELETE', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
if (!$repo->hapus((int) $params['id'])) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::noContent();
});
return ['router' => $router, 'auth' => $auth];
}Tiga keputusan pada berkas ini menentukan mutu seluruh suite. Pertama, buatAplikasi() memakai Database::buat() tanpa argumen — database :memory: yang lenyap bersama koneksinya, sehingga setiap test benar-benar mulai dari nol; ini kebalikan dari peringatan “in-memory itu per-proses” pada Bagian 3, yang di sini justru menjadi keunggulan. Kedua, putenv('TASK_API_DB') menghapus variabel lingkungan sebelum test apa pun berjalan — tanpa baris itu, menjalankan phpunit dari shell yang masih menyetel TASK_API_DB (misalnya sisa sesi Bagian 4) diam-diam memindahkan test ke database berkas dan merusak isolasi. Ketiga, aplikasi uji memasang endpoint dengan kode yang sama seperti front controller, sehingga yang diuji adalah perilaku asli, bukan tiruan yang cepat usang.
tests/ApiTest.php: Sebelas Test untuk Seluruh Kontrak #
Kelas ApiTest memetakan kontrak API menjadi test bernama. Setiap test memanggil buatAplikasi() lewat setUp(), mengirim request lewat helper kirim(), lalu meng-assert status, header, dan body JSON:
<?php
declare(strict_types=1);
use App\Request;
use App\Response;
use App\Router;
use App\TokenAuth;
use PHPUnit\Framework\TestCase;
/**
* Test API dari ujung ke ujung — tanpa web server.
*
* Setiap test membangun aplikasi segar lewat buatAplikasi() dari bootstrap:
* database :memory: berisi tiga seed tugas dan pengguna [email protected].
* Request dikirim sebagai array ke Router, response diperiksa sebagai
* objek: status, header, dan body JSON yang di-decode.
*/
final class ApiTest extends TestCase
{
private Router $router;
private TokenAuth $auth;
protected function setUp(): void
{
$app = buatAplikasi();
$this->router = $app['router'];
$this->auth = $app['auth'];
}
/**
* Kirim satu request ke aplikasi uji.
*
* @param array<string, mixed> $data
*/
private function kirim(array $data): Response
{
return $this->router->dispatch(Request::fromArray($data));
}
/**
* @return array<string, mixed> body JSON yang sudah di-decode.
*/
private function json(Response $response): array
{
return (array) json_decode($response->body(), true);
}
/**
* Login sebagai [email protected] dan kembalikan token-nya.
*/
private function login(): string
{
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/login',
'body' => ['email' => '[email protected]', 'sandi' => 'sandi-budi-2026'],
]);
self::assertSame(200, $response->status(), 'login pengguna demo harus berhasil');
return (string) $this->json($response)['token'];
}
/**
* @return array<string, string> header Authorization dengan token sah.
*/
private function headerSah(): array
{
return ['Authorization' => 'Bearer ' . $this->login()];
}
public function testKesehatanPublikTanpaToken(): void
{
$response = $this->kirim(['method' => 'GET', 'path' => '/api/kesehatan']);
self::assertSame(200, $response->status());
self::assertSame(['status' => 'ok'], $this->json($response));
}
public function testTugasTanpaTokenDitolak(): void
{
$response = $this->kirim(['method' => 'GET', 'path' => '/api/tugas']);
self::assertSame(401, $response->status());
self::assertSame('UNAUTHORIZED', $this->json($response)['error']['code']);
self::assertSame('Bearer', $response->header('WWW-Authenticate'));
}
public function testLoginDenganSandiSalahDitolak(): void
{
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/login',
'body' => ['email' => '[email protected]', 'sandi' => 'sandi-salah'],
]);
self::assertSame(401, $response->status());
self::assertSame('UNAUTHORIZED', $this->json($response)['error']['code']);
}
public function testDaftarTugasDenganTokenSah(): void
{
$response = $this->kirim([
'method' => 'GET',
'path' => '/api/tugas',
'headers' => $this->headerSah(),
]);
self::assertSame(200, $response->status());
$daftar = $this->json($response);
self::assertCount(3, $daftar, 'skema seed harus memuat tiga tugas');
self::assertSame('Belajar PHP dasar', $daftar[0]['judul']);
self::assertSame('selesai', $daftar[0]['status']);
}
public function testAlurLengkapSiklusTugas(): void
{
$headers = $this->headerSah();
// buat
$buat = $this->kirim([
'method' => 'POST',
'path' => '/api/tugas',
'headers' => $headers,
'body' => ['judul' => 'Belajar testing', 'status' => 'belum'],
]);
self::assertSame(201, $buat->status());
$tugas = $this->json($buat);
self::assertSame('Belajar testing', $tugas['judul']);
$id = (int) $tugas['id'];
// baca satu
$baca = $this->kirim(['method' => 'GET', 'path' => "/api/tugas/$id", 'headers' => $headers]);
self::assertSame(200, $baca->status());
self::assertSame($id, $this->json($baca)['id']);
// ubah parsial
$ubah = $this->kirim([
'method' => 'PUT',
'path' => "/api/tugas/$id",
'headers' => $headers,
'body' => ['status' => 'selesai'],
]);
self::assertSame(200, $ubah->status());
self::assertSame('selesai', $this->json($ubah)['status']);
self::assertSame('Belajar testing', $this->json($ubah)['judul'], 'PUT parsial tidak boleh menghapus judul');
// hapus
$hapus = $this->kirim(['method' => 'DELETE', 'path' => "/api/tugas/$id", 'headers' => $headers]);
self::assertSame(204, $hapus->status());
self::assertSame('', $hapus->body());
// sudah hilang
$hilang = $this->kirim(['method' => 'GET', 'path' => "/api/tugas/$id", 'headers' => $headers]);
self::assertSame(404, $hilang->status());
self::assertSame('NOT_FOUND', $this->json($hilang)['error']['code']);
}
public function testTulisTugasDenganDataTidakValidDitolak(): void
{
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/tugas',
'headers' => $this->headerSah(),
'body' => ['judul' => 'a', 'status' => 'batal'],
]);
self::assertSame(422, $response->status());
$error = $this->json($response)['error'];
self::assertSame('VALIDATION_ERROR', $error['code']);
self::assertArrayHasKey('judul', $error['details']);
self::assertArrayHasKey('status', $error['details']);
}
public function testTulisTugasTanpaTokenDitolakSebelumValidasi(): void
{
// 401 harus menang atas 422: pemeriksaan token berjalan lebih dulu.
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/tugas',
'body' => ['judul' => 'a', 'status' => 'batal'],
]);
self::assertSame(401, $response->status());
}
public function testTokenKedaluwarsaDitolak(): void
{
// Terbitkan langsung lewat TokenAuth dengan waktu kedaluwarsa lampau.
$token = $this->auth->issue(1, '2026-01-15 09:00:00');
$response = $this->kirim([
'method' => 'GET',
'path' => '/api/tugas',
'headers' => ['Authorization' => "Bearer $token"],
]);
self::assertSame(401, $response->status());
}
public function testLogoutMencabutToken(): void
{
$headers = $this->headerSah();
$logout = $this->kirim(['method' => 'POST', 'path' => '/api/logout', 'headers' => $headers]);
self::assertSame(204, $logout->status());
$setelah = $this->kirim(['method' => 'GET', 'path' => '/api/tugas', 'headers' => $headers]);
self::assertSame(401, $setelah->status());
}
public function testEndpointTidakDikenalMenjawab404(): void
{
$response = $this->kirim(['method' => 'GET', 'path' => '/api/tidak-ada']);
self::assertSame(404, $response->status());
self::assertSame('NOT_FOUND', $this->json($response)['error']['code']);
}
public function testMethodSalahMenjawab405DenganHeaderAllow(): void
{
$response = $this->kirim(['method' => 'PUT', 'path' => '/api/kesehatan']);
self::assertSame(405, $response->status());
self::assertSame('GET', $response->header('Allow'));
}
}Pemilihan test mengikuti kontrak yang sudah dibangun bagian demi bagian — setiap baris pada tabel di bawah menjaga satu janji yang pernah kita klaim ke calon pengguna API:
| Test | Janji yang dijaga |
|---|---|
testKesehatanPublikTanpaToken | Endpoint kesehatan selalu publik (Bagian 1) |
testTugasTanpaTokenDitolak | 401 + WWW-Authenticate tanpa token (Bagian 5) |
testLoginDenganSandiSalahDitolak | Pesan gagal login tidak membocorkan penyebab (Bagian 5) |
testDaftarTugasDenganTokenSah | Seed tiga tugas terbaca utuh (Bagian 3) |
testAlurLengkapSiklusTugas | CRUD 201/200/204/404 + PUT parsial (Bagian 3-4) |
testTulisTugasDenganDataTidakValidDitolak | 422 membawa details per kolom (Bagian 4) |
testTulisTugasTanpaTokenDitolakSebelumValidasi | Urutan lapisan: auth dulu, baru validasi (Bagian 5) |
testTokenKedaluwarsaDitolak | Token berwaktu lampau ditolak (Bagian 5) |
testLogoutMencabutToken | Logout mematikan token secara permanen (Bagian 5) |
testEndpointTidakDikenalMenjawab404 | Bentuk error routing konsisten (Bagian 2) |
testMethodSalahMenjawab405DenganHeaderAllow | 405 selalu membawa header Allow (Bagian 2) |
Dua detail kecil patut digarisbawahi. Test kedaluwarsa memakai $this->auth->issue(1, '2026-01-15 09:00:00') — menyuntik token kedaluwarsa lewat TokenAuth langsung alih-alih menunggu waktu berjalan, trik yang sama dengan demo CLI Bagian 5; waktu beku membuat test tidak pernah bergantung pada jam mesin. Sementara testTulisTugasTanpaTokenDitolakSebelumValidasi mengirim data yang jelas-jelas tidak valid tanpa token dan mengharapkan 401, bukan 422 — test ini menjaga urutan lapisan agar tidak ada yang tak sengaja membaliknya.
Menjalankan Test Suite #
PHPUnit dijalankan dengan --bootstrap yang menunjuk berkas pabrik aplikasi kita:
# dari direktori bagian ini — phpunit terpasang lewat composer
vendor/bin/phpunit --bootstrap tests/bootstrap.php tests/ApiTest.php
# varian berkas phar berdiri sendiri
php phpunit.phar --bootstrap tests/bootstrap.php tests/ApiTest.phpPHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
........... 11 / 11 (100%)
Time: 00:03.552, Memory: 28.05 MB
OK (11 tests, 38 assertions)Sebelas test, 38 assertion, semuanya hijau dalam waktu sekitar tiga setengah detik. Sebagian besar waktu itu habis di password_hash() dan password_verify() — bcrypt memang dirancang lambat, dan setiap test membangun pengguna serta login baru. Itu harga yang wajar untuk suite sekecil ini; bila suite membesar, pengguna demo bisa dibuat dengan hash yang dihitung sekali per proses. Untuk membaca hasil dengan sudut pandang perilaku, tambahkan --testdox:
$ vendor/bin/phpunit --bootstrap tests/bootstrap.php --testdox tests/ApiTest.phpApi
✔ Kesehatan publik tanpa token
✔ Tugas tanpa token ditolak
✔ Login dengan sandi salah ditolak
✔ Daftar tugas dengan token sah
✔ Alur lengkap siklus tugas
✔ Tulis tugas dengan data tidak valid ditolak
✔ Tulis tugas tanpa token ditolak sebelum validasi
✔ Token kedaluwarsa ditolak
✔ Logout mencabut token
✔ Endpoint tidak dikenal menjawab 404
✔ Method salah menjawab 405 dengan header allow
OK (11 tests, 38 assertions)Apa yang Berubah #
Dua berkas baru masuk ke proyek pada tahap ini, keduanya di luar jalur request produksi:
tests/bootstrap.php— memuat kelas-kelas proyek dengan path relatif lintas bagian, membersihkanTASK_API_DBdemi isolasi, dan menyediakanbuatAplikasi(): database:memory:+ skema + pengguna demo + seluruh endpoint.tests/ApiTest.php— sebelas test yang menjaga kontrak API: route publik vs terlindungi, alur login-logout-token, CRUD tugas, urutan auth sebelum validasi, bentuk error 404/405/422, dan headerAllow/WWW-Authenticate.
API kini punya jaring pengaman yang bisa dijalankan ulang kapan pun. Bagian 7 menutup proyek dengan dua hal yang membuat API ini bisa dipakai orang lain: dokumentasi OpenAPI sebagai kontrak tertulis untuk calon pengguna, dan Dockerfile sebagai cara mengemas serta menjalankannya di lingkungan lain.
Cara Menjalankan #
# 1. Demo teknik tanpa phpunit — salin blok "Teknik Inti" ke berkas
# contoh-uji.php di dalam folder bagian ini, lalu jalankan
php contoh-uji.php
# 2. Test suite lengkap (dari direktori bagian ini atau root proyek)
php /home/z/my-project/bin/phpunit.phar --bootstrap tests/bootstrap.php tests/ApiTest.php
# 3. Pemeriksaan sintaks kedua berkas test
php -l tests/bootstrap.php && php -l tests/ApiTest.phpDua berkas pendukung halaman ini — tests/ApiTest.php dan tests/bootstrap.php — tersedia di bagian bawah halaman. Untuk pendalaman, baca dokumentasi resmi PHPUnit
serta chapter menulis PHPUnit tests
, integration testing
, dan HTTP testing
. Dengan bukti otomatis di tangan, kita siap mempublikasikan kontrak API ini ke dunia luar.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
tests/ApiTest.php
Unduh berkas<?php
declare(strict_types=1);
use App\Request;
use App\Response;
use App\Router;
use App\TokenAuth;
use PHPUnit\Framework\TestCase;
/**
* Test API dari ujung ke ujung — tanpa web server.
*
* Setiap test membangun aplikasi segar lewat buatAplikasi() dari bootstrap:
* database :memory: berisi tiga seed tugas dan pengguna [email protected].
* Request dikirim sebagai array ke Router, response diperiksa sebagai
* objek: status, header, dan body JSON yang di-decode.
*/
final class ApiTest extends TestCase
{
private Router $router;
private TokenAuth $auth;
protected function setUp(): void
{
$app = buatAplikasi();
$this->router = $app['router'];
$this->auth = $app['auth'];
}
/**
* Kirim satu request ke aplikasi uji.
*
* @param array<string, mixed> $data
*/
private function kirim(array $data): Response
{
return $this->router->dispatch(Request::fromArray($data));
}
/**
* @return array<string, mixed> body JSON yang sudah di-decode.
*/
private function json(Response $response): array
{
return (array) json_decode($response->body(), true);
}
/**
* Login sebagai [email protected] dan kembalikan token-nya.
*/
private function login(): string
{
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/login',
'body' => ['email' => '[email protected]', 'sandi' => 'sandi-budi-2026'],
]);
self::assertSame(200, $response->status(), 'login pengguna demo harus berhasil');
return (string) $this->json($response)['token'];
}
/**
* @return array<string, string> header Authorization dengan token sah.
*/
private function headerSah(): array
{
return ['Authorization' => 'Bearer ' . $this->login()];
}
public function testKesehatanPublikTanpaToken(): void
{
$response = $this->kirim(['method' => 'GET', 'path' => '/api/kesehatan']);
self::assertSame(200, $response->status());
self::assertSame(['status' => 'ok'], $this->json($response));
}
public function testTugasTanpaTokenDitolak(): void
{
$response = $this->kirim(['method' => 'GET', 'path' => '/api/tugas']);
self::assertSame(401, $response->status());
self::assertSame('UNAUTHORIZED', $this->json($response)['error']['code']);
self::assertSame('Bearer', $response->header('WWW-Authenticate'));
}
public function testLoginDenganSandiSalahDitolak(): void
{
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/login',
'body' => ['email' => '[email protected]', 'sandi' => 'sandi-salah'],
]);
self::assertSame(401, $response->status());
self::assertSame('UNAUTHORIZED', $this->json($response)['error']['code']);
}
public function testDaftarTugasDenganTokenSah(): void
{
$response = $this->kirim([
'method' => 'GET',
'path' => '/api/tugas',
'headers' => $this->headerSah(),
]);
self::assertSame(200, $response->status());
$daftar = $this->json($response);
self::assertCount(3, $daftar, 'skema seed harus memuat tiga tugas');
self::assertSame('Belajar PHP dasar', $daftar[0]['judul']);
self::assertSame('selesai', $daftar[0]['status']);
}
public function testAlurLengkapSiklusTugas(): void
{
$headers = $this->headerSah();
// buat
$buat = $this->kirim([
'method' => 'POST',
'path' => '/api/tugas',
'headers' => $headers,
'body' => ['judul' => 'Belajar testing', 'status' => 'belum'],
]);
self::assertSame(201, $buat->status());
$tugas = $this->json($buat);
self::assertSame('Belajar testing', $tugas['judul']);
$id = (int) $tugas['id'];
// baca satu
$baca = $this->kirim(['method' => 'GET', 'path' => "/api/tugas/$id", 'headers' => $headers]);
self::assertSame(200, $baca->status());
self::assertSame($id, $this->json($baca)['id']);
// ubah parsial
$ubah = $this->kirim([
'method' => 'PUT',
'path' => "/api/tugas/$id",
'headers' => $headers,
'body' => ['status' => 'selesai'],
]);
self::assertSame(200, $ubah->status());
self::assertSame('selesai', $this->json($ubah)['status']);
self::assertSame('Belajar testing', $this->json($ubah)['judul'], 'PUT parsial tidak boleh menghapus judul');
// hapus
$hapus = $this->kirim(['method' => 'DELETE', 'path' => "/api/tugas/$id", 'headers' => $headers]);
self::assertSame(204, $hapus->status());
self::assertSame('', $hapus->body());
// sudah hilang
$hilang = $this->kirim(['method' => 'GET', 'path' => "/api/tugas/$id", 'headers' => $headers]);
self::assertSame(404, $hilang->status());
self::assertSame('NOT_FOUND', $this->json($hilang)['error']['code']);
}
public function testTulisTugasDenganDataTidakValidDitolak(): void
{
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/tugas',
'headers' => $this->headerSah(),
'body' => ['judul' => 'a', 'status' => 'batal'],
]);
self::assertSame(422, $response->status());
$error = $this->json($response)['error'];
self::assertSame('VALIDATION_ERROR', $error['code']);
self::assertArrayHasKey('judul', $error['details']);
self::assertArrayHasKey('status', $error['details']);
}
public function testTulisTugasTanpaTokenDitolakSebelumValidasi(): void
{
// 401 harus menang atas 422: pemeriksaan token berjalan lebih dulu.
$response = $this->kirim([
'method' => 'POST',
'path' => '/api/tugas',
'body' => ['judul' => 'a', 'status' => 'batal'],
]);
self::assertSame(401, $response->status());
}
public function testTokenKedaluwarsaDitolak(): void
{
// Terbitkan langsung lewat TokenAuth dengan waktu kedaluwarsa lampau.
$token = $this->auth->issue(1, '2026-01-15 09:00:00');
$response = $this->kirim([
'method' => 'GET',
'path' => '/api/tugas',
'headers' => ['Authorization' => "Bearer $token"],
]);
self::assertSame(401, $response->status());
}
public function testLogoutMencabutToken(): void
{
$headers = $this->headerSah();
$logout = $this->kirim(['method' => 'POST', 'path' => '/api/logout', 'headers' => $headers]);
self::assertSame(204, $logout->status());
$setelah = $this->kirim(['method' => 'GET', 'path' => '/api/tugas', 'headers' => $headers]);
self::assertSame(401, $setelah->status());
}
public function testEndpointTidakDikenalMenjawab404(): void
{
$response = $this->kirim(['method' => 'GET', 'path' => '/api/tidak-ada']);
self::assertSame(404, $response->status());
self::assertSame('NOT_FOUND', $this->json($response)['error']['code']);
}
public function testMethodSalahMenjawab405DenganHeaderAllow(): void
{
$response = $this->kirim(['method' => 'PUT', 'path' => '/api/kesehatan']);
self::assertSame(405, $response->status());
self::assertSame('GET', $response->header('Allow'));
}
}
tests/bootstrap.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Bootstrap test suite REST API Manajemen Tugas.
*
* Tugas berkas ini: memuat seluruh kelas proyek, lalu menyediakan pabrik
* aplikasi uji buatAplikasi() — database :memory: yang segar setiap kali
* dipanggil, dengan router yang memasang endpoint persis seperti front
* controller. Test tidak perlu web server: request dibangun dengan
* Request::fromArray(), di-dispatch router, dan response diperiksa
* langsung sebagai objek.
*
* Catatan struktur: pada proyek nyata seluruh berkas src/ berada dalam satu
* direktori dan dimuat otomatis oleh autoloader Composer. Pada bundle
* halaman ini kelas-kelas tersebar di folder bagian-bagian sebelumnya,
* sehingga kita memuatnya eksplisit dengan path relatif dari tests/.
*/
require __DIR__ . '/../../bagian-2-routing-dan-request/src/Request.php';
require __DIR__ . '/../../bagian-2-routing-dan-request/src/Response.php';
require __DIR__ . '/../../bagian-2-routing-dan-request/src/Router.php';
require __DIR__ . '/../../bagian-3-database-dan-repository/src/Database.php';
require __DIR__ . '/../../bagian-3-database-dan-repository/src/TaskRepository.php';
require __DIR__ . '/../../bagian-4-validation-dan-error-responses/src/Validator.php';
require __DIR__ . '/../../bagian-4-validation-dan-error-responses/src/ErrorHandler.php';
require __DIR__ . '/../../bagian-5-authentication-dan-authorization/src/TokenAuth.php';
require __DIR__ . '/../../bagian-5-authentication-dan-authorization/src/UserRepository.php';
use App\Database;
use App\ErrorHandler;
use App\Request;
use App\Response;
use App\Router;
use App\TaskRepository;
use App\TokenAuth;
use App\UserRepository;
use App\Validator;
// Test harus hermetis: buang TASK_API_DB dari lingkungan supaya
// Database::buat() selalu jatuh ke sqlite::memory:, apapun isi shell
// yang menjalankan phpunit.
putenv('TASK_API_DB');
const SKEMA_TUGAS = __DIR__ . '/../../bagian-3-database-dan-repository/database/schema.sql';
/**
* Bangun satu aplikasi uji yang utuh.
*
* Setiap panggilan menghasilkan database baru (in-memory) + router baru,
* sehingga setiap test mulai dari kondisi awal yang sama: tiga seed tugas
* dan satu pengguna demo.
*
* @return array{router: Router, auth: TokenAuth}
*/
function buatAplikasi(): array
{
$pdo = Database::buat();
Database::migrasi($pdo, SKEMA_TUGAS);
$pdo->exec('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
password_hash TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
kedaluwarsa TEXT NOT NULL,
dibuat_pada TEXT NOT NULL
)');
$repo = new TaskRepository($pdo);
$users = new UserRepository($pdo);
$auth = new TokenAuth($pdo);
// Pengguna tetap untuk alur login (sandi demo, bukan rahasia).
$users->buatUser('[email protected]', 'sandi-budi-2026', 'Budi');
$router = new Router();
// --- Route publik -----------------------------------------------------
$router->add('GET', '/api/kesehatan', static fn (Request $r): Response => Response::json(['status' => 'ok']));
$router->add('POST', '/api/login', static function (Request $r) use ($users, $auth): Response {
$baris = $users->cariEmail((string) $r->body('email', ''));
if ($baris === null || !password_verify((string) $r->body('sandi', ''), $baris['password_hash'])) {
return Response::json(ErrorHandler::payload([
'code' => 'UNAUTHORIZED',
'message' => 'Email atau sandi salah.',
]), 401);
}
return Response::json(['token' => $auth->issue((int) $baris['id'])]);
});
// --- Route terlindungi ------------------------------------------------
$router->add('POST', '/api/logout', static function (Request $r) use ($auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$auth->revoke(substr((string) $r->header('Authorization'), strlen('Bearer ')));
return Response::noContent();
});
$router->add('GET', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
return Response::json($repo->semua());
});
$router->add('GET', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$tugas = $repo->cariId((int) $params['id']);
if ($tugas === null) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::json($tugas);
});
$router->add('POST', '/api/tugas', static function (Request $r) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$errors = (new Validator())
->required('judul')
->min('judul', 3)
->max('judul', 100)
->in('status', ['belum', 'proses', 'selesai'])
->validate($r->body());
if ($errors !== []) {
return Response::json(ErrorHandler::payload($errors), 422);
}
return Response::json($repo->buat($r->body()), 201);
});
$router->add('PUT', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
$errors = (new Validator())
->min('judul', 3)
->max('judul', 100)
->in('status', ['belum', 'proses', 'selesai'])
->validate($r->body());
if ($errors !== []) {
return Response::json(ErrorHandler::payload($errors), 422);
}
$tugas = $repo->perbarui((int) $params['id'], $r->body());
if ($tugas === null) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::json($tugas);
});
$router->add('DELETE', '/api/tugas/{id}', static function (Request $r, array $params) use ($repo, $auth): Response {
if (($tolak = $auth->periksa($r)) !== null) {
return $tolak;
}
if (!$repo->hapus((int) $params['id'])) {
return Response::json(ErrorHandler::payload([
'code' => 'NOT_FOUND',
'message' => sprintf('Tugas dengan id %s tidak ditemukan.', $params['id']),
]), 404);
}
return Response::noContent();
});
return ['router' => $router, 'auth' => $auth];
}