MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

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

9

Part 9: Perbaikan Issue Komunitas & CI/CD

10

Part 10: Penguatan Keamanan & Celah IDOR

Artikel ini tersedia dalam Bahasa Inggris

šŸ‡¬šŸ‡§ Read in English
šŸ‡®šŸ‡© Bahasa IndonesiašŸ“š Rebuild Laravel E-Learning

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.

Muhammad Fari MadyanPenulis

5 menit baca

Ā·

10 Maret 2026


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:

BASH

./vendor/bin/pest --version

Konfigurasi phpunit.xml untuk database testing:

XML

<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

<?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

<?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

<?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

<?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

<?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

BASH

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

YAML

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