Implementasi proyek

Membangun aplikasi utuh dengan pengujian sejak hari pertama.

Daftar isi

Implementasi proyek mengubah satu potongan rancangan menjadi kode yang berjalan beserta pengujian yang menegakkannya. Topik ini membangun endpoint POST /api/catatan dari permukaan API aplikasi catatan secara utuh, yaitu kelas Form Request untuk validasi, controller yang menyimpan catatan, pendaftaran rute, dan satu kelas pengujian fitur yang memeriksa kontraknya. Urutan kerjanya menempatkan pengujian sejak hari pertama, yaitu kasus tes ditulis dan dijalankan lebih dahulu dalam keadaan gagal, kemudian implementasi ditulis sampai seluruh kasus lulus. Setelah topik ini, satu endpoint dari tabel kontrak telah terpasang dan teruji.

Kasus tes ditulis lebih dahulu#

Tiga status pada tabel kontrak, yaitu 201 untuk penyimpanan yang berhasil, 422 untuk isi yang gagal validasi, dan 401 untuk permintaan tanpa token, langsung menjadi asersi pada kelas pengujian. Menuliskannya lebih dahulu bukan sekadar ritual, melainkan cara menegakkan kontrak, karena pengujian yang dijalankan sebelum implementasi harus gagal dengan alasan yang jelas. Setiap kegagalan yang tampil pada saat itu merinci pekerjaan yang belum selesai, dan daftar kegagalan itu sendiri menjadi daftar tugas implementasi.

Kelas pengujian menggunakan tiga kasus yang sejajar dengan kontrak. Kasus pertama mengautentikasi pengguna dengan guard sanctum, mengirim judul, isi, dan category_id melalui postJson(), lalu memeriksa status 201, dua kunci JSON, dan keberadaan baris di database. Kasus kedua mengirim isi tanpa judul dan menuntut 422 beserta error validasi pada kunci judul. Kasus ketiga mengirim permintaan tanpa autentikasi dan menuntut 401. Siklus merah dan hijau pada implementasi digambarkan sebagai berikut.

1. Tulis kasus teskontrak 201, 422, dan 4012. Jalankan: merah3 kasus gagal, semua 4043. Tulis implementasiForm Request, controller, rute4. Jalankan: hijau3 kasus lulus, 8 asersi
Siklus pengujian sejak hari pertama pada potongan implementasi
Blok berikut menyiapkan berkas rute API dari keadaan kosong, menuliskan kelas pengujian, lalu menjalankannya. Kelas tes dijalankan dengan phpunit secara langsung, dengan opsi --colors=never agar keluarannya bebas kode warna dan pesan kegagalan setiap kasus tampil utuh.

bash
# berkas rute api disiapkan dari keadaan kosong untuk potongan implementasi ini
cd ~/aplikasi-catatan
cat > routes/api.php <<'PHP'
<?php

use Illuminate\Support\Facades\Route;

Route::get('/status', fn () => response()->json(['siap' => true]));
PHP
# kelas pengujian ditulis lebih dahulu, sebelum implementasi ada
cat > tests/Feature/PencatatanCatatanTest.php <<'PHP'
<?php

namespace Tests\Feature;

use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PencatatanCatatanTest extends TestCase
{
    use RefreshDatabase;

    public function test_catatan_baru_tersimpan_dengan_status_201(): void
    {
        $kategori = Category::create(['nama' => 'Kerja']);
        $pengguna = User::factory()->create();

        $respons = $this->actingAs($pengguna, 'sanctum')->postJson('/api/catatan', [
            'judul' => 'Rencana belajar mingguan',
            'isi' => 'Menyusun topik yang dipelajari setiap hari kerja.',
            'category_id' => $kategori->id,
        ]);

        $respons->assertCreated();
        $respons->assertJsonPath('data.judul', 'Rencana belajar mingguan');
        $respons->assertJsonPath('data.kategori.nama', 'Kerja');
        $this->assertDatabaseHas('notes', [
            'judul' => 'Rencana belajar mingguan',
            'category_id' => $kategori->id,
        ]);
    }

    public function test_data_tidak_lengkap_ditolak_dengan_status_422(): void
    {
        $pengguna = User::factory()->create();

        $respons = $this->actingAs($pengguna, 'sanctum')->postJson('/api/catatan', [
            'isi' => 'Judulnya sengaja dikosongkan.',
        ]);

        $respons->assertStatus(422);
        $respons->assertJsonValidationErrors(['judul']);
    }

    public function test_permintaan_tanpa_token_ditolak_dengan_status_401(): void
    {
        $respons = $this->postJson('/api/catatan', [
            'judul' => 'Catatan tanpa token',
            'isi' => 'Isi catatan tanpa token.',
        ]);

        $respons->assertStatus(401);
    }
}
PHP
# kelas tes dijalankan dan seluruh kasusnya gagal seperti yang diharapkan
./vendor/bin/phpunit --colors=never --filter=PencatatanCatatanTest | grep -E "^[0-9]\)|^Expected|^Failed|^FAILURES|^Tests:"
text Keluaran
1) Tests\Feature\PencatatanCatatanTest::test_catatan_baru_tersimpan_dengan_status_201
Expected response status code [201] but received 404.
Failed asserting that 404 is identical to 201.
2) Tests\Feature\PencatatanCatatanTest::test_data_tidak_lengkap_ditolak_dengan_status_422
Expected response status code [422] but received 404.
Failed asserting that 404 is identical to 422.
3) Tests\Feature\PencatatanCatatanTest::test_permintaan_tanpa_token_ditolak_dengan_status_401
Expected response status code [401] but received 404.
Failed asserting that 404 is identical to 401.
FAILURES!
Tests: 3, Assertions: 3, Failures: 3.

Ketiga kasus gagal dengan penyebab yang sama, yaitu respons 404 dari router karena rute POST /api/catatan belum terdaftar. Pesan kegagalannya menyebut penyebabnya secara eksplisit, misalnya Expected response status code [201] but received 404., sehingga setiap barisnya tepat menggambarkan bagian kontrak yang belum dipenuhi. Dari titik ini, implementasi cukup mengikuti daftar kegagalan tersebut sampai tidak ada lagi yang tersisa.

Form Request menampung aturan validasi#

Form Request adalah kelas permintaan yang menampung aturan validasi dan otorisasi pada satu berkas, dan Laravel memanggilnya secara otomatis apabila kelas tersebut diinjeksi melalui type hint pada argumen method controller. Kelas SimpanCatatanRequest memuat tiga aturan yang sejajar dengan skema, yaitu judul wajib berupa string hingga seratus karakter, isi wajib berupa string, dan category_id boleh kosong tetapi harus berupa bilangan yang ada di tabel categories. Aturan exists:categories,id menghubungkan validasi dengan kunci asing yang dirancang pada perencanaan, sehingga permintaan dengan kategori yang tidak dikenal dijawab dengan status 422 oleh lapisan validasi tanpa menyentuh database.

bash
# menuliskan kelas Form Request pada proyek uji
cd ~/aplikasi-catatan
mkdir -p app/Http/Requests
cat > app/Http/Requests/SimpanCatatanRequest.php <<'PHP'
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class SimpanCatatanRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'judul' => ['required', 'string', 'max:100'],
            'isi' => ['required', 'string'],
            'category_id' => ['nullable', 'integer', 'exists:categories,id'],
        ];
    }
}
PHP

Method authorize() mengembalikan true karena pemeriksaan token sudah ditangani middleware auth:sanctum sebelum permintaan mencapai kelas ini. Setelah validasi lulus, method validated() pada kelas ini mengembalikan hanya kunci yang memiliki aturan, sehingga controller tidak perlu menyaring isi permintaannya sendiri. Untuk permintaan yang mengirim header Accept: application/json, kegagalan validasi dirender otomatis sebagai respons 422 berisi daftar error per kolom, sesuai kasus kedua pada kelas pengujian.

Controller dan pendaftaran rute#

Controller menyimpan catatan dari data tervalidasi lalu mengembalikan jawaban dengan bentuk yang ditetapkan kelas NoteResource, yaitu kelas API resource yang menjadi bagian aplikasi. Berkasnya dituliskan kembali di sini agar potongan implementasi berdiri sendiri, lengkap dengan kunci kategori yang hanya muncul apabila relasinya dimuat.

bash
# menuliskan kembali kelas NoteResource pada proyek uji
cd ~/aplikasi-catatan
mkdir -p app/Http/Resources
cat > app/Http/Resources/NoteResource.php <<'PHP'
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Str;

class NoteResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'judul' => $this->judul,
            'ringkasan' => Str::limit($this->isi, 50),
            'kategori' => $this->whenLoaded('category', fn () => [
                'id' => $this->category->id,
                'nama' => $this->category->nama,
            ]),
        ];
    }
}
PHP
bash
# menuliskan controller endpoint pada proyek uji
cd ~/aplikasi-catatan
cat > app/Http/Controllers/CatatanController.php <<'PHP'
<?php

namespace App\Http\Controllers;

use App\Http\Requests\SimpanCatatanRequest;
use App\Http\Resources\NoteResource;
use App\Models\Note;
use Illuminate\Http\JsonResponse;

class CatatanController extends Controller
{
    public function store(SimpanCatatanRequest $request): JsonResponse
    {
        $note = Note::create($request->validated());
        $note->load('category');

        return (new NoteResource($note))->response()->setStatusCode(201);
    }
}
PHP

Method store() menerima SimpanCatatanRequest, membuat catatan dari validated(), lalu memuat relasi category sebelum resource dirender agar kunci kategori muncul pada JSON. Jawaban diberi status 201 secara eksplisit melalui setStatusCode(201) karena render resource saja menghasilkan 200 secara bawaan. Pada rute ini pemeriksaan berlangsung berlapis, yaitu middleware auth:sanctum terlebih dahulu, kemudian validasi pada Form Request, dan terakhir penyimpanan pada controller. Terakhir, rute didaftarkan di dalam grup middleware yang sama, dan kelas pengujian dijalankan ulang untuk menutup siklusnya.

bash
# mendaftarkan rute endpoint lalu menjalankan kelas tes sekali lagi
cd ~/aplikasi-catatan
cat > routes/api.php <<'PHP'
<?php

use App\Http\Controllers\CatatanController;
use Illuminate\Support\Facades\Route;

Route::get('/status', fn () => response()->json(['siap' => true]));

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/catatan', [CatatanController::class, 'store']);
});
PHP
# kelas tes dijalankan ulang setelah implementasi selesai
./vendor/bin/phpunit --colors=never --filter=PencatatanCatatanTest | grep -E "^OK|^Tests:"
text Keluaran
OK (3 tests, 8 assertions)

Ketiga kasus lulus dengan delapan asersi, dan keadaan merah berubah menjadi hijau tanpa satu pun baris pada kelas pengujian yang disunting. Pada tahap ini perintah --filter cukup digunakan untuk menjalankan satu kelas tes saja selama iterasi berlangsung.

Melihat jawaban nyata endpoint#

Pengujian fitur memeriksa status dan kunci tertentu, sedangkan bentuk utuh jawabannya lebih mudah dinilai dari permintaan sungguhan. Skrip berikut memanggil endpoint melalui kernel HTTP aplikasi di dalam satu proses PHP, sehingga tidak ada php artisan serve yang perlu dijalankan. Token Sanctum disertakan pada header Authorization, dan teksnya berformat {id}|{karakter acak} sengaja tidak dicetak karena nilainya rahasia. Catatan dengan judul yang sama dihapus lebih dahulu di dalam skrip, sehingga eksekusi ulang selalu menghasilkan jawaban yang sama.

php uji-endpoint.php
<?php

use App\Models\Category;
use App\Models\Note;
use App\Models\User;
use Illuminate\Http\Request;

$root = getcwd();
if (! is_file($root.'/bootstrap/app.php')) {
    $root = getenv('HOME').'/aplikasi-catatan';
}

require $root.'/vendor/autoload.php';

$app = require $root.'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();

// pengguna, kategori, dan token disiapkan secara deterministik
$pengguna = User::firstOrCreate(
    ['email' => '[email protected]'],
    ['name' => 'Ali', 'password' => 'kunci-rahasia-ali']
);
$pengguna->tokens()->where('name', 'uji-endpoint')->delete();
$token = $pengguna->createToken('uji-endpoint');
$kategori = Category::firstOrCreate(['nama' => 'Pribadi']);

// catatan dengan judul yang sama dihapus agar POST benar-benar membuat baru
Note::where('judul', 'Rencana belajar pribadi')->delete();

$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$resp = $kernel->handle(Request::create('http://localhost/api/catatan', 'POST', [], [], [], [
    'HTTP_ACCEPT' => 'application/json',
    'CONTENT_TYPE' => 'application/json',
    'HTTP_AUTHORIZATION' => 'Bearer '.$token->plainTextToken,
], json_encode([
    'judul' => 'Rencana belajar pribadi',
    'isi' => 'Merencanakan bahan belajar mingguan dalam daftar pendek.',
    'category_id' => $kategori->id,
])));

$isi = json_decode($resp->getContent(), true);
echo 'status: '.$resp->getStatusCode().PHP_EOL;
echo 'judul: '.$isi['data']['judul'].PHP_EOL;
echo 'ringkasan: '.$isi['data']['ringkasan'].PHP_EOL;
echo 'kategori: '.$isi['data']['kategori']['nama'].PHP_EOL;
text Keluaran
status: 201
judul: Rencana belajar pribadi
ringkasan: Merencanakan bahan belajar mingguan dalam daftar p...
kategori: Pribadi

Jawaban tersebut memperlihatkan kontrak yang dirancang bekerja pada praktiknya, yaitu pembungkus data, kunci ringkasan hasil pemotongan Str::limit, dan kunci kategori yang muncul karena relasinya dimuat di controller. Kunci id juga ada di dalam jawaban, tetapi tidak dicetak oleh skrip karena nilainya bergantung pada isi tabel saat skrip dijalankan. Status 201 menyatakan sumber daya baru telah dibuat, dan permintaan yang sama tanpa token akan berhenti di middleware dengan status 401 sebagaimana diperiksa kasus ketiga.

Kesalahan umum#

Kesalahan pertama adalah membiarkan authorize() mengembalikan false dari kerangka bawaan kelas Form Request. Seluruh permintaan dijawab dengan status 403 meskipun tokennya sah, dan penyebabnya sering dicari di middleware padahal aturannya berada di dalam kelas validasi. Perbaikannya adalah mengembalikan true apabila otorisasi memang ditangani tempat lain, atau menuliskan pemeriksaan otorisasi yang nyata di dalam method tersebut.

Kesalahan kedua adalah mengembalikan resource tanpa menaikkan status jawabannya. Render NoteResource saja menghasilkan 200, sehingga asersi assertCreated() tetap gagal meskipun data sudah tersimpan dan bentuk JSON-nya sudah benar. Perbaikannya adalah menaikkan statusnya secara eksplisit seperti setStatusCode(201) pada controller di atas.

Kesalahan ketiga adalah melupakan pemanggilan load('category') sebelum resource dirender. Kunci kategori menghilang dari JSON tanpa pesan error karena whenLoaded hanya menyertakan relasi yang termuat, dan kasus pertama yang memeriksa data.kategori.nama ikut gagal. Perbaikannya adalah memuat relasinya pada controller, atau menambahkan with('category') apabila data diambil melalui query.

Ringkasan#

  • Kasus tes ditulis dan dijalankan lebih dahulu, dan tiga kegagalannya dengan status 404 menjadi daftar tugas implementasi dari kontrak 201, 422, dan 401.
  • Form Request menampung aturan validasi yang sejajar dengan skema, termasuk exists:categories,id, dan kegagalan validasinya dirender otomatis sebagai 422 untuk permintaan JSON.
  • Controller menyimpan catatan dari validated(), memuat relasi category, lalu mengembalikan NoteResource dengan status 201, sedangkan rute tulis dilindungi auth:sanctum.
  • Kelas tes yang sama dijalankan ulang tanpa perubahan hingga ketiga kasus lulus, dan permintaan nyata dengan token memperlihatkan bentuk jawaban lengkapnya.

Referensi#