API yang tidak diuji adalah kontrak yang belum dibuktikan — perubahan kecil di validasi atau filter bisa diam-diam merusak klien tanpa satu pun halaman web yang memperlihatkan error. Feature test menggantikan browser dalam proyek ini: kita menembak endpoint dengan postJson(), membaca respons JSON, dan mengklaim status code serta bentuk datanya. Bagian penutup ini menulis dua berkas test yang menghajar seluruh kontrak dari Bagian 1 sampai 5.
Menyiapkan Lingkungan Test #
Dua kelas test (AuthApiTest dan TaskApiTest) berdiri di atas dua fondasi bawaan aplikasi Laravel: konfigurasi phpunit.xml yang memakai SQLite :memory: sebagai database test, dan trait RefreshDatabase yang menjalankan ulang migration sebelum tiap test method. Berkat :memory:, setiap test mendapat tabel bersih tanpa menyentuh database pengembangan. Semua token di test adalah token sungguhan hasil createToken() — bukan string palsu — sehingga alur verifikasi hash di Sanctum ikut teruji.
Nama method test memakai awalan test_ lalu kalimat deskriptif berbahasa Indonesia. PHPUnit mengubah nama itu menjadi label berbahasa manusia pada output (logout mencabut token aktif), sehingga hasil php artisan test sekaligus berfungsi sebagai daftar perilaku kontrak yang bisa dibaca tanpa membuka kode. Data uji dibuat dengan factory — Task::factory()->create(['status' => 'done', ...]) — sehingga setiap test hanya menuliskan kolom yang relevan dan sisanya diisi TaskFactory.
Menguji Alur Autentikasi #
AuthApiTest menguji tiga skenario inti: register mengembalikan 201 dengan token yang langsung bisa dipakai, kredensial salah ditolak 422, dan logout mencabut token aktif:
public function test_logout_mencabut_token_aktif(): void
{
$user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken;
$this->withToken($token)
->postJson('/api/logout')
->assertStatus(200)
->assertJsonPath('message', 'Logout berhasil.');
$this->assertDatabaseCount('personal_access_tokens', 0);
// Token lama sudah dicabut: request berikutnya ditolak 401.
$this->app->make('auth')->forgetGuards();
$this->withToken($token)
->getJson('/api/tasks')
->assertStatus(401);
}Method withToken() memasang header Authorization: Bearer seolah-olah klien sungguhan, assertJsonPath() memeriksa nilai pada posisi JSON tertentu, dan assertDatabaseCount() memastikan baris token benar-benar terhapus. Test register juga memverifikasi token yang barusan diterbitkan langsung lolos ke endpoint privat — bukan hanya sekadar ada di respons.
Menguji Kontrak Tasks #
TaskApiTest menyiapkan user dan token sekali di setUp(), lalu tiap test tinggal memanggil helper actingAsApi(). Skenario terpenting menjaga tepi kontrak: tamu ditolak, tugas milik orang lain tidak terlihat, dan input kotor ditolak:
public function test_tugas_milik_pengguna_lain_mengembalikan_404(): void
{
$task = Task::factory()->create(); // milik pengguna lain
$this->actingAsApi()->getJson("/api/tasks/{$task->id}")->assertStatus(404);
$this->actingAsApi()
->putJson("/api/tasks/{$task->id}", ['title' => 'Perubahan'])
->assertStatus(404);
$this->actingAsApi()->deleteJson("/api/tasks/{$task->id}")->assertStatus(404);
}
public function test_filter_status_dan_priority(): void
{
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'pending', 'priority' => 'low']);
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'in_progress', 'priority' => 'high']);
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'done', 'priority' => 'high']);
$this->actingAsApi()
->getJson('/api/tasks?priority=high')
->assertJsonPath('meta.total', 2);
$this->actingAsApi()
->getJson('/api/tasks?status=done&priority=high')
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.status', 'done');
}Test pagination membuat 20 tugas lalu meminta ?per_page=8&page=2 dan mengklaim meta.current_page bernilai 2, meta.total bernilai 20, serta tautan links.next mengarah ke halaman 3. Test validasi mengirim status bernilai selesai dan mendapatkan assertJsonValidationErrors('status') — envelope 422 dari Bagian 4 ikut diverifikasi. Assert JSON berbasis dokumentasi HTTP test
ini berjalan tanpa server: framework menangani request secara internal, jadi ratusan assertion selesai dalam hitungan detik.
Rangkaian assertion lain melengkapi jangkauan test: assertJsonCount() memastikan jumlah item pada data (daftar tidak melebar ke milik orang lain), assertDatabaseHas()/assertDatabaseMissing() memeriksa baris database langsung setelah aksi store/destroy, dan assertNoContent() memastikan respons 204 benar-benar tanpa body. Kombinasi assert respons dan assert database membedakan “kata API” dari “keadaan nyata” — respons sukses yang tidak disimpan pun tertangkap.
Menjalankan Seluruh Test #
php artisan testOutput yang Dihasilkan:
PASS Tests\Unit\ExampleTest
✓ that true is true
PASS Tests\Feature\AuthApiTest
✓ register mengembalikan token dan user 201 0.17s
✓ register dengan email duplikat mengembalikan 422 0.02s
✓ register dengan input kosong mengembalikan 422 0.01s
✓ login mengembalikan token untuk kredensial benar 0.01s
✓ login dengan kata sandi salah mengembalikan 422 0.01s
✓ logout mencabut token aktif 0.01s
✓ akses endpoint privat tanpa token mengembalikan 401 0.01s
PASS Tests\Feature\ExampleTest
✓ the application returns a successful response 0.02s
PASS Tests\Feature\TaskApiTest
✓ index hanya menampilkan tugas milik sendiri 0.01s
✓ semua endpoint tugas menolak tamu dengan 401 0.01s
✓ store menyimpan tugas dengan default dan mengembalikan 201 0.01s
✓ store dengan status dan prioritas eksplisit 0.01s
✓ store dengan input tidak valid mengembalikan 422 0.01s
✓ show mengembalikan detail tugas 0.01s
✓ tugas milik pengguna lain mengembalikan 404 0.01s
✓ tugas yang tidak ada mengembalikan 404 0.01s
✓ update mengubah field yang dikirim saja 0.01s
✓ update dengan status tidak valid mengembalikan 422 0.01s
✓ destroy menghapus tugas dan mengembalikan 204 0.01s
✓ filter status dan priority 0.01s
✓ filter dengan nilai tidak valid mengembalikan 422 0.01s
✓ pagination membagi tugas per halaman 0.02s
Tests: 23 passed (102 assertions)
Duration: 0.49sDua puluh satu test proyek plus dua test bawaan framework — semuanya hijau dengan 102 assertion. Proyek REST API Manajemen Tugas kini tuntas dari rancangan hingga bukti: resource, endpoint, token, validasi, filter, pagination, dan test yang menjaga semuanya. Dari sini kita bisa melanjutkan ke dokumentasi Sanctum untuk fitur lanjutan seperti masa kedaluwarsa token dan permission per ability, atau ke database testing untuk memperdalam strategi data uji.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
app/Http/Controllers/AuthApiController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthApiController extends Controller
{
/** Daftar akun baru lalu langsung terbitkan token pertama. */
public function register(Request $request): JsonResponse
{
$credentials = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
]);
$user = User::create([
'name' => $credentials['name'],
'email' => $credentials['email'],
'password' => Hash::make($credentials['password']),
]);
$token = $user->createToken('api')->plainTextToken;
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
], 201);
}
/** Tukar email dan kata sandi dengan personal access token. */
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
]);
$user = User::where('email', $credentials['email'])->first();
if ($user === null || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => 'Email atau kata sandi salah.',
]);
}
$token = $user->createToken('api')->plainTextToken;
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
]);
}
/** Cabut token yang dipakai request ini; token lain milik pengguna tetap hidup. */
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Logout berhasil.',
]);
}
}
app/Http/Controllers/Controller.php
Unduh berkas<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
app/Http/Controllers/TaskController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Requests\UpdateTaskRequest;
use App\Http\Resources\TaskCollection;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
class TaskController extends Controller
{
/** Daftar tugas milik pengguna, dengan filter status/priority dan pagination. */
public function index(Request $request): TaskCollection
{
$filters = $request->validate([
'status' => ['nullable', Rule::in(Task::STATUSES)],
'priority' => ['nullable', Rule::in(Task::PRIORITIES)],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$tasks = $request->user()->tasks()
->when($filters['status'] ?? null, fn ($query, $status) => $query->status($status))
->when($filters['priority'] ?? null, fn ($query, $priority) => $query->priority($priority))
->latest('id')
->paginate($filters['per_page'] ?? 15)
->appends(array_filter($filters));
return new TaskCollection($tasks);
}
/** Simpan tugas baru milik pengguna yang terautentikasi. */
public function store(StoreTaskRequest $request): JsonResponse
{
$data = array_merge([
'status' => 'pending',
'priority' => 'medium',
], $request->validated());
$task = $request->user()->tasks()->create($data);
return (new TaskResource($task))
->response()
->setStatusCode(201);
}
/** Tampilkan satu tugas milik pengguna. */
public function show(Request $request, Task $task): TaskResource
{
// 404, bukan 403, agar keberadaan tugas milik orang lain tidak bocor.
abort_unless($task->user_id === $request->user()->id, 404);
return new TaskResource($task);
}
/** Ubah sebagian atau seluruh field tugas. */
public function update(UpdateTaskRequest $request, Task $task): TaskResource
{
abort_unless($task->user_id === $request->user()->id, 404);
$task->update($request->validated());
return new TaskResource($task->refresh());
}
/** Hapus tugas; respons 204 tanpa body. */
public function destroy(Request $request, Task $task): Response
{
abort_unless($task->user_id === $request->user()->id, 404);
$task->delete();
return response()->noContent();
}
}
app/Http/Requests/StoreTaskRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['sometimes', Rule::in(Task::STATUSES)],
'priority' => ['sometimes', Rule::in(Task::PRIORITIES)],
'due_date' => ['nullable', 'date'],
];
}
public function messages(): array
{
return [
'title.required' => 'Judul tugas wajib diisi.',
'status.in' => 'Status harus salah satu dari: pending, in_progress, done.',
'priority.in' => 'Prioritas harus salah satu dari: low, medium, high.',
];
}
}
app/Http/Requests/UpdateTaskRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** Semua field bersifat partial: hanya field yang dikirim yang divalidasi. */
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string'],
'status' => ['sometimes', Rule::in(Task::STATUSES)],
'priority' => ['sometimes', Rule::in(Task::PRIORITIES)],
'due_date' => ['sometimes', 'nullable', 'date'],
];
}
public function messages(): array
{
return [
'title.required' => 'Judul tugas wajib diisi.',
'status.in' => 'Status harus salah satu dari: pending, in_progress, done.',
'priority.in' => 'Prioritas harus salah satu dari: low, medium, high.',
];
}
}
app/Http/Resources/TaskCollection.php
Unduh berkas<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
/** @extends ResourceCollection<\App\Http\Resources\TaskResource> */
class TaskCollection extends ResourceCollection
{
/** Setiap item dikonversi lewat TaskResource. */
public $collects = TaskResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
/** Meta pagination ringkas: hanya field yang dipakai klien. */
public function paginationInformation(Request $request, array $paginated, array $default): array
{
return [
'links' => [
'first' => $paginated['first_page_url'],
'last' => $paginated['last_page_url'],
'next' => $paginated['next_page_url'],
'prev' => $paginated['prev_page_url'],
],
'meta' => [
'current_page' => $paginated['current_page'],
'from' => $paginated['from'],
'last_page' => $paginated['last_page'],
'per_page' => $paginated['per_page'],
'to' => $paginated['to'],
'total' => $paginated['total'],
],
];
}
}
app/Http/Resources/TaskResource.php
Unduh berkas<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Task */
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_date' => $this->due_date?->format('Y-m-d'),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
app/Models/Task.php
Unduh berkas<?php
namespace App\Models;
use Database\Factories\TaskFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Task extends Model
{
/** @use HasFactory<TaskFactory> */
use HasFactory;
/** Nilai status yang diizinkan kolom tasks.status. */
public const STATUSES = ['pending', 'in_progress', 'done'];
/** Nilai priority yang diizinkan kolom tasks.priority. */
public const PRIORITIES = ['low', 'medium', 'high'];
protected $fillable = [
'title',
'description',
'status',
'priority',
'due_date',
];
protected function casts(): array
{
return [
'due_date' => 'date',
];
}
/** Tugas selalu dimiliki satu pengguna. */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** Batasi query pada tugas ber-status tertentu, mis. ->status('done'). */
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
/** Batasi query pada tugas berprioritas tertentu, mis. ->priority('high'). */
public function scopePriority(Builder $query, string $priority): Builder
{
return $query->where('priority', $priority);
}
}
app/Models/User.php
Unduh berkas<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/** Semua tugas milik pengguna. */
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
}
database/factories/TaskFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Task;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Task> */
class TaskFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'title' => 'Belajar Laravel',
'description' => 'Membaca dokumentasi dan berlatih.',
'status' => 'pending',
'priority' => 'medium',
'due_date' => null,
];
}
}
database/factories/UserFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
database/migrations/0001_01_01_000000_create_users_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
database/migrations/0001_01_01_000001_create_cache_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
database/migrations/0001_01_01_000002_create_jobs_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
database/migrations/2026_10_13_153049_create_personal_access_tokens_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
database/migrations/2026_10_14_000001_create_tasks_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('pending');
$table->string('priority')->default('medium');
$table->date('due_date')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
}
};
routes/api.php
Unduh berkas<?php
use App\Http\Controllers\AuthApiController;
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;
// Endpoint publik: mendaftar akun baru dan menukar kredensial dengan token.
Route::post('/register', [AuthApiController::class, 'register']);
Route::post('/login', [AuthApiController::class, 'login']);
// Endpoint privat: butuh header Authorization: Bearer <token> Sanctum.
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthApiController::class, 'logout']);
Route::apiResource('tasks', TaskController::class);
});
tests/Feature/AuthApiTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthApiTest extends TestCase
{
use RefreshDatabase;
public function test_register_mengembalikan_token_dan_user_201(): void
{
$response = $this->postJson('/api/register', [
'name' => 'Budi',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response->assertStatus(201)
->assertJsonStructure([
'user' => ['id', 'name', 'email'],
'token',
]);
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
]);
// Token yang diterbitkan harus bisa dipakai mengakses endpoint privat.
$token = $response->json('token');
$this->withToken($token)
->getJson('/api/tasks')
->assertStatus(200)
->assertJsonPath('meta.total', 0);
}
public function test_register_dengan_email_duplikat_mengembalikan_422(): void
{
User::factory()->create(['email' => '[email protected]']);
$this->postJson('/api/register', [
'name' => 'Budi Dua',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
])->assertStatus(422)->assertJsonValidationErrors('email');
}
public function test_register_dengan_input_kosong_mengembalikan_422(): void
{
$this->postJson('/api/register', [])
->assertStatus(422)
->assertJsonValidationErrors(['name', 'email', 'password']);
}
public function test_login_mengembalikan_token_untuk_kredensial_benar(): void
{
$user = User::factory()->create([
'name' => 'Siti',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response = $this->postJson('/api/login', [
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response->assertStatus(200)
->assertJsonPath('user.email', '[email protected]')
->assertJsonStructure(['user', 'token']);
$this->withToken($response->json('token'))
->getJson('/api/tasks')
->assertStatus(200);
}
public function test_login_dengan_kata_sandi_salah_mengembalikan_422(): void
{
User::factory()->create([
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$this->postJson('/api/login', [
'email' => '[email protected]',
'password' => 'kata-sandi-salah',
])->assertStatus(422)->assertJsonValidationErrors('email');
}
public function test_logout_mencabut_token_aktif(): void
{
$user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken;
$this->withToken($token)
->postJson('/api/logout')
->assertStatus(200)
->assertJsonPath('message', 'Logout berhasil.');
$this->assertDatabaseCount('personal_access_tokens', 0);
// Token lama sudah dicabut: request berikutnya ditolak 401.
// Guard auth di-cache per request dalam satu proses; kita reset
// cache tersebut agar request kedua benar-benar memeriksa token.
$this->app->make('auth')->forgetGuards();
$this->withToken($token)
->getJson('/api/tasks')
->assertStatus(401);
}
public function test_akses_endpoint_privat_tanpa_token_mengembalikan_401(): void
{
$this->getJson('/api/tasks')->assertStatus(401);
}
}
tests/Feature/TaskApiTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Task;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TaskApiTest extends TestCase
{
use RefreshDatabase;
private User $user;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create([
'name' => 'Budi',
'email' => '[email protected]',
]);
$this->token = $this->user->createToken('api')->plainTextToken;
}
private function actingAsApi(): self
{
return $this->withToken($this->token);
}
public function test_index_hanya_menampilkan_tugas_milik_sendiri(): void
{
Task::factory()->count(2)->create(['user_id' => $this->user->id]);
Task::factory()->create(); // tugas milik pengguna lain
$this->actingAsApi()
->getJson('/api/tasks')
->assertStatus(200)
->assertJsonCount(2, 'data')
->assertJsonPath('meta.total', 2);
}
public function test_semua_endpoint_tugas_menolak_tamu_dengan_401(): void
{
$task = Task::factory()->create(['user_id' => $this->user->id]);
$this->getJson('/api/tasks')->assertStatus(401);
$this->postJson('/api/tasks', [])->assertStatus(401);
$this->getJson("/api/tasks/{$task->id}")->assertStatus(401);
$this->putJson("/api/tasks/{$task->id}", [])->assertStatus(401);
$this->deleteJson("/api/tasks/{$task->id}")->assertStatus(401);
}
public function test_store_menyimpan_tugas_dengan_default_dan_mengembalikan_201(): void
{
$response = $this->actingAsApi()
->postJson('/api/tasks', [
'title' => 'Belajar Sanctum',
'description' => 'Membaca dokumentasi API authentication.',
'due_date' => '2026-01-20',
]);
$response->assertStatus(201)
->assertJsonPath('data.title', 'Belajar Sanctum')
->assertJsonPath('data.status', 'pending')
->assertJsonPath('data.priority', 'medium')
->assertJsonPath('data.due_date', '2026-01-20');
$this->assertDatabaseHas('tasks', [
'user_id' => $this->user->id,
'title' => 'Belajar Sanctum',
]);
}
public function test_store_dengan_status_dan_prioritas_eksplisit(): void
{
$this->actingAsApi()
->postJson('/api/tasks', [
'title' => 'Deploy ke production',
'status' => 'in_progress',
'priority' => 'high',
])
->assertStatus(201)
->assertJsonPath('data.status', 'in_progress')
->assertJsonPath('data.priority', 'high');
}
public function test_store_dengan_input_tidak_valid_mengembalikan_422(): void
{
$this->actingAsApi()
->postJson('/api/tasks', [
'title' => '',
'status' => 'selesai',
'priority' => 'tinggi',
'due_date' => 'bukan-tanggal',
])
->assertStatus(422)
->assertJsonValidationErrors(['title', 'status', 'priority', 'due_date']);
$this->assertDatabaseCount('tasks', 0);
}
public function test_show_mengembalikan_detail_tugas(): void
{
$task = Task::factory()->create([
'user_id' => $this->user->id,
'title' => 'Menulis laporan',
]);
$this->actingAsApi()
->getJson("/api/tasks/{$task->id}")
->assertStatus(200)
->assertJsonPath('data.title', 'Menulis laporan')
->assertJsonStructure([
'data' => [
'id', 'title', 'description', 'status',
'priority', 'due_date', 'created_at', 'updated_at',
],
]);
}
public function test_tugas_milik_pengguna_lain_mengembalikan_404(): void
{
$task = Task::factory()->create(); // milik pengguna lain
$this->actingAsApi()->getJson("/api/tasks/{$task->id}")->assertStatus(404);
$this->actingAsApi()
->putJson("/api/tasks/{$task->id}", ['title' => 'Perubahan'])
->assertStatus(404);
$this->actingAsApi()->deleteJson("/api/tasks/{$task->id}")->assertStatus(404);
}
public function test_tugas_yang_tidak_ada_mengembalikan_404(): void
{
$this->actingAsApi()->getJson('/api/tasks/999')->assertStatus(404);
}
public function test_update_mengubah_field_yang_dikirim_saja(): void
{
$task = Task::factory()->create([
'user_id' => $this->user->id,
'title' => 'Belajar Sanctum',
'priority' => 'medium',
]);
$this->actingAsApi()
->putJson("/api/tasks/{$task->id}", [
'status' => 'done',
'priority' => 'high',
])
->assertStatus(200)
->assertJsonPath('data.status', 'done')
->assertJsonPath('data.priority', 'high')
->assertJsonPath('data.title', 'Belajar Sanctum');
}
public function test_update_dengan_status_tidak_valid_mengembalikan_422(): void
{
$task = Task::factory()->create(['user_id' => $this->user->id]);
$this->actingAsApi()
->putJson("/api/tasks/{$task->id}", ['status' => 'batal'])
->assertStatus(422)
->assertJsonValidationErrors('status');
$this->assertDatabaseHas('tasks', ['id' => $task->id, 'status' => 'pending']);
}
public function test_destroy_menghapus_tugas_dan_mengembalikan_204(): void
{
$task = Task::factory()->create(['user_id' => $this->user->id]);
$this->actingAsApi()
->deleteJson("/api/tasks/{$task->id}")
->assertStatus(204)
->assertNoContent();
$this->assertDatabaseMissing('tasks', ['id' => $task->id]);
}
public function test_filter_status_dan_priority(): void
{
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'pending', 'priority' => 'low']);
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'in_progress', 'priority' => 'high']);
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'done', 'priority' => 'high']);
$this->actingAsApi()
->getJson('/api/tasks?status=in_progress')
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.status', 'in_progress');
$this->actingAsApi()
->getJson('/api/tasks?priority=high')
->assertJsonPath('meta.total', 2);
$this->actingAsApi()
->getJson('/api/tasks?status=done&priority=high')
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.status', 'done')
->assertJsonPath('data.0.priority', 'high');
}
public function test_filter_dengan_nilai_tidak_valid_mengembalikan_422(): void
{
$this->actingAsApi()
->getJson('/api/tasks?status=unknown')
->assertStatus(422)
->assertJsonValidationErrors('status');
}
public function test_pagination_membagi_tugas_per_halaman(): void
{
Task::factory()->count(20)->create(['user_id' => $this->user->id]);
$response = $this->actingAsApi()
->getJson('/api/tasks?per_page=8&page=2')
->assertStatus(200)
->assertJsonCount(8, 'data')
->assertJsonPath('meta.current_page', 2)
->assertJsonPath('meta.last_page', 3)
->assertJsonPath('meta.per_page', 8)
->assertJsonPath('meta.total', 20)
->assertJsonPath('meta.from', 9)
->assertJsonPath('meta.to', 16);
$base = rtrim(config('app.url'), '/');
$this->assertSame(
"{$base}/api/tasks?per_page=8&page=3",
$response->json('links.next'),
);
$this->assertSame(
"{$base}/api/tasks?per_page=8&page=1",
$response->json('links.prev'),
);
}
}
tests/TestCase.php
Unduh berkas<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}