Testing Overview
Introduction
Testing is a critical part of software development that ensures code quality, prevents regressions, and provides confidence when making changes. This guide covers testing practices for Laravel applications using Pest PHP - a delightful testing framework with a focus on simplicity.
Why Pest?
Pest is built on top of PHPUnit but provides a more elegant and expressive testing syntax:
- Simpler syntax - More readable and concise tests
- Better error messages - Clear, actionable feedback
- Expectation API - Fluent assertions with
expect() - Higher-order tests - Test multiple scenarios easily
- Plugins - Laravel, Livewire, Faker integration
- Parallel testing - Faster test execution
- Type coverage - Check type declaration coverage
Installation
Install Pest
# Install Pest
composer require pestphp/pest --dev
composer require pestphp/pest-plugin-laravel --dev
# Initialize Pest
php artisan pest:install
# Optional: Install additional plugins
composer require pestphp/pest-plugin-faker --devConfiguration
Pest is configured via phpunit.xml and tests/Pest.php:
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
</phpunit>tests/Pest.php
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
// Use TestCase for all tests
uses(TestCase::class)->in('Feature', 'Unit');
// Use RefreshDatabase for feature tests
uses(RefreshDatabase::class)->in('Feature');
// Global test helpers
function actingAsUser(): \App\Models\User
{
$user = \App\Models\User::factory()->create();
test()->actingAs($user);
return $user;
}
function actingAsAdmin(): \App\Models\User
{
$admin = \App\Models\User::factory()->create(['role' => 'admin']);
test()->actingAs($admin);
return $admin;
}Testing Types
1. Unit Tests
Test individual units of code in isolation.
Location: tests/Unit/
Purpose:
- Test single methods/functions
- Mock dependencies
- Fast execution
- No database interaction
Example:
<?php
use App\Services\DiscountCalculator;
test('calculates percentage discount correctly', function () {
$calculator = new DiscountCalculator();
$result = $calculator->calculatePercentage(100, 10);
expect($result)->toBe(10.0);
});2. Feature Tests
Test complete features and user flows.
Location: tests/Feature/
Purpose:
- Test HTTP requests/responses
- Test full workflows
- Database interactions
- Integration testing
Example:
<?php
use App\Models\User;
test('user can register', function () {
$response = $this->postJson('/api/register', [
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(201);
expect(User::where('email', 'john@example.com')->exists())->toBeTrue();
});3. API Tests
Test RESTful API endpoints.
Location: tests/Feature/Api/
Purpose:
- Test API endpoints
- Validate JSON responses
- Test authentication
- Test authorization
Example:
<?php
use App\Models\User;
test('authenticated user can fetch their profile', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->getJson('/api/v1/profile');
$response->assertStatus(200)
->assertJson([
'data' => [
'id' => $user->id,
'email' => $user->email,
]
]);
});Test Structure
Anatomy of a Pest Test
<?php
// Import necessary classes
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
// Test description (what it tests)
test('user can update their profile', function () {
// Arrange - Set up test data
$user = User::factory()->create();
// Act - Perform the action
$response = $this->actingAs($user)
->putJson('/api/v1/profile', [
'name' => 'Updated Name',
]);
// Assert - Verify the outcome
$response->assertStatus(200);
expect($user->fresh()->name)->toBe('Updated Name');
});
// Alternative syntax using it()
it('prevents unauthorized users from accessing admin panel', function () {
$response = $this->getJson('/api/v1/admin/dashboard');
$response->assertStatus(401);
});Using Datasets
Test the same logic with different inputs:
<?php
use App\Services\DiscountCalculator;
test('calculates discount correctly', function (float $price, float $discount, float $expected) {
$calculator = new DiscountCalculator();
$result = $calculator->calculatePercentage($price, $discount);
expect($result)->toBe($expected);
})->with([
[100, 10, 10.0],
[200, 15, 30.0],
[50, 20, 10.0],
[1000, 5, 50.0],
]);
// Named datasets
test('validates email format', function (string $email, bool $isValid) {
$result = filter_var($email, FILTER_VALIDATE_EMAIL);
expect((bool) $result)->toBe($isValid);
})->with([
'valid email' => ['john@example.com', true],
'invalid email' => ['not-an-email', false],
'missing @' => ['johnexample.com', false],
'valid with subdomain' => ['john@mail.example.com', true],
]);Running Tests
Basic Commands
# Run all tests
php artisan test
# Run all tests with Pest directly
./vendor/bin/pest
# Run specific test file
php artisan test tests/Feature/UserTest.php
# Run specific test by name
php artisan test --filter="user can register"
# Run tests in parallel (faster)
php artisan test --parallel
# Run with coverage
php artisan test --coverage
# Run with minimum coverage requirement
php artisan test --coverage --min=80
# Run specific test suite
php artisan test --testsuite=Feature
php artisan test --testsuite=UnitContinuous Testing
# Watch mode - reruns tests on file changes
./vendor/bin/pest --watchExpectations API
Pest provides a fluent expectation API:
<?php
test('demonstrates expectation API', function () {
$user = User::factory()->create(['name' => 'John']);
// Basic expectations
expect($user->name)->toBe('John');
expect($user->email)->toBeString();
expect($user->id)->toBeInt();
expect($user->isActive())->toBeTrue();
// Type expectations
expect($user)->toBeInstanceOf(User::class);
expect([1, 2, 3])->toBeArray();
expect(null)->toBeNull();
// Collection expectations
expect([1, 2, 3])->toHaveCount(3);
expect(['a', 'b', 'c'])->toContain('b');
expect($user->posts)->toBeEmpty();
// String expectations
expect('hello world')->toContain('world');
expect('test@example.com')->toEndWith('.com');
expect('https://example.com')->toStartWith('https://');
// Number expectations
expect(10)->toBeGreaterThan(5);
expect(3)->toBeLessThan(10);
expect(5)->toBeBetween(1, 10);
// Negation
expect($user->name)->not->toBe('Jane');
expect($user->posts)->not->toBeEmpty();
});Database Testing
Refresh Database
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
// Automatically applied via tests/Pest.php
test('creates user in database', function () {
$user = User::factory()->create();
// Database assertions
$this->assertDatabaseHas('users', [
'email' => $user->email,
]);
expect(User::count())->toBe(1);
});Database Assertions
<?php
test('user deletion works', function () {
$user = User::factory()->create();
$user->delete();
// Assert record is missing
$this->assertDatabaseMissing('users', [
'id' => $user->id,
'deleted_at' => null,
]);
// Assert soft delete
$this->assertSoftDeleted('users', [
'id' => $user->id,
]);
});
test('database count assertions', function () {
User::factory()->count(5)->create();
$this->assertDatabaseCount('users', 5);
});Mocking and Fakes
Mocking Services
<?php
use App\Services\PaymentService;
use Mockery\MockInterface;
test('processes payment successfully', function () {
// Mock the payment service
$this->mock(PaymentService::class, function (MockInterface $mock) {
$mock->shouldReceive('charge')
->once()
->with(100.00)
->andReturn(true);
});
$service = app(PaymentService::class);
$result = $service->charge(100.00);
expect($result)->toBeTrue();
});Faking Facades
<?php
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Event;
use App\Mail\WelcomeEmail;
use App\Events\UserRegistered;
test('sends welcome email on registration', function () {
Mail::fake();
$user = User::factory()->create();
Mail::to($user)->send(new WelcomeEmail($user));
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
});
test('dispatches user registered event', function () {
Event::fake([UserRegistered::class]);
$user = User::factory()->create();
event(new UserRegistered($user));
Event::assertDispatched(UserRegistered::class, function ($event) use ($user) {
return $event->user->id === $user->id;
});
});
test('uploads file to storage', function () {
Storage::fake('public');
$file = UploadedFile::fake()->image('photo.jpg');
Storage::disk('public')->put('photos', $file);
Storage::disk('public')->assertExists('photos/' . $file->hashName());
});Test Organization
Directory Structure
tests/
├── Feature/
│ ├── Api/
│ │ ├── V1/
│ │ │ ├── UserTest.php
│ │ │ ├── PostTest.php
│ │ │ └── OrderTest.php
│ │ └── V2/
│ ├── Auth/
│ │ ├── LoginTest.php
│ │ └── RegisterTest.php
│ └── Admin/
│ └── DashboardTest.php
├── Unit/
│ ├── Services/
│ │ ├── UserServiceTest.php
│ │ └── PaymentServiceTest.php
│ ├── Models/
│ │ └── UserTest.php
│ └── Helpers/
│ └── StringHelperTest.php
├── Pest.php
└── TestCase.phpGrouping Tests
<?php
describe('User Management', function () {
test('can create user', function () {
// Test implementation
});
test('can update user', function () {
// Test implementation
});
test('can delete user', function () {
// Test implementation
});
});
describe('User Validation', function () {
test('requires email', function () {
// Test implementation
});
test('requires unique email', function () {
// Test implementation
});
});Code Coverage
Generate Coverage Report
# HTML coverage report
php artisan test --coverage-html=coverage
# Terminal coverage report
php artisan test --coverage
# Minimum coverage requirement
php artisan test --coverage --min=80
# Coverage for specific paths
php artisan test --coverage --min=80 --path=app/ServicesCoverage Configuration
<!-- phpunit.xml -->
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">app</directory>
</include>
<exclude>
<directory>app/Console</directory>
<file>app/Providers/RouteServiceProvider.php</file>
</exclude>
<report>
<html outputDirectory="coverage" />
</report>
</coverage>Best Practices
1. Test Naming
<?php
// Good - Descriptive test names
test('user can update their profile with valid data')
test('returns 404 when user not found')
test('validates required fields on registration')
// Bad - Vague test names
test('test profile')
test('user test')
test('validation')2. Arrange-Act-Assert Pattern
<?php
test('user can create a post', function () {
// Arrange - Setup
$user = User::factory()->create();
$data = ['title' => 'Test Post', 'content' => 'Content'];
// Act - Execute
$response = $this->actingAs($user)->postJson('/api/posts', $data);
// Assert - Verify
$response->assertStatus(201);
expect(Post::count())->toBe(1);
});3. One Assertion Focus
<?php
// Good - Focused tests
test('validates email is required', function () {
$response = $this->postJson('/api/register', [
'name' => 'John',
'password' => 'password123',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['email']);
});
test('validates email format', function () {
$response = $this->postJson('/api/register', [
'email' => 'not-an-email',
]);
$response->assertJsonValidationErrors(['email']);
});
// Avoid - Testing too many things
test('validates all registration fields', function () {
// Testing email, password, name all at once
});4. Use Factories
<?php
// Good - Use factories
test('creates user', function () {
$user = User::factory()->create();
expect($user)->toBeInstanceOf(User::class);
});
// Bad - Manual creation
test('creates user', function () {
$user = User::create([
'name' => 'John',
'email' => 'john@example.com',
'password' => bcrypt('password'),
// ... many more fields
]);
});5. Test Edge Cases
<?php
test('handles empty string', function () { /* ... */ });
test('handles null value', function () { /* ... */ });
test('handles maximum length', function () { /* ... */ });
test('handles special characters', function () { /* ... */ });
test('handles concurrent requests', function () { /* ... */ });Continuous Integration
GitHub Actions Example
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: testing
MYSQL_ROOT_PASSWORD: password
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, mysql
coverage: xdebug
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Generate key
run: php artisan key:generate
- name: Run migrations
run: php artisan migrate --force
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: password
- name: Run tests
run: php artisan test --parallel --coverage --min=80
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: passwordCommon Testing Patterns
Testing Authentication
<?php
test('guest cannot access protected route', function () {
$response = $this->getJson('/api/profile');
$response->assertStatus(401);
});
test('authenticated user can access protected route', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->getJson('/api/profile');
$response->assertStatus(200);
});Testing Authorization
<?php
test('user cannot delete others posts', function () {
$user = User::factory()->create();
$post = Post::factory()->create(); // Different author
$response = $this->actingAs($user)
->deleteJson("/api/posts/{$post->id}");
$response->assertStatus(403);
});Testing Validation
<?php
test('validates required fields', function (array $data, array $errors) {
$response = $this->postJson('/api/users', $data);
$response->assertStatus(422)
->assertJsonValidationErrors($errors);
})->with([
[[], ['name', 'email', 'password']],
[['name' => 'John'], ['email', 'password']],
[['email' => 'john@example.com'], ['name', 'password']],
]);Testing Tools
Useful Packages
# Pest plugins
composer require pestphp/pest-plugin-faker --dev
composer require pestphp/pest-plugin-watch --dev
# Testing utilities
composer require laravel/sanctum --dev # API authentication testing
composer require spatie/laravel-ray --dev # DebuggingRelated Documentation
- Unit Testing - Detailed unit testing guide
- Feature Testing - Feature testing patterns
- API Testing - API endpoint testing
- Best Practices - General coding best practices
Quick Reference
Common Commands
# Run tests
php artisan test
# Run with coverage
php artisan test --coverage --min=80
# Run specific test
php artisan test --filter="test name"
# Run in parallel
php artisan test --parallel
# Watch mode
./vendor/bin/pest --watchCommon Assertions
expect($value)->toBe($expected)
expect($value)->toBeTrue()
expect($value)->toBeNull()
expect($array)->toHaveCount(5)
expect($string)->toContain('substring')
$this->assertDatabaseHas('users', ['email' => 'test@example.com'])
$response->assertStatus(200)
$response->assertJson(['key' => 'value'])