writing-tests

Use when writing tests, creating test files, naming test functions, choosing between Feature and Unit tests, using factories, mocking dependencies, or asserting HTTP responses in a Laravel DDD project.

The Rule

Every endpoint gets a Feature test. Every use case gets a Unit test. No code ships without tests that prove it works. Tests are not optional, not "nice to have", not "we'll add them later." They are part of the deliverable.


Framework & Tooling

ToolVersionPurpose
Pest PHP^3.0Primary test framework (wrapper over PHPUnit)
pest-plugin-laravelLaravel-specific assertions and helpers
pest-plugin-fakerFaker integration via $this->faker
Mockery^1.6Mock objects and spies
Larastan^2.9Static analysis (PHPStan level 5)
Laravel Pint^1.13Code formatting (PSR-12)

Run tests: vendor/bin/pest Run static analysis: vendor/bin/phpstan analyse Run formatter: vendor/bin/pint


Test Directory Structure

tests/
├── Pest.php                              # Pest configuration
├── TestCase.php                          # Base test class
├── Feature/
│   ├── {BoundedContext}/
│   │   └── {Module}/
│   │       ├── Create{Entity}Test.php
│   │       ├── Update{Entity}Test.php
│   │       ├── Delete{Entity}Test.php
│   │       ├── List{Entity}Test.php
│   │       └── Search{Entity}Test.php
└── Unit/
    ├── {BoundedContext}/
    │   └── {Module}/
    │       ├── Create{Entity}Test.php
    │       ├── Find{Entity}Test.php
    │       ├── Update{Entity}Test.php
    │       ├── Delete{Entity}Test.php
    │       └── {Entity}Test.php

Tests mirror the domain structure — organized by bounded context and module, not by technical concern.


Feature vs Unit — Where Does This Test Go?

What You're TestingTypeDirectory
HTTP endpoint (controller + request + response)Featuretests/Feature/
Full request cycle with databaseFeaturetests/Feature/
Authentication and authorization flowFeaturetests/Feature/
Inertia page renderingFeaturetests/Feature/
API JSON response structureFeaturetests/Feature/
Use case logic (isolated)Unittests/Unit/
Domain entity behavior methodsUnittests/Unit/
Value object validationUnittests/Unit/
State transitionsUnittests/Unit/
Repository query logicUnittests/Unit/
Domain event registrationUnittests/Unit/

The line is simple: If it touches HTTP or the database, it's Feature. If it tests isolated logic, it's Unit.


Test Naming

File Naming

One test file per subject. File name matches the action being tested:

ArtifactTest File
CreateContactControllerCreateContactTest.php
DeleteContact (use case)DeleteContactTest.php
Contact (entity methods)ContactTest.php
EloquentContactRepositoryContactRepositoryTest.php

Method Naming

Use Pest's it() syntax or test_ prefix. Methods describe what should happen:

// Pest it() syntax
it('creates a contact with valid data', function () { });
it('throws ContactNotFound when id does not exist', function () { });
it('returns 422 when email is missing', function () { });
it('deletes the contact and redirects', function () { });
it('does not delete when user is not owner', function () { });

// Alternative: test_ prefix
test('it creates a contact with valid data', function () { });

Rules:

  • Describe the expected behavior, not the implementation
  • Include the failure scenario in the name when testing negative cases
  • Stay consistent within a project — pick one style and use it everywhere

Feature Test Patterns

Web Endpoint (Inertia)

use App\CustomerRelationshipManagement\Contacts\Domain\Contact;
use App\IdentityAndAccess\Users\Domain\User;

use function Pest\Laravel\actingAs;

it('displays the contact list page', function () {
    $user = User::factory()->create();
    Contact::factory()->count(3)->create();

    actingAs($user)
        ->get('/contacts')
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->component('Contacts/Index')
            ->has('contacts', 3)
        );
});

Web Endpoint (Create + Redirect)

it('creates a contact and redirects to show page', function () {
    $user = User::factory()->create();

    actingAs($user)
        ->post('/contacts', [
            'first_name' => 'John',
            'last_name' => 'Doe',
            'email' => '[email protected]',
        ])
        ->assertRedirect();

    $this->assertDatabaseHas('crm_contacts', [
        'email' => '[email protected]',
    ]);
});

Web Endpoint (Validation Failure)

it('returns validation errors when email is missing', function () {
    $user = User::factory()->create();

    actingAs($user)
        ->post('/contacts', [
            'first_name' => 'John',
        ])
        ->assertSessionHasErrors(['email']);
});

Web Endpoint (Delete)

it('deletes the contact and redirects', function () {
    $user = User::factory()->create();
    $contact = Contact::factory()->create();

    actingAs($user)
        ->delete("/contacts/{$contact->id}")
        ->assertRedirect('/contacts');

    $this->assertDatabaseMissing('crm_contacts', [
        'id' => $contact->id,
    ]);
});

Web Endpoint (Unauthorized)

it('redirects unauthenticated users to login', function () {
    $this->get('/contacts')
        ->assertRedirect('/login');
});

API Endpoint (JSON)

use function Pest\Laravel\actingAs;

it('returns contacts matching search criteria', function () {
    $user = User::factory()->create();
    Contact::factory()->count(5)->create();

    actingAs($user)
        ->postJson('/api/contacts/search', [
            'filters' => [
                ['field' => 'email', 'operator' => 'CONTAINS', 'value' => 'example'],
            ],
            'perPage' => 10,
        ])
        ->assertOk()
        ->assertJsonStructure([
            'data' => [['id', 'first_name', 'last_name', 'email']],
        ]);
});

Unit Test Patterns

Use Case — Create

use App\CustomerRelationshipManagement\Contacts\Application\CreateContact;
use App\CustomerRelationshipManagement\Contacts\Domain\Contact;
use App\CustomerRelationshipManagement\Contacts\Domain\Contracts\ContactRepository;
use App\Shared\Domain\Contracts\ServiceBus\EventBus;

it('creates and persists a contact', function () {
    $repository = Mockery::mock(ContactRepository::class);
    $eventBus = Mockery::mock(EventBus::class);

    $repository->shouldReceive('save')
        ->once()
        ->andReturnUsing(fn (Contact $c) => $c);

    $eventBus->shouldReceive('publish')->once();

    $creator = new CreateContact($repository, $eventBus);
    $contact = $creator->create([
        'first_name' => 'John',
        'last_name' => 'Doe',
        'email' => '[email protected]',
    ]);

    expect($contact)->toBeInstanceOf(Contact::class);
});

Use Case — Find (Success)

use App\CustomerRelationshipManagement\Contacts\Application\FindContact;
use App\CustomerRelationshipManagement\Contacts\Domain\Contact;
use App\CustomerRelationshipManagement\Contacts\Domain\Contracts\ContactRepository;

it('returns the contact when found', function () {
    $contact = Contact::factory()->make(['id' => 'uuid-123']);
    $repository = Mockery::mock(ContactRepository::class);

    $repository->shouldReceive('find')
        ->with('uuid-123')
        ->andReturn($contact);

    $finder = new FindContact($repository);

    expect($finder->byId('uuid-123'))->toBe($contact);
});

Use Case — Find (Not Found)

use App\CustomerRelationshipManagement\Contacts\Application\FindContact;
use App\CustomerRelationshipManagement\Contacts\Domain\Contracts\ContactRepository;
use App\CustomerRelationshipManagement\Contacts\Domain\Exceptions\ContactNotFound;

it('throws ContactNotFound when contact does not exist', function () {
    $repository = Mockery::mock(ContactRepository::class);

    $repository->shouldReceive('find')
        ->with('missing-id')
        ->andReturnNull();

    $finder = new FindContact($repository);
    $finder->byId('missing-id');
})->throws(ContactNotFound::class);

Use Case — Delete (Idempotent)

it('silently returns when contact does not exist', function () {
    $repository = Mockery::mock(ContactRepository::class);
    $eventBus = Mockery::mock(EventBus::class);

    $repository->shouldReceive('find')
        ->with('missing-id')
        ->andReturnNull();

    $repository->shouldNotReceive('delete');
    $eventBus->shouldNotReceive('publish');

    $deleter = new DeleteContact($repository, $eventBus);
    $deleter->delete('missing-id');
});

Entity — Domain Behavior

use App\CustomerRelationshipManagement\Contacts\Domain\Contact;
use App\CustomerRelationshipManagement\Contacts\Domain\Events\ContactNameUpdated;
use App\Shared\Domain\Contracts\ServiceBus\EventBus;

it('registers a name updated event when name changes', function () {
    $contact = Contact::factory()->make([
        'first_name' => 'John',
        'last_name' => 'Doe',
    ]);

    $contact->updateName('Jane', 'Smith');

    $eventBus = Mockery::mock(EventBus::class);
    $eventBus->shouldReceive('publish')
        ->once()
        ->withArgs(fn ($event) => $event instanceof ContactNameUpdated);

    $contact->publishDomainEvents($eventBus);
});

Value Object — Validation

use App\Shared\Domain\ValueObjects\ABN;
use App\Shared\Domain\Exceptions\InvalidABNException;

it('creates a valid ABN', function () {
    $abn = new ABN('51 824 753 556');

    expect($abn->value())->toBe('51824753556');
});

it('throws on invalid ABN format', function () {
    new ABN('invalid');
})->throws(InvalidABNException::class);

Database Handling

RefreshDatabase (Default)

// tests/Pest.php
uses(Illuminate\Foundation\Testing\RefreshDatabase::class)->in('Feature');

Applied globally to all Feature tests via Pest.php. Runs migrations before each test and rolls back after.

In-Memory SQLite (Fast)

<!-- phpunit.xml -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

Mocking Strategy

Prefer Laravel Fakes Over Mockery

For framework services, use Laravel's built-in fakes:

// Events
Event::fake();
// ... execute code ...
Event::assertDispatched(ContactCreated::class);
Event::assertNotDispatched(ContactDeleted::class);

// Mail
Mail::fake();
// ... execute code ...
Mail::assertSent(UserInvitationMail::class, fn ($mail) =>
    $mail->hasTo('[email protected]')
);

// Queue
Queue::fake();
// ... execute code ...
Queue::assertPushed(SendEmailVerification::class);

// Storage
Storage::fake('documents');
// ... execute code ...
Storage::disk('documents')->assertExists('file.pdf');

Use Mockery For Domain Contracts

For repository and event bus contracts in Unit tests:

$repository = Mockery::mock(ContactRepository::class);
$repository->shouldReceive('find')->with('id')->andReturn($contact);
$repository->shouldReceive('save')->once()->andReturnUsing(fn ($c) => $c);

$eventBus = Mockery::mock(EventBus::class);
$eventBus->shouldReceive('publish')->once();

Never Mock What You Don't Own

  • Mock contracts (interfaces) — ContactRepository, EventBus
  • Never mock concrete classes (Eloquent models, Laravel internals)
  • Never mock the class under test

Factory Usage in Tests

Creating Entities

// Single entity
$contact = Contact::factory()->create();

// With specific attributes
$contact = Contact::factory()->create([
    'email' => '[email protected]',
]);

// Multiple entities
$contacts = Contact::factory()->count(5)->create();

// With state
$visa = Visa::factory()->granted()->create();

// In-memory only (no database)
$contact = Contact::factory()->make();

Relationship Factories

// Entity with relationship
$contact = Contact::factory()
    ->for(Account::factory())
    ->create();

// Entity with multiple related entities
$account = Account::factory()
    ->has(Contact::factory()->count(3))
    ->create();

Factories extend EloquentFactory which calls Model::new() — domain events are registered automatically. See implementing-factories for the full factory setup.


Pest Configuration

tests/Pest.php

<?php

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

uses(TestCase::class)->in('Feature', 'Unit');
uses(RefreshDatabase::class)->in('Feature');

Pest Expectations (Prefer Over PHPUnit Assertions)

// Pest expect() syntax — preferred
expect($contact)->toBeInstanceOf(Contact::class);
expect($contact->email)->toBe('[email protected]');
expect($contacts)->toHaveCount(3);
expect($result)->toBeNull();
expect($result)->not->toBeNull();
expect(fn () => $finder->byId('x'))->toThrow(ContactNotFound::class);

// PHPUnit assertions — acceptable but less idiomatic in Pest
$this->assertInstanceOf(Contact::class, $contact);
$this->assertEquals('[email protected]', $contact->email);
$this->assertDatabaseHas('crm_contacts', ['id' => $contact->id]);

Use expect() for value assertions. Use $this->assert* for database and HTTP assertions (which don't have Pest equivalents).


What to Test — Minimum Coverage

For Every Controller (Feature Tests)

  • Successful action (happy path)
  • Validation failure (missing/invalid input)
  • Unauthenticated access (redirects to login)
  • Unauthorized access (forbidden when not owner)
  • Not-found response (invalid ID)

For Every Use Case (Unit Tests)

  • Successful execution (happy path)
  • Not-found exception (for find/update/delete)
  • Validation failure (invalid input)
  • Domain events published after persistence
  • Idempotent deletion (no exception on missing entity)

For Every Entity (Unit Tests)

  • Domain behavior methods register correct events
  • static new() registers Created event
  • toBeDeleted() registers Deleted event
  • State transitions follow allowed paths
  • Invalid transitions throw domain exceptions

Common Mistakes

MistakeFix
No tests for an endpointEvery endpoint gets a Feature test — no exceptions
Testing implementation details instead of behaviorTest WHAT happens, not HOW it happens internally
Using Mockery::mock(ConcreteClass::class)Mock interfaces (contracts), not concrete classes
Missing validation failure testsAlways test that invalid input returns errors
Missing authentication testAlways test unauthenticated access redirects
Sharing state between testsEach test creates its own data — no shared fixtures
Testing private methods directlyTest through public API; private methods are implementation
Skipping domain event assertions in unit testsAlways verify events are registered after domain operations
Using $this->withoutExceptionHandling() everywhereOnly use when debugging — remove before committing

Red Flags — STOP and Fix

  • A controller with no corresponding Feature test file
  • A use case with no corresponding Unit test file
  • A test that passes without any assertions
  • A test name that doesn't describe the expected behavior
  • Mockery used on a concrete Eloquent model
  • RefreshDatabase used in a Unit test (it shouldn't touch the database)
  • Tests that depend on execution order

Pre-flight Checklist

  • Every controller has a Feature test?
  • Every use case has a Unit test?
  • Tests use Pest syntax (it() or test())?
  • Feature tests use actingAs() for authentication?
  • Unit tests mock contracts (interfaces), not concrete classes?
  • Database assertions use assertDatabaseHas / assertDatabaseMissing?
  • Domain event assertions verify correct events are registered?
  • Validation failure tests check assertSessionHasErrors or 422 response?
  • Test files mirror the bounded context directory structure?
  • RefreshDatabase applied to Feature tests only (via Pest.php)?

Laravel Reference

This skill uses Laravel-specific features. Consult the official Laravel documentation (via Context7 or web search) for details on:

  • Pest PHP — testing framework (pestphp.com)
  • Illuminate\Foundation\Testing\RefreshDatabase — database reset per test
  • actingAs() — authenticate a user for testing
  • assertInertia() — Inertia.js response assertions
  • assertJson(), assertJsonStructure() — JSON API response assertions
  • assertDatabaseHas(), assertDatabaseMissing() — database state assertions
  • assertSessionHasErrors() — validation error assertions
  • Event::fake(), Mail::fake(), Queue::fake() — Laravel service fakes
  • Mockery — mock object framework (mockery.io)