Mengapa Test?
Codebase Laravel 5.2 yang asli punya nol test. Kalau ada yang rusak, kita baru tahu ketika user komplain. Tidak lagi.
Testing memberikan kita:
- š”ļø Kepercayaan untuk refactor tanpa rasa takut
- š Deteksi bug dini sebelum production
- š Dokumentasi perilaku yang diharapkan
- š Development lebih cepat dalam jangka panjang
Setup Pest PHP
Laravel 12 sudah datang dengan Pest pre-installed. Verifikasi:
./vendor/bin/pest --version
Konfigurasi phpunit.xml untuk database testing:
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
Struktur Test
| Direktori | File | Deskripsi |
|---|---|---|
tests/Feature/Auth/ | RoleAccessTest.php | Test akses berbasis role |
tests/Feature/Exam/ | CreateExamTest.php | Test pembuatan ujian |
tests/Feature/Exam/ | TakeExamTest.php | Test pengerjaan ujian |
tests/Feature/Exam/ | GradingTest.php | Test penilaian |
tests/Feature/Student/ | RegistrationTest.php | Test registrasi siswa |
tests/Unit/Models/ | ExamTest.php | Unit test model Exam |
tests/Unit/Models/ | QuestionTest.php | Unit test model Question |
tests/Unit/Services/ | GradingServiceTest.php | Test service penilaian |
tests/ | Pest.php | Konfigurasi Pest |
Unit Tests
Testing Model Exam
tests/Unit/Models/ExamTest.php:
<?php
use App\Models\Exam;
use App\Models\Subject;
it('menentukan apakah ujian aktif', function () {
$exam = Exam::factory()->create([
'status' => 'published',
'start_time' => now()->subHour(),
'end_time' => now()->addHour(),
]);
expect($exam->isActive())->toBeTrue();
});
it('mengembalikan false untuk ujian draft', function () {
$exam = Exam::factory()->create([
'status' => 'draft',
'start_time' => now()->subHour(),
'end_time' => now()->addHour(),
]);
expect($exam->isActive())->toBeFalse();
});
it('mengembalikan false untuk ujian kedaluwarsa', function () {
$exam = Exam::factory()->create([
'status' => 'published',
'start_time' => now()->subDays(2),
'end_time' => now()->subDay(),
]);
expect($exam->isActive())->toBeFalse();
});
it('menghitung total poin dengan benar', function () {
$exam = Exam::factory()
->hasQuestions(5, ['points' => 2])
->create();
expect($exam->questions->sum('points'))->toBe(10);
});
Testing Grading Service
Buat service penilaian khusus app/Services/GradingService.php:
<?php
namespace App\Services;
use App\Models\ExamAttempt;
class GradingService
{
public function grade(ExamAttempt $attempt): array
{
$questions = $attempt->exam->questions;
$correctCount = 0;
$totalPoints = 0;
$earnedPoints = 0;
foreach ($questions as $question) {
$totalPoints += $question->points;
$answer = $attempt->answers->firstWhere('question_id', $question->id);
if ($answer && $this->isCorrect($answer->answer, $question->correct_answer)) {
$correctCount++;
$earnedPoints += $question->points;
}
}
$score = $totalPoints > 0 ? round(($earnedPoints / $totalPoints) * 100) : 0;
return [
'score' => $score,
'correct_count' => $correctCount,
'total_questions' => $questions->count(),
'earned_points' => $earnedPoints,
'total_points' => $totalPoints,
'passed' => $score >= $attempt->exam->passing_score,
];
}
private function isCorrect(string $userAnswer, string $correctAnswer): bool
{
return strtolower(trim($userAnswer)) === strtolower(trim($correctAnswer));
}
}
Test-nya tests/Unit/Services/GradingServiceTest.php:
<?php
use App\Models\Answer;
use App\Models\Exam;
use App\Models\ExamAttempt;
use App\Models\Question;
use App\Models\User;
use App\Services\GradingService;
beforeEach(function () {
$this->service = new GradingService();
});
it('menilai skor sempurna dengan benar', function () {
$exam = Exam::factory()->create(['passing_score' => 60]);
$student = User::factory()->create();
$questions = Question::factory()->count(3)->create([
'exam_id' => $exam->id,
'correct_answer' => 'A',
'points' => 1,
]);
$attempt = ExamAttempt::factory()->create([
'exam_id' => $exam->id,
'student_id' => $student->id,
]);
foreach ($questions as $question) {
Answer::create([
'exam_attempt_id' => $attempt->id,
'question_id' => $question->id,
'answer' => 'A',
]);
}
$result = $this->service->grade($attempt->fresh(['answers', 'exam.questions']));
expect($result['score'])->toBe(100);
expect($result['correct_count'])->toBe(3);
expect($result['passed'])->toBeTrue();
});
it('menangani skor parsial', function () {
$exam = Exam::factory()->create(['passing_score' => 60]);
$student = User::factory()->create();
$questions = Question::factory()->count(4)->create([
'exam_id' => $exam->id,
'correct_answer' => 'A',
'points' => 1,
]);
$attempt = ExamAttempt::factory()->create([
'exam_id' => $exam->id,
'student_id' => $student->id,
]);
// Jawab 2 benar, 2 salah
Answer::create(['exam_attempt_id' => $attempt->id, 'question_id' => $questions[0]->id, 'answer' => 'A']);
Answer::create(['exam_attempt_id' => $attempt->id, 'question_id' => $questions[1]->id, 'answer' => 'A']);
Answer::create(['exam_attempt_id' => $attempt->id, 'question_id' => $questions[2]->id, 'answer' => 'B']);
Answer::create(['exam_attempt_id' => $attempt->id, 'question_id' => $questions[3]->id, 'answer' => 'C']);
$result = $this->service->grade($attempt->fresh(['answers', 'exam.questions']));
expect($result['score'])->toBe(50);
expect($result['correct_count'])->toBe(2);
expect($result['passed'])->toBeFalse();
});
it('case insensitive saat menilai', function () {
$exam = Exam::factory()->create();
$student = User::factory()->create();
$question = Question::factory()->create([
'exam_id' => $exam->id,
'correct_answer' => 'True',
'points' => 1,
]);
$attempt = ExamAttempt::factory()->create([
'exam_id' => $exam->id,
'student_id' => $student->id,
]);
Answer::create([
'exam_attempt_id' => $attempt->id,
'question_id' => $question->id,
'answer' => 'true', // huruf kecil
]);
$result = $this->service->grade($attempt->fresh(['answers', 'exam.questions']));
expect($result['correct_count'])->toBe(1);
});
Feature Tests
Test Akses Berbasis Role
tests/Feature/Auth/RoleAccessTest.php:
<?php
use App\Models\User;
it('membolehkan admin mengakses panel admin', function () {
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin)
->get('/admin')
->assertOk();
});
it('mencegah siswa mengakses panel admin', function () {
$student = User::factory()->create();
$student->assignRole('student');
$this->actingAs($student)
->get('/admin')
->assertForbidden();
});
it('membolehkan guru mengakses panel guru', function () {
$teacher = User::factory()->create();
$teacher->assignRole('teacher');
$this->actingAs($teacher)
->get('/teacher')
->assertOk();
});
it('membolehkan siswa mengakses panel siswa', function () {
$student = User::factory()->create();
$student->assignRole('student');
$this->actingAs($student)
->get('/student')
->assertOk();
});
Test Pengerjaan Ujian
tests/Feature/Exam/TakeExamTest.php:
<?php
use App\Models\Exam;
use App\Models\ExamAttempt;
use App\Models\Question;
use App\Models\User;
it('mencegah pengerjaan ujian tidak aktif', function () {
$student = User::factory()->create();
$student->assignRole('student');
$exam = Exam::factory()->create([
'status' => 'draft',
]);
$this->actingAs($student)
->get("/student/take-exam/{$exam->id}")
->assertRedirect();
});
it('membuat exam attempt ketika siswa memulai ujian', function () {
$student = User::factory()->create();
$student->assignRole('student');
$exam = Exam::factory()->create([
'status' => 'published',
'start_time' => now()->subHour(),
'end_time' => now()->addHour(),
]);
$this->actingAs($student)
->get("/student/take-exam/{$exam->id}");
$this->assertDatabaseHas('exam_attempts', [
'exam_id' => $exam->id,
'student_id' => $student->id,
'status' => 'in_progress',
]);
});
it('mencegah multiple attempts pada ujian yang sama', function () {
$student = User::factory()->create();
$student->assignRole('student');
$exam = Exam::factory()->create([
'status' => 'published',
'start_time' => now()->subHour(),
'end_time' => now()->addHour(),
]);
// Buat attempt yang sudah selesai
ExamAttempt::factory()->create([
'exam_id' => $exam->id,
'student_id' => $student->id,
'status' => 'completed',
]);
// Harus redirect ke hasil
$this->actingAs($student)
->get("/student/take-exam/{$exam->id}")
->assertRedirect();
});
Menjalankan Tests
# Jalankan semua tests
./vendor/bin/pest
# Jalankan dengan coverage
./vendor/bin/pest --coverage
# Jalankan file test spesifik
./vendor/bin/pest tests/Feature/Exam/TakeExamTest.php
# Jalankan tests yang cocok dengan nama
./vendor/bin/pest --filter="grading"
Continuous Integration
Tambahkan ke .github/workflows/tests.yml:
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, ctype, json, pdo_sqlite
coverage: xdebug
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist
- name: Copy env file
run: cp .env.example .env
- name: Generate key
run: php artisan key:generate
- name: Run Tests
run: ./vendor/bin/pest --coverage --min=80
Ringkasan
Kita telah mengimplementasikan testing komprehensif:
- ā Unit tests untuk model dan service
- ā Feature tests untuk autentikasi dan alur ujian
- ā Grading service dengan edge cases
- ā Pipeline CI/CD dengan GitHub Actions
- ā Requirement code coverage
Selanjutnya
Di Part 8, kita akan mendeploy aplikasi ke production:
- Setup server dengan Ubuntu + NGINX
- Migrasi database dari sistem lama
- Konfigurasi SSL dan keamanan
- PM2 untuk manajemen proses
Lanjut ke Part 8: Deployment ā
