Skip to main content

Overview

Laravel Breeze API uses Pest PHP for testing. Pest provides an elegant testing syntax built on top of PHPUnit, making tests more readable and easier to write.

Test Configuration

PHPUnit Configuration

File: 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>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="BROADCAST_CONNECTION" value="null"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="PULSE_ENABLED" value="false"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <env name="NIGHTWATCH_ENABLED" value="false"/>
    </php>
</phpunit>
Key Testing Settings:
  • Database: SQLite in-memory for fast, isolated tests
  • Cache: Array driver (no persistence)
  • Mail: Array driver (captures emails without sending)
  • Queue: Sync driver (processes jobs immediately)
  • Session: Array driver (in-memory sessions)
  • Bcrypt Rounds: Reduced to 4 for faster password hashing

Running Tests

Run All Tests

php artisan test

Run with Composer

composer test
This executes:
php artisan config:clear --ansi
php artisan test

Run Specific Test File

php artisan test tests/Feature/Auth/AuthenticationTest.php

Run Specific Test

php artisan test --filter="users can authenticate"

Run with Coverage

php artisan test --coverage

Run in Parallel

php artisan test --parallel

Test Structure

Test Directories

tests/
├── Feature/
│   ├── Auth/
│   │   ├── AuthenticationTest.php
│   │   ├── EmailVerificationTest.php
│   │   ├── PasswordResetTest.php
│   │   └── RegistrationTest.php
│   └── ExampleTest.php
└── Unit/
    └── ExampleTest.php
  • Feature Tests: Test complete features and API endpoints
  • Unit Tests: Test individual classes and methods

Authentication Tests

AuthenticationTest

File: tests/Feature/Auth/AuthenticationTest.php
use App\Models\User;

test('users can authenticate using the login screen', function () {
    $user = User::factory()->create();

    $response = $this->post('/login', [
        'email' => $user->email,
        'password' => 'password',
    ]);

    $this->assertAuthenticated();
    $response->assertNoContent();
});

test('users can not authenticate with invalid password', function () {
    $user = User::factory()->create();

    $this->post('/login', [
        'email' => $user->email,
        'password' => 'wrong-password',
    ]);

    $this->assertGuest();
});

test('users can logout', function () {
    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/logout');

    $this->assertGuest();
    $response->assertNoContent();
});
What It Tests:
  • Login with valid credentials
  • Login rejection with invalid password
  • Logout functionality
  • Session management

RegistrationTest

File: tests/Feature/Auth/RegistrationTest.php
test('new users can register', function () {
    $response = $this->post('/register', [
        'name' => 'Test User',
        'email' => '[email protected]',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);

    $this->assertAuthenticated();
    $response->assertNoContent();
});
What It Tests:
  • User registration
  • Automatic login after registration
  • Password confirmation validation

Pest Testing Syntax

Basic Test Structure

test('description of what is being tested', function () {
    // Arrange: Set up test data
    $user = User::factory()->create();
    
    // Act: Perform the action
    $response = $this->post('/login', [
        'email' => $user->email,
        'password' => 'password',
    ]);
    
    // Assert: Verify the outcome
    $this->assertAuthenticated();
    $response->assertNoContent();
});

Using Expect (Pest Style)

test('user has correct attributes', function () {
    $user = User::factory()->create([
        'name' => 'John Doe',
        'email' => '[email protected]',
    ]);
    
    expect($user->name)->toBe('John Doe')
        ->and($user->email)->toBe('[email protected]')
        ->and($user->email_verified_at)->toBeNull();
});

Common Assertions

Authentication Assertions

// User is authenticated
$this->assertAuthenticated();

// User is not authenticated
$this->assertGuest();

// Specific user is authenticated
$this->assertAuthenticatedAs($user);

Response Assertions

// Status code assertions
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertUnauthorized(); // 401
$response->assertForbidden(); // 403
$response->assertNotFound(); // 404
$response->assertUnprocessable(); // 422

// JSON structure assertions
$response->assertJson([
    'status' => 'verification-link-sent'
]);

// JSON validation error assertions
$response->assertJsonValidationErrors(['email']);

Database Assertions

// Record exists in database
$this->assertDatabaseHas('users', [
    'email' => '[email protected]'
]);

// Record does not exist
$this->assertDatabaseMissing('users', [
    'email' => '[email protected]'
]);

// Count records
$this->assertDatabaseCount('users', 1);

Writing Custom Tests

Feature Test Example

test('authenticated users can access profile', function () {
    $user = User::factory()->create();
    
    $response = $this->actingAs($user)
        ->get('/api/user');
    
    $response->assertOk()
        ->assertJson([
            'id' => $user->id,
            'name' => $user->name,
            'email' => $user->email,
        ]);
});

Unit Test Example

test('user password is hashed', function () {
    $user = User::factory()->create([
        'password' => 'plain-password'
    ]);
    
    expect($user->password)
        ->not->toBe('plain-password')
        ->and(Hash::check('plain-password', $user->password))->toBeTrue();
});

Testing Email

Email Verification Test

use Illuminate\Support\Facades\Mail;
use Illuminate\Auth\Notifications\VerifyEmail;

test('email verification notification is sent', function () {
    Mail::fake();
    
    $user = User::factory()->unverified()->create();
    
    $user->sendEmailVerificationNotification();
    
    Mail::assertSent(VerifyEmail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});

Password Reset Email Test

use Illuminate\Support\Facades\Notification;
use Illuminate\Auth\Notifications\ResetPassword;

test('password reset link is sent', function () {
    Notification::fake();
    
    $user = User::factory()->create();
    
    $this->post('/forgot-password', [
        'email' => $user->email
    ]);
    
    Notification::assertSentTo($user, ResetPassword::class);
});

Testing Validation

test('registration requires valid email', function () {
    $response = $this->post('/register', [
        'name' => 'Test User',
        'email' => 'invalid-email',
        'password' => 'password',
        'password_confirmation' => 'password',
    ]);
    
    $response->assertUnprocessable()
        ->assertJsonValidationErrors(['email']);
});

test('registration requires password confirmation', function () {
    $response = $this->post('/register', [
        'name' => 'Test User',
        'email' => '[email protected]',
        'password' => 'password',
        'password_confirmation' => 'different-password',
    ]);
    
    $response->assertUnprocessable()
        ->assertJsonValidationErrors(['password']);
});

Testing Rate Limiting

use Illuminate\Support\Facades\RateLimiter;

test('login is rate limited after 5 attempts', function () {
    $user = User::factory()->create();
    
    // Make 5 failed attempts
    for ($i = 0; $i < 5; $i++) {
        $this->post('/login', [
            'email' => $user->email,
            'password' => 'wrong-password',
        ]);
    }
    
    // 6th attempt should be rate limited
    $response = $this->post('/login', [
        'email' => $user->email,
        'password' => 'wrong-password',
    ]);
    
    $response->assertUnprocessable()
        ->assertJsonValidationErrors(['email']);
    
    expect($response->json('errors.email.0'))
        ->toContain('Too many login attempts');
});

Testing API with CSRF

test('api requests require csrf token', function () {
    $user = User::factory()->create();
    
    // Get CSRF token
    $this->get('/sanctum/csrf-cookie');
    
    // Make authenticated request
    $response = $this->actingAs($user)
        ->get('/api/user');
    
    $response->assertOk();
});

Best Practices

Use Factories

Always use factories to create test data:
// Good
$user = User::factory()->create();

// Avoid
$user = User::create([
    'name' => 'Test',
    'email' => '[email protected]',
    'password' => Hash::make('password'),
]);

Keep Tests Isolated

Each test should be independent:
test('example test', function () {
    // Create fresh data
    $user = User::factory()->create();
    
    // Don't rely on data from other tests
});

Test One Thing

Each test should verify one specific behavior:
// Good: One assertion
test('login returns no content response', function () {
    $user = User::factory()->create();
    
    $response = $this->post('/login', [
        'email' => $user->email,
        'password' => 'password',
    ]);
    
    $response->assertNoContent();
});

// Good: Related assertions
test('user is authenticated after login', function () {
    $user = User::factory()->create();
    
    $this->post('/login', [
        'email' => $user->email,
        'password' => 'password',
    ]);
    
    $this->assertAuthenticated();
    $this->assertAuthenticatedAs($user);
});

Use Descriptive Test Names

// Good
test('users can authenticate with valid credentials', function () {
    // ...
});

// Bad
test('test login', function () {
    // ...
});

Continuous Integration

Add to your CI pipeline:
.github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          extensions: mbstring, pdo_sqlite
      
      - name: Install dependencies
        run: composer install --prefer-dist --no-progress
      
      - name: Run tests
        run: php artisan test

Next Steps

API Routes

Review API endpoints to test

Controllers

Understand controller logic

Build docs developers (and LLMs) love