Rebuild Laravel E-Learning

1

Part 1: Pengenalan & Mengapa Saya Upgrade

2

Part 2: Setup Laravel 12 + Filament

3

Part 3: Desain Skema Database

4

Part 4: Autentikasi & Role User

5

Part 5: Membangun Sistem Ujian

6

Part 6: Fitur Real-Time

7

Part 7: Strategi Testing

8

Part 8: Deployment

Artikel ini tersedia dalam Bahasa Inggris

šŸ‡¬šŸ‡§ Read in English

10 Maret 2026

šŸ‡®šŸ‡© Bahasa Indonesia

Laravel E-Learning Part 7: Strategi Testing dengan Pest PHP

Implementasi testing komprehensif untuk platform e-learning menggunakan Pest PHP, mencakup unit tests, feature tests, dan browser testing.

5 min read


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

DirektoriFileDeskripsi
tests/Feature/Auth/RoleAccessTest.phpTest akses berbasis role
tests/Feature/Exam/CreateExamTest.phpTest pembuatan ujian
tests/Feature/Exam/TakeExamTest.phpTest pengerjaan ujian
tests/Feature/Exam/GradingTest.phpTest penilaian
tests/Feature/Student/RegistrationTest.phpTest registrasi siswa
tests/Unit/Models/ExamTest.phpUnit test model Exam
tests/Unit/Models/QuestionTest.phpUnit test model Question
tests/Unit/Services/GradingServiceTest.phpTest service penilaian
tests/Pest.phpKonfigurasi 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 →

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

Laravel E-Learning Part 6: Real-Time Features with Livewire

Artikel Selanjutnya →

Laravel E-Learning Part 8: Production Deployment

Next article thumbnail