Loading
Please wait...

Laravel E-Learning Rebuild

1

Part 1: Introduction & Why I'm Upgrading

2

Part 2: Setting Up Laravel 11 + Filament

3

Part 3: Database Schema Design

4

Part 4: Authentication & User Roles

5

Part 5: Building the Exam System

6

Part 6: Real-Time Features

7

Part 7: Testing Strategy

8

Part 8: Deployment

9 Januari 2026

Laravel E-Learning Part 7: Testing Strategy with Pest PHP

Implementing comprehensive testing for our e-learning platform using Pest PHP, covering unit tests, feature tests, and browser testing.

5 min read


Why Test?

The original Laravel 5.2 codebase had zero tests. When something broke, we only found out when users complained. Never again.

Testing gives us:

  • 🛡️ Confidence to refactor without fear
  • 🐛 Early bug detection before production
  • 📝 Documentation of expected behavior
  • 🚀 Faster development in the long run

Setup Pest PHP

Laravel 11 comes with Pest pre-installed. Verify:

./vendor/bin/pest --version

Configure phpunit.xml for testing database:

<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

Test Structure

DirectoryFileDescription
tests/Feature/Auth/RoleAccessTest.phpRole-based access tests
tests/Feature/Exam/CreateExamTest.phpExam creation tests
tests/Feature/Exam/TakeExamTest.phpExam taking tests
tests/Feature/Exam/GradingTest.phpGrading tests
tests/Feature/Student/RegistrationTest.phpStudent registration tests
tests/Unit/Models/ExamTest.phpExam model unit tests
tests/Unit/Models/QuestionTest.phpQuestion model unit tests
tests/Unit/Services/GradingServiceTest.phpGrading service tests
tests/Pest.phpPest configuration

Unit Tests

Testing the Exam Model

tests/Unit/Models/ExamTest.php:

<?php

use App\Models\Exam;
use App\Models\Subject;

it('determines if exam is active', function () {
    $exam = Exam::factory()->create([
        'status' => 'published',
        'start_time' => now()->subHour(),
        'end_time' => now()->addHour(),
    ]);

    expect($exam->isActive())->toBeTrue();
});

it('returns false for draft exams', function () {
    $exam = Exam::factory()->create([
        'status' => 'draft',
        'start_time' => now()->subHour(),
        'end_time' => now()->addHour(),
    ]);

    expect($exam->isActive())->toBeFalse();
});

it('returns false for expired exams', function () {
    $exam = Exam::factory()->create([
        'status' => 'published',
        'start_time' => now()->subDays(2),
        'end_time' => now()->subDay(),
    ]);

    expect($exam->isActive())->toBeFalse();
});

it('calculates total points correctly', function () {
    $exam = Exam::factory()
        ->hasQuestions(5, ['points' => 2])
        ->create();

    expect($exam->questions->sum('points'))->toBe(10);
});

Testing the Grading Service

Create a dedicated grading service 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 it 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('grades a perfect score correctly', 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('handles partial scores', 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,
    ]);

    // Answer 2 correctly, 2 incorrectly
    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('is case insensitive when grading', 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', // lowercase
    ]);

    $result = $this->service->grade($attempt->fresh(['answers', 'exam.questions']));

    expect($result['correct_count'])->toBe(1);
});

Feature Tests

Role-Based Access Test

tests/Feature/Auth/RoleAccessTest.php:

<?php

use App\Models\User;

it('allows admin to access admin panel', function () {
    $admin = User::factory()->create();
    $admin->assignRole('admin');

    $this->actingAs($admin)
        ->get('/admin')
        ->assertOk();
});

it('prevents student from accessing admin panel', function () {
    $student = User::factory()->create();
    $student->assignRole('student');

    $this->actingAs($student)
        ->get('/admin')
        ->assertForbidden();
});

it('allows teacher to access teacher panel', function () {
    $teacher = User::factory()->create();
    $teacher->assignRole('teacher');

    $this->actingAs($teacher)
        ->get('/teacher')
        ->assertOk();
});

it('allows student to access student panel', function () {
    $student = User::factory()->create();
    $student->assignRole('student');

    $this->actingAs($student)
        ->get('/student')
        ->assertOk();
});

Exam Taking Test

tests/Feature/Exam/TakeExamTest.php:

<?php

use App\Models\Exam;
use App\Models\ExamAttempt;
use App\Models\Question;
use App\Models\User;

it('prevents taking inactive exam', 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('creates exam attempt when student starts exam', 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('prevents multiple attempts on same exam', function () {
    $student = User::factory()->create();
    $student->assignRole('student');

    $exam = Exam::factory()->create([
        'status' => 'published',
        'start_time' => now()->subHour(),
        'end_time' => now()->addHour(),
    ]);

    // Create completed attempt
    ExamAttempt::factory()->create([
        'exam_id' => $exam->id,
        'student_id' => $student->id,
        'status' => 'completed',
    ]);

    // Should redirect to results
    $this->actingAs($student)
        ->get("/student/take-exam/{$exam->id}")
        ->assertRedirect();
});

Running Tests

# Run all tests
./vendor/bin/pest

# Run with coverage
./vendor/bin/pest --coverage

# Run specific test file
./vendor/bin/pest tests/Feature/Exam/TakeExamTest.php

# Run tests matching a name
./vendor/bin/pest --filter="grading"

Continuous Integration

Add to .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

Summary

We've implemented comprehensive testing:

  • ✅ Unit tests for models and services
  • ✅ Feature tests for authentication and exam flow
  • ✅ Grading service with edge cases
  • ✅ CI/CD pipeline with GitHub Actions
  • ✅ Code coverage requirements

What's Next

In Part 8, we'll deploy our application to production:

  • Server setup with Ubuntu + NGINX
  • Database migration from old system
  • SSL and security configuration
  • PM2 for process management

Continue to Part 8: Deployment →

Continue Reading

Previous article

← Previous Article

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

Next Article →

Laravel E-Learning Part 8: Production Deployment

Next article