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
| Directory | File | Description |
|---|---|---|
tests/Feature/Auth/ | RoleAccessTest.php | Role-based access tests |
tests/Feature/Exam/ | CreateExamTest.php | Exam creation tests |
tests/Feature/Exam/ | TakeExamTest.php | Exam taking tests |
tests/Feature/Exam/ | GradingTest.php | Grading tests |
tests/Feature/Student/ | RegistrationTest.php | Student registration tests |
tests/Unit/Models/ | ExamTest.php | Exam model unit tests |
tests/Unit/Models/ | QuestionTest.php | Question model unit tests |
tests/Unit/Services/ | GradingServiceTest.php | Grading service tests |
tests/ | Pest.php | Pest 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 →
