API tidak punya halaman untuk dilihat manusia — kontraknya adalah status code, struktur JSON, dan header. Maka test API adalah kontrak yang dieksekusi: setiap endpoint kita kunci dengan assertion status, bentuk data-links-meta, pesan validasi 422, sampai envelope error kustom. Chapter HTTP Tests telah memperkenalkan assertJson; sekarang kita susun suite API lengkap dengan autentikasi token Sanctum, pagination, dan disiplin struktur. Semua contoh memakai resource Post yang sama dengan suite sebelumnya, dengan route routes/api.php berprefiks /api dan middleware auth:sanctum.

getJson dan Header Accept Otomatis #

Pasangan getJson, postJson, patchJson, dan deleteJson otomatis menyertakan header Accept: application/json — dan header inilah yang membuat Laravel merespons dalam JSON alih-alih mengalihkan seperti halaman web. Suite kita membuktikan bedanya lewat tamu yang mengetuk endpoint yang sama:

php
public function test_header_accept_mengubah_bentuk_penolakan_tamu(): void
{
    $this->post('/api/posts', ['title' => 'Tanpa Accept', 'body' => 'Isi.'])
        ->assertRedirect('/login');

    $this->postJson('/api/posts', ['title' => 'Dengan Accept', 'body' => 'Isi.'])
        ->assertUnauthorized();
}

Request pertama tanpa Accept diperlakukan seperti pengunjung web: middleware auth mengalihkan ke /login (302). Request kedua dengan Accept JSON menerima badan 401 — dan assertUnauthorized() adalah pintasan untuk assertStatus(401).

Token Sanctum: actingAs vs Token Asli #

Untuk klien API, ada dua cara mengautentikasi test. Yang tercepat adalah Sanctum::actingAs() dengan daftar abilities, cocok menguji logika route:

php
public function test_abilities_membedakan_token_baca_dan_tulis(): void
{
    $budi = $this->userBudi();

    Sanctum::actingAs($budi, ['posts:read']);
    $this->postJson('/api/posts', [
        'title' => 'Ditolak Token Baca',
        'body' => 'Token ini hanya boleh membaca.',
    ])->assertForbidden();

    Sanctum::actingAs($budi, ['posts:create']);
    $this->postJson('/api/posts', [
        'title' => 'Belajar API Test',
        'body' => 'Dibuat lewat postJson di dalam test.',
    ])->assertCreated()
        ->assertJsonPath('data.title', 'Belajar API Test');
}

Route store kita dilindungi middleware abilities:posts:create, jadi token baca ditolak 403 dan token tulis lolos sampai 201 (assertCreated). Ketika yang diuji justru mekanisme token itu sendiri — bearer header, pencatatan di database — kita pakai token asli dari createToken() dengan helper withToken():

php
$token = $budi->createToken('ponsel-budi')->plainTextToken;

$this->getJson('/api/posts')->assertUnauthorized();

$this->withToken($token)->getJson('/api/posts')->assertOk();

$this->assertDatabaseHas('personal_access_tokens', [
    'name' => 'ponsel-budi',
]);

Request tanpa token ditolak 401, request dengan header Authorization: Bearer diterima, dan baris token tercatat di database — tiga bukti yang tidak bisa diberikan actingAs. Detail lengkap pola ini ada di chapter Pengujian Sanctum .

Disiplin Struktur JSON: Pagination #

Index endpoint kita memakai PostResource::collection(Post::latest('id')->paginate(3)), sehingga response membentuk tiga key utama. Inilah bentuk nyatanya untuk lima post:

json
{
    "data": [
        {
            "id": 5,
            "title": "Post Pertama",
            "published_at": "2026-01-10T08:00:00+00:00"
        }
    ],
    "links": {
        "first": "http://localhost:8000/api/posts?page=1",
        "last": "http://localhost:8000/api/posts?page=2",
        "prev": null,
        "next": "http://localhost:8000/api/posts?page=2"
    },
    "meta": {
        "current_page": 1,
        "last_page": 2,
        "per_page": 3,
        "total": 5
    }
}

Test-nya membagi verifikasi menjadi tiga lapis — jumlah, nilai, dan bentuk:

php
$this->getJson('/api/posts')
    ->assertOk()
    ->assertJsonCount(3, 'data')
    ->assertJsonPath('data.0.title', 'Post Pertama')
    ->assertJsonPath('meta.total', 5)
    ->assertJsonPath('meta.last_page', 2)
    ->assertJsonStructure([
        'data' => [['id', 'title', 'published_at']],
        'links' => ['first', 'last', 'prev', 'next'],
        'meta' => ['current_page', 'from', 'last_page', 'path', 'per_page', 'to', 'total'],
    ]);

assertJsonCount(3, 'data') membuktikan per_page dihormati; assertJsonPath mengunci nilai pada titik tertentu; assertJsonStructure memverifikasi kontrak bentuk tanpa peduli nilai — kombinasi ini mendeteksi kolom yang hilang sekaligus pengurutan yang salah.

Validasi 422 dan Envelope Error #

Validasi gagal pada request JSON membalas 422 dengan pesan per field. Bentuk nyatanya dari suite kita:

json
{
    "message": "The title field is required. (and 1 more error)",
    "errors": {
        "title": ["The title field is required."],
        "body": ["The body field is required."]
    }
}

Assertion-nya menyasar status, field yang salah, dan bentuk envelope sekaligus:

php
$this->postJson('/api/posts', ['title' => '', 'body' => ''])
    ->assertUnprocessable()
    ->assertJsonValidationErrors(['title', 'body'])
    ->assertJsonStructure(['message', 'errors' => ['title', 'body']]);

$this->assertDatabaseCount('posts', 0);

Untuk error yang bukan validasi, aplikasi kita memakai envelope kustom di controller — show mencari post manual dan membalas 404 dengan kode error yang kita definisikan sendiri:

php
$this->getJson('/api/posts/999')
    ->assertNotFound()
    ->assertJson([
        'error' => [
            'code' => 'post_tidak_ditemukan',
            'message' => 'Post tidak ditemukan.',
        ],
    ]);

Pola assertJson di atas membandingkan subset — cukup untuk mengunci field penting tanpa menyalin seluruh response. Operasi patchJson dan deleteJson menutup suite: patch memperbarui judul dan meng-assert data.title baru, delete membalas {\"data\":{\"terhapus\":true}} lalu baris dipastikan hilang lewat assertDatabaseMissing.

Seluruh Suite Berjalan #

bash
php artisan test tests/Feature/ApiPostTest.php

Output yang Dihasilkan:

text
  PASS  Tests\Feature\ApiPostTest
  ✓  tamu ditolak 401                                                      0.15s
  ✓  ponsel tanpa token ditolak dengan token diterima                     0.03s
  ✓  paginasi menyertakan meta dan links                                   0.02s
  ✓  abilities membedakan token baca dan tulis                             0.01s
  ✓  validasi 422 dengan envelope error                                     0.01s
  ✓  header accept mengubah bentuk penolakan tamu                           0.01s
  ✓  404 menggunakan envelope error kustom                                  0.01s
  ✓  patch dan delete mengelola satu post                                   0.01s

  Tests:    8 passed (50 assertions)
  Duration:  0.33s

Endpoint API kita juga menerima file — profil dengan foto, dokumen PDF. Upload file butuh perlakuan khusus di test, dan Laravel menyediakan UploadedFile::fake() beserta fake storage untuk itu. Chapter File Upload Testing berikutnya membedah keduanya. Rujukan lengkap ada di dokumentasi HTTP Tests .