Caching & Performance Optimization di Laravel API - Perwira Learning Center

 


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:

  1. Fitur baru tidak merusak fitur lama (regression testing)
  2. API berfungsi sesuai spesifikasi (validation)
  3. Edge cases tertangani dengan baik (robustness)
  4. Dokumentasi selalu sinkron dengan implementasi (living documentation)

Pada hari kelima minggu ini, saya mempelajari dua pendekatan testing untuk REST API:

  1. Feature Test dengan PHPUnit
    •  Testing otomatis terintegrasi dengan Laravel
    • Bisa dijalankan di CI/CD pipeline
    • Memastikan kode bekerja sebelum deploy
  2. 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
env
# 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
<?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
<?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
<?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
text
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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
bash
# 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
bash
# 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)
bash
# 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
  1. Buat Collection Baru: "Laravel REST API - Perwira Learning Center"
  2. Set Variables: 
    • base_url: http://localhost:8000
    • api_version: v1
    • auth_token: (kosong, akan diisi saat login)
  3. 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
javascript
// 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:

javascript
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:

javascript
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
javascript
// 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
bash
# 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
yaml
# .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)
php
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
php
/**
 * @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
php
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('&lt;script&gt;', $response->getContent());
}
3.10.4 Testing Performance
php
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

KendalaSolusi
Database testing lambatGunakan SQLite in-memory, parallel testing
File upload testingGunakan Storage::fake() untuk mock
Token expired di testGunakan Sanctum::actingAs() daripada bikin token beneran
Rate limiting mengganggu testNonaktifkan throttle di environment testing
Data provider terlalu banyakGroup test cases, gunakan dataset yang representatif
Test flaky (kadang sukses kadang fail)Cari dependency ke waktu/urutan, gunakan RefreshDatabase
Mocking external APIGunakan 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. 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. 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. 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

Posting Komentar

0 Komentar