1. LATAR BELAKANG
Setelah lima hari membangun API dengan berbagai fitur canggih—relasi database, pagination filtering, file upload, versioning, dan rate limiting—kita sampai pada tahap yang tidak kalah pentingnya: testing. API yang hebat tanpa testing yang baik adalah bom waktu yang siap meledak di production.
Testing adalah safety net yang memastikan:
- Fitur baru tidak merusak fitur lama (regression testing)
- API berfungsi sesuai spesifikasi (validation)
- Edge cases tertangani dengan baik (robustness)
- Dokumentasi selalu sinkron dengan implementasi (living documentation)
Pada hari kelima minggu ini, saya mempelajari dua pendekatan testing untuk REST API:
- Feature Test dengan PHPUnit
- Testing otomatis terintegrasi dengan Laravel
- Bisa dijalankan di CI/CD pipeline
- Memastikan kode bekerja sebelum deploy
- Postman Collection & Testing
- Testing manual yang bisa diotomatisasi
- Bisa dibagikan ke tim frontend sebagai dokumentasi
- Collection Runner untuk regression testing
Dengan kombinasi kedua pendekatan ini, kita bisa memastikan API kita robust, reliable, dan siap production.
2. ALAT DAN BAHAN
2.1 Perangkat Lunak
- Laravel 11 - Project API dari hari sebelumnya
- PHPUnit - Testing framework (bawaan Laravel)
- Postman - Untuk collection testing
- Newman - CLI tool untuk menjalankan Postman collection
- SQLite - Untuk database testing in-memory
- Visual Studio Code - Editor kode
- Git Bash/Terminal - Untuk menjalankan test
2.2 Perangkat Keras
- Laptop dengan spesifikasi standar
3. PEMBAHASAN
3.1 Persiapan Environment Testing
3.1.1 Konfigurasi Database Testing
# File: .env.testing (buat file baru) APP_NAME="Laravel API Test" APP_ENV=testing APP_KEY=base64:YourAppKeyHere APP_DEBUG=true APP_URL=http://localhost LOG_CHANNEL=stack DB_CONNECTION=sqlite DB_DATABASE=:memory: # Menggunakan database in-memory untuk testing BROADCAST_DRIVER=log CACHE_DRIVER=array QUEUE_CONNECTION=sync SESSION_DRIVER=array
3.1.2 Konfigurasi phpunit.xml
<?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true" > <testsuites> <testsuite name="Unit"> <directory>tests/Unit</directory> </testsuite> <testsuite name="Feature"> <directory>tests/Feature</directory> </testsuite> <testsuite name="Api"> <directory>tests/Feature/Api</directory> </testsuite> </testsuites> <php> <env name="APP_ENV" value="testing"/> <env name="BCRYPT_ROUNDS" value="4"/> <env name="CACHE_DRIVER" value="array"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="MAIL_MAILER" value="array"/> <env name="QUEUE_CONNECTION" value="sync"/> <env name="SESSION_DRIVER" value="array"/> <env name="TELESCOPE_ENABLED" value="false"/> </php> </phpunit>
3.1.3 Membuat Test Case Khusus API
<?php // File: tests/TestCase.php (sudah ada) namespace Tests; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\RefreshDatabase; abstract class TestCase extends BaseTestCase { use CreatesApplication, RefreshDatabase; /** * Setup the test environment. */ protected function setUp(): void { parent::setUp(); // Run migrations $this->artisan('migrate:fresh'); // Run seeders if needed $this->artisan('db:seed'); } }
<?php // File: tests/ApiTestCase.php namespace Tests; use App\Models\User; use Laravel\Sanctum\Sanctum; abstract class ApiTestCase extends TestCase { /** * Create and authenticate a user */ protected function authenticateUser($attributes = []): User { $user = User::factory()->create($attributes); Sanctum::actingAs($user, ['*']); return $user; } /** * Create admin user */ protected function authenticateAdmin(): User { $admin = User::factory()->create([ 'role' => 'admin' ]); Sanctum::actingAs($admin, ['*']); return $admin; } /** * Get authentication headers */ protected function getAuthHeaders(User $user): array { $token = $user->createToken('test-token')->plainTextToken; return [ 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json' ]; } }
3.2 Feature Testing untuk Endpoint Publik
3.2.1 Struktur Folder Test
tests/ ├── Feature/ │ ├── Api/ │ │ ├── Auth/ │ │ │ ├── LoginTest.php │ │ │ ├── RegisterTest.php │ │ │ └── LogoutTest.php │ │ ├── Post/ │ │ │ ├── ListPostsTest.php │ │ │ ├── CreatePostTest.php │ │ │ ├── UpdatePostTest.php │ │ │ └── DeletePostTest.php │ │ ├── Comment/ │ │ │ └── CommentTest.php │ │ └── Profile/ │ │ └── ProfileTest.php
3.2.2 Testing List Posts (Publik)
<?php // File: tests/Feature/Api/Post/ListPostsTest.php namespace Tests\Feature\Api\Post; use Tests\ApiTestCase; use App\Models\Post; use App\Models\Category; use App\Models\User; class ListPostsTest extends ApiTestCase { /** * Test dapat mengambil daftar posts */ public function test_can_get_posts_list() { // Buat data dummy Post::factory(15)->create(); // Request ke endpoint $response = $this->getJson('/api/v1/posts'); // Assertions $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ '*' => ['id', 'title', 'content', 'author'] ], 'links', 'meta' ]); $this->assertCount(15, $response->json('data')); } /** * Test pagination bekerja */ public function test_pagination_works() { Post::factory(30)->create(); $response = $this->getJson('/api/v1/posts?page=2&per_page=10'); $response->assertStatus(200) ->assertJsonPath('meta.current_page', 2) ->assertJsonPath('meta.per_page', 10) ->assertJsonPath('meta.total', 30); } /** * Test filtering by category */ public function test_can_filter_by_category() { $category = Category::factory()->create(); Post::factory(5)->create(['category_id' => $category->id]); Post::factory(10)->create(); // posts lain $response = $this->getJson('/api/v1/posts?category_id=' . $category->id); $response->assertStatus(200); $this->assertCount(5, $response->json('data')); } /** * Test search functionality */ public function test_can_search_posts() { Post::factory()->create(['title' => 'Belajar Laravel API']); Post::factory()->create(['title' => 'Tutorial PHP']); Post::factory()->create(['title' => 'Tips JavaScript']); $response = $this->getJson('/api/v1/posts?search=Laravel'); $response->assertStatus(200); $this->assertCount(1, $response->json('data')); $this->assertEquals('Belajar Laravel API', $response->json('data.0.title')); } /** * Test sorting works */ public function test_can_sort_posts() { $post1 = Post::factory()->create(['title' => 'A Post', 'created_at' => now()->subDay()]); $post2 = Post::factory()->create(['title' => 'B Post', 'created_at' => now()]); $post3 = Post::factory()->create(['title' => 'C Post', 'created_at' => now()->subHour()]); // Sort by title ascending $response = $this->getJson('/api/v1/posts?sort_by=title&order=asc'); $titles = collect($response->json('data'))->pluck('title')->toArray(); $this->assertEquals(['A Post', 'B Post', 'C Post'], $titles); // Sort by created_at descending (default) $response = $this->getJson('/api/v1/posts'); $firstPost = $response->json('data.0'); $this->assertEquals($post2->id, $firstPost['id']); } }
3.2.3 Testing Single Post (Publik)
<?php // File: tests/Feature/Api/Post/ShowPostTest.php namespace Tests\Feature\Api\Post; use Tests\ApiTestCase; use App\Models\Post; use App\Models\Comment; class ShowPostTest extends ApiTestCase { /** * Test dapat melihat detail post */ public function test_can_view_post_detail() { $post = Post::factory()->create(); $response = $this->getJson('/api/v1/posts/' . $post->id); $response->assertStatus(200) ->assertJsonStructure([ 'data' => [ 'id', 'title', 'content', 'author', 'category', 'created_at' ] ]) ->assertJsonPath('data.id', $post->id) ->assertJsonPath('data.title', $post->title); } /** * Test 404 jika post tidak ditemukan */ public function test_returns_404_if_post_not_found() { $response = $this->getJson('/api/v1/posts/99999'); $response->assertStatus(404) ->assertJson([ 'success' => false, 'message' => 'Data tidak ditemukan' ]); } /** * Test views increment ketika dilihat */ public function test_views_increment_when_viewed() { $post = Post::factory()->create(['views' => 10]); $this->getJson('/api/v1/posts/' . $post->id); $this->assertDatabaseHas('posts', [ 'id' => $post->id, 'views' => 11 ]); } /** * Test post dengan comments */ public function test_can_view_post_with_comments() { $post = Post::factory()->create(); Comment::factory(5)->create([ 'commentable_id' => $post->id, 'commentable_type' => Post::class ]); $response = $this->getJson('/api/v2/posts/' . $post->id . '?include=comments'); $response->assertStatus(200); $this->assertArrayHasKey('comments', $response->json('data')); $this->assertCount(5, $response->json('data.comments')); } }
3.3 Feature Testing untuk Endpoint dengan Autentikasi
3.3.1 Testing Create Post (Authenticated)
<?php // File: tests/Feature/Api/Post/CreatePostTest.php namespace Tests\Feature\Api\Post; use Tests\ApiTestCase; use App\Models\Post; use App\Models\Category; class CreatePostTest extends ApiTestCase { /** * Test user dapat membuat post */ public function test_authenticated_user_can_create_post() { $user = $this->authenticateUser(); $category = Category::factory()->create(); $postData = [ 'title' => 'Test Post Title', 'content' => 'This is the content of the test post. It should be long enough.', 'category_id' => $category->id ]; $response = $this->postJson('/api/v1/posts', $postData); $response->assertStatus(201) ->assertJsonStructure([ 'success', 'message', 'data' => ['id', 'title', 'content'] ]) ->assertJsonPath('data.title', $postData['title']) ->assertJsonPath('data.user_id', $user->id); // Assert database $this->assertDatabaseHas('posts', [ 'title' => $postData['title'], 'user_id' => $user->id ]); } /** * Test guest tidak bisa membuat post */ public function test_guest_cannot_create_post() { $category = Category::factory()->create(); $response = $this->postJson('/api/v1/posts', [ 'title' => 'Test Post', 'content' => 'Content', 'category_id' => $category->id ]); $response->assertStatus(401); } /** * Test validasi saat membuat post */ public function test_validation_errors_when_creating_post() { $this->authenticateUser(); // Kirim data kosong $response = $this->postJson('/api/v1/posts', []); $response->assertStatus(422) ->assertJsonValidationErrors(['title', 'content', 'category_id']); // Test title terlalu pendek $response = $this->postJson('/api/v1/posts', [ 'title' => 'a', 'content' => 'valid content', 'category_id' => 1 ]); $response->assertStatus(422) ->assertJsonValidationErrors(['title']); } /** * Test membuat post dengan tags (V2) */ public function test_can_create_post_with_tags_in_v2() { $this->authenticateUser(); $response = $this->postJson('/api/v2/posts', [ 'title' => 'Post with Tags', 'content' => 'This post has tags', 'category_id' => 1, 'tags' => [1, 2, 3] ]); $response->assertStatus(201); $postId = $response->json('data.id'); // Assert tags attached $this->assertDatabaseHas('post_tag', [ 'post_id' => $postId, 'tag_id' => 1 ]); } }
3.3.2 Testing Update Post (Authorization)
<?php // File: tests/Feature/Api/Post/UpdatePostTest.php namespace Tests\Feature\Api\Post; use Tests\ApiTestCase; use App\Models\Post; use App\Models\User; class UpdatePostTest extends ApiTestCase { /** * Test pemilik post dapat mengupdate */ public function test_owner_can_update_their_post() { $user = $this->authenticateUser(); $post = Post::factory()->create(['user_id' => $user->id]); $response = $this->putJson('/api/v1/posts/' . $post->id, [ 'title' => 'Updated Title', 'content' => 'Updated content that is long enough', 'category_id' => $post->category_id ]); $response->assertStatus(200) ->assertJsonPath('data.title', 'Updated Title'); $this->assertDatabaseHas('posts', [ 'id' => $post->id, 'title' => 'Updated Title' ]); } /** * Test user tidak bisa mengupdate post orang lain */ public function test_user_cannot_update_others_post() { $this->authenticateUser(); $otherUser = User::factory()->create(); $post = Post::factory()->create(['user_id' => $otherUser->id]); $response = $this->putJson('/api/v1/posts/' . $post->id, [ 'title' => 'Hacked Title', 'content' => 'Hacked content', 'category_id' => $post->category_id ]); $response->assertStatus(403); // Forbidden $this->assertDatabaseMissing('posts', [ 'id' => $post->id, 'title' => 'Hacked Title' ]); } /** * Test admin bisa mengupdate post siapapun */ public function test_admin_can_update_any_post() { $this->authenticateAdmin(); $otherUser = User::factory()->create(); $post = Post::factory()->create(['user_id' => $otherUser->id]); $response = $this->putJson('/api/v1/posts/' . $post->id, [ 'title' => 'Admin Updated', 'content' => 'Updated by admin', 'category_id' => $post->category_id ]); $response->assertStatus(200); $this->assertDatabaseHas('posts', [ 'id' => $post->id, 'title' => 'Admin Updated' ]); } /** * Test update dengan PATCH (partial update) */ public function test_can_partial_update_with_patch() { $user = $this->authenticateUser(); $post = Post::factory()->create([ 'user_id' => $user->id, 'title' => 'Original Title', 'content' => 'Original content' ]); $response = $this->patchJson('/api/v1/posts/' . $post->id, [ 'title' => 'Only Title Updated' ]); $response->assertStatus(200); $this->assertDatabaseHas('posts', [ 'id' => $post->id, 'title' => 'Only Title Updated', 'content' => 'Original content' // tetap sama ]); } }
3.3.3 Testing Delete Post
<?php // File: tests/Feature/Api/Post/DeletePostTest.php namespace Tests\Feature\Api\Post; use Tests\ApiTestCase; use App\Models\Post; use App\Models\User; class DeletePostTest extends ApiTestCase { /** * Test pemilik dapat menghapus post */ public function test_owner_can_delete_their_post() { $user = $this->authenticateUser(); $post = Post::factory()->create(['user_id' => $user->id]); $response = $this->deleteJson('/api/v1/posts/' . $post->id); $response->assertStatus(200) ->assertJson([ 'success' => true, 'message' => 'Post berhasil dihapus' ]); $this->assertSoftDeleted('posts', [ 'id' => $post->id ]); } /** * Test user tidak bisa hapus post orang lain */ public function test_user_cannot_delete_others_post() { $this->authenticateUser(); $otherUser = User::factory()->create(); $post = Post::factory()->create(['user_id' => $otherUser->id]); $response = $this->deleteJson('/api/v1/posts/' . $post->id); $response->assertStatus(403); $this->assertDatabaseHas('posts', [ 'id' => $post->id, 'deleted_at' => null ]); } /** * Test menghapus post yang tidak ada */ public function test_delete_nonexistent_post_returns_404() { $this->authenticateUser(); $response = $this->deleteJson('/api/v1/posts/99999'); $response->assertStatus(404); } /** * Test restore soft deleted post (V2) */ public function test_admin_can_restore_deleted_post() { $this->authenticateAdmin(); $post = Post::factory()->create(); $post->delete(); $response = $this->postJson('/api/v2/posts/' . $post->id . '/restore'); $response->assertStatus(200); $this->assertDatabaseHas('posts', [ 'id' => $post->id, 'deleted_at' => null ]); } }
3.4 Testing Autentikasi
3.4.1 Testing Register
<?php // File: tests/Feature/Api/Auth/RegisterTest.php namespace Tests\Feature\Api\Auth; use Tests\ApiTestCase; use App\Models\User; class RegisterTest extends ApiTestCase { /** * Test user dapat register */ public function test_user_can_register() { $response = $this->postJson('/api/v1/auth/register', [ 'name' => 'Test User', 'email' => 'test@example.com', 'password' => 'password123', 'password_confirmation' => 'password123' ]); $response->assertStatus(201) ->assertJsonStructure([ 'success', 'message', 'data' => [ 'user' => ['id', 'name', 'email'], 'token' ] ]); $this->assertDatabaseHas('users', [ 'name' => 'Test User', 'email' => 'test@example.com' ]); } /** * Test validasi register */ public function test_register_validation() { // Email sudah terdaftar User::factory()->create(['email' => 'existing@example.com']); $response = $this->postJson('/api/v1/auth/register', [ 'name' => 'Test', 'email' => 'existing@example.com', 'password' => 'pass', 'password_confirmation' => 'different' ]); $response->assertStatus(422) ->assertJsonValidationErrors(['email', 'password']); } }
3.4.2 Testing Login
<?php // File: tests/Feature/Api/Auth/LoginTest.php namespace Tests\Feature\Api\Auth; use Tests\ApiTestCase; use App\Models\User; use Illuminate\Support\Facades\Hash; class LoginTest extends ApiTestCase { /** * Test user dapat login */ public function test_user_can_login() { $user = User::factory()->create([ 'email' => 'user@example.com', 'password' => Hash::make('password123') ]); $response = $this->postJson('/api/v1/auth/login', [ 'email' => 'user@example.com', 'password' => 'password123' ]); $response->assertStatus(200) ->assertJsonStructure([ 'success', 'message', 'data' => [ 'user', 'token', 'token_type' ] ]); } /** * Test login dengan kredensial salah */ public function test_login_with_invalid_credentials() { $user = User::factory()->create([ 'email' => 'user@example.com', 'password' => Hash::make('correctpassword') ]); $response = $this->postJson('/api/v1/auth/login', [ 'email' => 'user@example.com', 'password' => 'wrongpassword' ]); $response->assertStatus(422) ->assertJsonValidationErrors(['email']); } /** * Test rate limiting pada login */ public function test_login_rate_limiting() { // Coba login 6 kali (limit 5 per menit) for ($i = 0; $i < 6; $i++) { $response = $this->postJson('/api/v1/auth/login', [ 'email' => 'any@email.com', 'password' => 'wrong' ]); } $response->assertStatus(429); // Too Many Attempts } /** * Test logout */ public function test_user_can_logout() { $user = $this->authenticateUser(); $response = $this->postJson('/api/v1/auth/logout'); $response->assertStatus(200); // Token harusnya sudah dihapus $this->assertDatabaseCount('personal_access_tokens', 0); } }
3.5 Testing Relasi dan Fitur Kompleks
3.5.1 Testing Comments (Polymorphic)
<?php // File: tests/Feature/Api/Comment/CommentTest.php namespace Tests\Feature\Api\Comment; use Tests\ApiTestCase; use App\Models\Post; use App\Models\Video; use App\Models\Comment; class CommentTest extends ApiTestCase { /** * Test user dapat comment di post */ public function test_user_can_comment_on_post() { $user = $this->authenticateUser(); $post = Post::factory()->create(); $response = $this->postJson('/api/v1/posts/' . $post->id . '/comments', [ 'content' => 'This is a test comment' ]); $response->assertStatus(201); $this->assertDatabaseHas('comments', [ 'user_id' => $user->id, 'commentable_id' => $post->id, 'commentable_type' => Post::class, 'content' => 'This is a test comment' ]); } /** * Test user dapat comment di video */ public function test_user_can_comment_on_video() { $user = $this->authenticateUser(); $video = Video::factory()->create(); $response = $this->postJson('/api/v1/videos/' . $video->id . '/comments', [ 'content' => 'Great video!' ]); $response->assertStatus(201); $this->assertDatabaseHas('comments', [ 'user_id' => $user->id, 'commentable_id' => $video->id, 'commentable_type' => Video::class ]); } /** * Test user dapat melihat komentar di post */ public function test_can_view_post_comments() { $post = Post::factory()->create(); Comment::factory(10)->create([ 'commentable_id' => $post->id, 'commentable_type' => Post::class ]); $response = $this->getJson('/api/v1/posts/' . $post->id . '/comments'); $response->assertStatus(200) ->assertJsonCount(10, 'data'); } /** * Test user dapat menghapus komentar sendiri */ public function test_user_can_delete_own_comment() { $user = $this->authenticateUser(); $post = Post::factory()->create(); $comment = Comment::factory()->create([ 'user_id' => $user->id, 'commentable_id' => $post->id, 'commentable_type' => Post::class ]); $response = $this->deleteJson('/api/v1/comments/' . $comment->id); $response->assertStatus(200); $this->assertDatabaseMissing('comments', [ 'id' => $comment->id, 'deleted_at' => null ]); } }
3.5.2 Testing File Upload
<?php // File: tests/Feature/Api/Profile/UploadAvatarTest.php namespace Tests\Feature\Api\Profile; use Tests\ApiTestCase; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; class UploadAvatarTest extends ApiTestCase { /** * Test user dapat upload avatar */ public function test_user_can_upload_avatar() { Storage::fake('public'); $user = $this->authenticateUser(); $file = UploadedFile::fake()->image('avatar.jpg', 200, 200); $response = $this->postJson('/api/user/avatar', [ 'avatar' => $file ]); $response->assertStatus(201) ->assertJsonStructure([ 'success', 'message', 'data' => ['avatar_url'] ]); // Assert file tersimpan Storage::disk('public')->assertExists('avatars/' . $file->hashName()); // Assert database terupdate $this->assertDatabaseHas('profiles', [ 'user_id' => $user->id, 'avatar' => 'avatars/' . $file->hashName() ]); } /** * Test validasi file upload */ public function test_avatar_validation() { Storage::fake('public'); $this->authenticateUser(); // File terlalu besar (3MB > 2MB) $file = UploadedFile::fake()->image('avatar.jpg')->size(3072); $response = $this->postJson('/api/user/avatar', [ 'avatar' => $file ]); $response->assertStatus(422) ->assertJsonValidationErrors(['avatar']); // File bukan gambar $file = UploadedFile::fake()->create('document.pdf', 100); $response = $this->postJson('/api/user/avatar', [ 'avatar' => $file ]); $response->assertStatus(422) ->assertJsonValidationErrors(['avatar']); } /** * Test user dapat hapus avatar */ public function test_user_can_delete_avatar() { Storage::fake('public'); $user = $this->authenticateUser(); // Upload dulu $file = UploadedFile::fake()->image('avatar.jpg'); $this->postJson('/api/user/avatar', ['avatar' => $file]); // Hapus $response = $this->deleteJson('/api/user/avatar'); $response->assertStatus(200); // Assert profile avatar null $this->assertDatabaseHas('profiles', [ 'user_id' => $user->id, 'avatar' => null ]); } }
3.5.3 Testing Multiple File Upload
<?php // File: tests/Feature/Api/Post/GalleryTest.php namespace Tests\Feature\Api\Post; use Tests\ApiTestCase; use App\Models\Post; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; class GalleryTest extends ApiTestCase { /** * Test user dapat upload multiple gambar */ public function test_user_can_upload_multiple_images() { Storage::fake('public'); $user = $this->authenticateUser(); $post = Post::factory()->create(['user_id' => $user->id]); $files = [ UploadedFile::fake()->image('image1.jpg'), UploadedFile::fake()->image('image2.jpg'), UploadedFile::fake()->image('image3.jpg') ]; $response = $this->postJson('/api/v1/posts/' . $post->id . '/gallery', [ 'images' => $files ]); $response->assertStatus(201); // Assert 3 gallery records $this->assertDatabaseCount('galleries', 3); // Assert files tersimpan foreach ($files as $file) { Storage::disk('public')->assertExists('gallery/' . $post->id . '/' . $file->hashName()); } } /** * Test validasi jumlah maksimal upload */ public function test_cannot_upload_more_than_10_images() { $user = $this->authenticateUser(); $post = Post::factory()->create(['user_id' => $user->id]); $files = []; for ($i = 0; $i < 11; $i++) { $files[] = UploadedFile::fake()->image("image{$i}.jpg"); } $response = $this->postJson('/api/v1/posts/' . $post->id . '/gallery', [ 'images' => $files ]); $response->assertStatus(422) ->assertJsonValidationErrors(['images']); } }
3.6 Testing Rate Limiting
<?php // File: tests/Feature/Api/RateLimitTest.php namespace Tests\Feature\Api; use Tests\ApiTestCase; use App\Models\User; class RateLimitTest extends ApiTestCase { /** * Test rate limit untuk guest */ public function test_rate_limit_for_guests() { // Lakukan 31 request (limit 30 per menit) for ($i = 0; $i < 31; $i++) { $response = $this->getJson('/api/v1/posts'); if ($i < 30) { $response->assertStatus(200); } else { $response->assertStatus(429); $response->assertJson([ 'message' => 'Too Many Attempts.' ]); } } } /** * Test rate limit berbeda untuk authenticated user */ public function test_rate_limit_for_authenticated_users() { $user = User::factory()->create(); // Lakukan 101 request (limit 100 untuk auth) for ($i = 0; $i < 101; $i++) { $response = $this->actingAs($user) ->getJson('/api/v1/posts'); if ($i < 100) { $response->assertStatus(200); } else { $response->assertStatus(429); } } } /** * Test rate limit headers */ public function test_rate_limit_headers_are_present() { $response = $this->getJson('/api/v1/posts'); $response->assertHeader('X-RateLimit-Limit'); $response->assertHeader('X-RateLimit-Remaining'); } /** * Test rate limit untuk endpoint login lebih ketat */ public function test_login_rate_limit() { // Coba login 6 kali (limit 5 per menit) for ($i = 0; $i < 6; $i++) { $response = $this->postJson('/api/v1/auth/login', [ 'email' => 'test@example.com', 'password' => 'wrongpassword' ]); } $response->assertStatus(429); $response->assertHeader('Retry-After'); } }
3.7 Testing API Versioning
<?php // File: tests/Feature/Api/VersioningTest.php namespace Tests\Feature\Api; use Tests\ApiTestCase; class VersioningTest extends ApiTestCase { /** * Test V1 response structure */ public function test_v1_response_structure() { $response = $this->getJson('/api/v1/posts'); $response->assertJsonStructure([ 'data' => [ '*' => ['id', 'title', 'content', 'author'] ] ]); } /** * Test V2 response structure (lebih lengkap) */ public function test_v2_response_structure() { $response = $this->getJson('/api/v2/posts'); $response->assertJsonStructure([ 'data' => [ '*' => [ 'id', 'title', 'slug', 'excerpt', 'content', 'author', 'category', 'stats', 'created_ago' ] ] ]); } /** * Test versioning via Accept header */ public function test_versioning_via_accept_header() { $response = $this->withHeaders([ 'Accept' => 'application/vnd.api.v2+json' ]) ->getJson('/api/posts'); $response->assertJsonStructure([ 'data' => [ '*' => ['excerpt', 'stats'] // Fitur V2 ] ]); } /** * Test backward compatibility */ public function test_backward_compatibility() { // V1 tetap return field lama meskipun di database ada field baru $response = $this->getJson('/api/v1/posts'); $data = $response->json('data.0'); $this->assertArrayNotHasKey('excerpt', $data); $this->assertArrayNotHasKey('stats', $data); } }
3.8 Menjalankan Tests
3.8.1 Menjalankan Semua Test
# Jalankan semua test php artisan test # Dengan output verbose php artisan test --verbose # Jalankan test dengan coverage php artisan test --coverage
3.8.2 Menjalankan Test Spesifik
# Jalankan test file tertentu php artisan test tests/Feature/Api/Post/CreatePostTest.php # Jalankan method test tertentu php artisan test --filter test_user_can_create_post # Jalankan test di folder tertentu php artisan test tests/Feature/Api/Auth
3.8.3 Parallel Testing (Laravel 11)
# Install parallel testing composer require brianium/paratest --dev # Jalankan parallel test php artisan test --parallel
3.9 Postman Collection untuk API Testing
3.9.1 Membuat Collection di Postman
- Buat Collection Baru: "Laravel REST API - Perwira Learning Center"
- Set Variables:
base_url:http://localhost:8000api_version:v1auth_token: (kosong, akan diisi saat login)- Buat Folder Structure:
- Auth
- Register
- Login
- Logout
- Posts
- List Posts
- Get Post Detail
- Create Post
- Update Post
- Delete Post
- Comments
- Profile
- Gallery
3.9.2 Pre-request Script untuk Autentikasi
// Collection > Pre-request Script // Set token setelah login const token = pm.collectionVariables.get("authToken"); if (token) { pm.request.headers.add({ key: "Authorization", value: `Bearer ${token}` }); } // Set Accept header untuk versioning const version = pm.collectionVariables.get("apiVersion") || "v1"; pm.request.headers.add({ key: "Accept", value: `application/vnd.api.${version}+json` });
3.9.3 Test Script di Postman
Login Request - Tests:
pm.test("Status code is 200", function () { pm.response.to.have.status(200); }); pm.test("Response has token", function () { var jsonData = pm.response.json(); pm.expect(jsonData.data.token).to.exist; // Simpan token untuk request berikutnya pm.collectionVariables.set("authToken", jsonData.data.token); }); pm.test("Response structure is correct", function () { var jsonData = pm.response.json(); pm.expect(jsonData.success).to.be.true; pm.expect(jsonData.data.user).to.have.property('name'); pm.expect(jsonData.data.user).to.have.property('email'); });
Create Post Request - Tests:
pm.test("Status code is 201", function () { pm.response.to.have.status(201); }); pm.test("Post created successfully", function () { var jsonData = pm.response.json(); pm.expect(jsonData.success).to.be.true; pm.expect(jsonData.data.title).to.eql(pm.request.body.title); }); pm.test("Response time is less than 500ms", function () { pm.expect(pm.response.responseTime).to.be.below(500); }); // Save post ID for later use var jsonData = pm.response.json(); pm.collectionVariables.set("lastPostId", jsonData.data.id);
3.9.4 Collection Variables
// Pre-request Script untuk random data const timestamp = Date.now(); pm.variables.set("randomEmail", `user${timestamp}@example.com`); pm.variables.set("randomTitle", `Test Post ${timestamp}`);
3.9.5 Environment-Specific Variables
Buat environment berbeda:
- Local:
base_url: http://localhost:8000 - Staging:
base_url: https://staging-api.example.com - Production:
base_url: https://api.example.com
3.9.6 Running Collection dengan Newman
# Install Newman npm install -g newman # Run collection newman run Laravel-API-Collection.postman_collection.json \ -e Local-Environment.postman_environment.json \ --reporters cli,json \ --reporter-json-export test-results.json # Run dengan HTML report npm install -g newman-reporter-html newman run collection.json -e environment.json -r htmlextra
3.9.7 Integrasi Newman dengan CI/CD
# .github/workflows/api-tests.yml name: API Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.1' - name: Start Laravel run: | composer install php artisan serve --env=testing & - name: Install Newman run: npm install -g newman - name: Run Postman Tests run: | newman run tests/Postman/Laravel-API-Collection.json \ -e tests/Postman/Testing-Environment.json \ --reporters cli,json \ --reporter-json-export test-results.json
3.10 Testing Best Practices
3.10.1 AAA Pattern (Arrange-Act-Assert)
public function test_user_can_create_post() { // Arrange - Siapkan data $user = User::factory()->create(); $category = Category::factory()->create(); $postData = [ 'title' => 'Test Post', 'content' => 'Test Content', 'category_id' => $category->id ]; // Act - Lakukan aksi $response = $this->actingAs($user) ->postJson('/api/posts', $postData); // Assert - Verifikasi hasil $response->assertStatus(201); $this->assertDatabaseHas('posts', ['title' => 'Test Post']); }
3.10.2 Data Provider untuk Multiple Scenarios
/** * @dataProvider postValidationProvider */ public function test_post_validation($data, $expectedErrors) { $this->authenticateUser(); $response = $this->postJson('/api/posts', $data); $response->assertStatus(422); $response->assertJsonValidationErrors($expectedErrors); } public function postValidationProvider() { return [ 'missing title' => [ ['content' => 'Content', 'category_id' => 1], ['title'] ], 'title too short' => [ ['title' => 'a', 'content' => 'Content', 'category_id' => 1], ['title'] ], 'missing category' => [ ['title' => 'Valid Title', 'content' => 'Content'], ['category_id'] ], ]; }
3.10.3 Testing Edge Cases
public function test_handles_edge_cases() { // Empty result $response = $this->getJson('/api/posts?search=NoResult'); $response->assertJsonCount(0, 'data'); // Pagination with zero per_page $response = $this->getJson('/api/posts?per_page=0'); $response->assertStatus(422); // Negative page number $response = $this->getJson('/api/posts?page=-1'); $response->assertStatus(200); // Should default to page 1 // SQL injection attempt $response = $this->getJson("/api/posts?search=' OR '1'='1"); $response->assertStatus(200); // Should be safe // XSS attempt $response = $this->postJson('/api/posts', [ 'title' => '<script>alert("xss")</script>', 'content' => 'Content', 'category_id' => 1 ]); $response->assertStatus(201); // Should be escaped in response $this->assertStringContainsString('<script>', $response->getContent()); }
3.10.4 Testing Performance
public function test_index_page_performance() { // Seed banyak data Post::factory(1000)->create(); $startTime = microtime(true); $response = $this->getJson('/api/posts?per_page=100'); $endTime = microtime(true); $executionTime = ($endTime - $startTime) * 1000; // in ms $response->assertStatus(200); $this->assertLessThan(500, $executionTime, 'Response time should be less than 500ms'); }
3.11 Kendala dan Solusi
| Kendala | Solusi |
|---|---|
| Database testing lambat | Gunakan SQLite in-memory, parallel testing |
| File upload testing | Gunakan Storage::fake() untuk mock |
| Token expired di test | Gunakan Sanctum::actingAs() daripada bikin token beneran |
| Rate limiting mengganggu test | Nonaktifkan throttle di environment testing |
| Data provider terlalu banyak | Group test cases, gunakan dataset yang representatif |
| Test flaky (kadang sukses kadang fail) | Cari dependency ke waktu/urutan, gunakan RefreshDatabase |
| Mocking external API | Gunakan Http::fake() untuk mock external requests |
4. KESIMPULAN
Testing adalah investasi yang tidak boleh dilewatkan dalam pengembangan API profesional. Pada hari kelima ini, kita telah mempelajari:
- 1. Feature Testing dengan PHPUnit
- Setup environment testing dengan SQLite in-memory
- Test untuk endpoint publik dan protected
- Test untuk autentikasi dan otorisasi
- Test untuk relasi dan fitur kompleks
- Test untuk file upload
- Test untuk rate limiting dan versioning
- 2. Postman Collection Testing
- Membuat collection terstruktur
- Pre-request scripts untuk otomatisasi
- Test scripts untuk validasi response
- Environment variables untuk fleksibilitas
- Newman untuk CI/CD integration
- 3. Best Practices
- AAA Pattern (Arrange-Act-Assert)
- Data Provider untuk multiple scenarios
- Edge cases testing
- Performance testing
- Mocking external dependencies
Manfaat yang Didapat:
- Confidence - Yakin API bekerja sebelum deploy
- Documentation - Test sebagai living documentation
- Regression Prevention - Fitur baru tidak merusak yang lama
- Debugging Easier - Langsung tahu bagian mana yang error
- CI/CD Ready - Bisa diintegrasi dengan pipeline
Dengan kemampuan testing ini, siklus pengembangan API kita menjadi lebih matang: TDD (Test-Driven Development) bisa diterapkan, di mana kita menulis test terlebih dahulu sebelum implementasi.
5. DAFTAR PUSTAKA
- Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - Testing. https://laravel.com/docs/12.x/testing
- Laravel Official Documentation. (n.d.). Laravel 12.x Documentation - HTTP Tests. https://laravel.com/docs/12.x/http-tests
- PHPUnit. (n.d.). PHPUnit Documentation. https://phpunit.de/documentation.html
- Postman. (n.d.). Postman Test Scripts. https://learning.postman.com/docs/writing-scripts/test-scripts/
- Newman. (n.d.). Newman Documentation. https://github.com/postmanlabs/newman

0 Komentar