Skip to content

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

bash
# 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 --dev

Configuration

Pest is configured via phpunit.xml and tests/Pest.php:

phpunit.xml

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

bash
# 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=Unit

Continuous Testing

bash
# Watch mode - reruns tests on file changes
./vendor/bin/pest --watch

Expectations API

Pest provides a fluent expectation API:

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

Grouping Tests

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

bash
# 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/Services

Coverage Configuration

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

yaml
# .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: password

Common Testing Patterns

Testing Authentication

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

bash
# 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  # Debugging


Quick Reference

Common Commands

bash
# 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 --watch

Common Assertions

php
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'])

CPR - Clinical Patient Records