laravel-tdd

Laravel testing strategies with PHPUnit, Pest, model factories, HTTP tests, Sanctum authentication testing, mocking, and coverage.

INSTALLATION
npx skills add https://github.com/affaan-m/ecc --skill laravel-tdd
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

Laravel Testing with TDD

Test-driven development for Laravel applications using PHPUnit, Pest, Laravel factories, and testing helpers.

When to Activate

  • Writing new Laravel applications or features
  • Implementing API endpoints with Sanctum or Passport authentication
  • Testing Eloquent models, relationships, scopes, and accessors
  • Setting up testing infrastructure for Laravel projects
  • Writing feature tests for HTTP controllers and form requests
  • Mocking external services (queues, mail, notifications, HTTP)

TDD Workflow for Laravel

Red-Green-Refactor Cycle

// Step 1: RED — Write a failing test

public function test_a_product_can_be_created(): void

{

    $product = Product::factory()->create(['name' => 'Test Product']);

    $this->assertDatabaseHas('products', ['name' => 'Test Product']);

}

// Step 2: GREEN — Write the migration, model, and factory

// Step 3: REFACTOR — Improve while keeping tests green

Setup

PHPUnit Configuration

<?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 suffix="Test.php">tests/Unit</directory>

        </testsuite>

        <testsuite name="Feature">

            <directory suffix="Test.php">tests/Feature</directory>

        </testsuite>

    </testsuites>

    <php>

        <env name="APP_ENV" value="testing"/>

        <env name="BCRYPT_ROUNDS" value="4"/>

        <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"/>

    </php>

</phpunit>

Base TestCase Setup

namespace Tests;

use Illuminate\Foundation\Testing\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase

{

    protected function setUp(): void

    {

        parent::setUp();

        // Call $this->withoutExceptionHandling() only in tests that

        // test non-HTTP exceptions; it suppresses assertStatus() etc.

    }

    // Helper: Authenticate and return user

    protected function actingAsUser(): mixed

    {

        $user = \App\Models\User::factory()->create();

        $this->actingAs($user);

        return $user;

    }

    protected function actingAsAdmin(): mixed

    {

        $admin = \App\Models\User::factory()->admin()->create();

        $this->actingAs($admin);

        return $admin;

    }

}

Model Factories

// database/factories/UserFactory.php

class UserFactory extends Factory

{

    protected static ?string $password = null;

    public function definition(): array

    {

        return [

            'name' => fake()->name(),

            'email' => fake()->unique()->safeEmail(),

            'email_verified_at' => now(),

            'password' => static::$password ??= Hash::make('password'),

            'remember_token' => Str::random(10),

            'role' => 'user',

        ];

    }

    public function admin(): static

    {

        return $this->state(fn (array $attributes) => ['role' => 'admin']);

    }

    public function unverified(): static

    {

        return $this->state(fn (array $attributes) => ['email_verified_at' => null]);

    }

}

// database/factories/ProductFactory.php

class ProductFactory extends Factory

{

    public function definition(): array

    {

        return [

            'name' => fake()->unique()->words(3, true),

            'slug' => fn (array $attrs) => Str::slug($attrs['name']),

            'description' => fake()->paragraph(),

            'price' => fake()->numberBetween(100, 100000),

            'stock' => fake()->numberBetween(0, 100),

            'is_active' => true,

            'user_id' => UserFactory::new(),

        ];

    }

    public function outOfStock(): static

    {

        return $this->state(fn (array $attributes) => ['stock' => 0]);

    }

}

Using Factories

$user = User::factory()->create();

$admin = User::factory()->admin()->create();

$product = Product::factory()->create(['user_id' => $user->id]);

$products = Product::factory()->count(10)->create();

$draft = Product::factory()->make(); // Not persisted

// With relationships

$user = User::factory()->has(Product::factory()->count(3))->create();

// Sequences

User::factory()->count(3)->sequence(

    ['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'],

)->create();

Model Testing

namespace Tests\Unit\Models;

use App\Models\User;

use App\Models\Product;

use Illuminate\Foundation\Testing\RefreshDatabase;

use Tests\TestCase;

class UserTest extends TestCase

{

    use RefreshDatabase;

    public function test_it_hides_sensitive_attributes(): void

    {

        $user = User::factory()->create();

        $this->assertArrayNotHasKey('password', $user->toArray());

    }

    public function test_admin_scope_returns_only_admins(): void

    {

        User::factory()->admin()->create();

        User::factory()->count(3)->create();

        $this->assertCount(1, User::admin()->get());

    }

}

class ProductTest extends TestCase

{

    use RefreshDatabase;

    public function test_active_scope_filters_correctly(): void

    {

        Product::factory()->count(3)->create(['is_active' => true]);

        Product::factory()->count(2)->create(['is_active' => false]);

        $this->assertCount(3, Product::active()->get());

    }

    public function test_it_belongs_to_a_user(): void

    {

        $user = User::factory()->create();

        $product = Product::factory()->create(['user_id' => $user->id]);

        $this->assertTrue($product->user->is($user));

    }

}

Feature / HTTP Testing

namespace Tests\Feature\Http\Controllers;

use App\Models\Product;

use App\Models\User;

use Illuminate\Foundation\Testing\RefreshDatabase;

use Tests\TestCase;

class ProductControllerTest extends TestCase

{

    use RefreshDatabase;

    public function test_guests_are_redirected_to_login(): void

    {

        $this->get(route('products.create'))->assertRedirect(route('login'));

    }

    public function test_it_stores_a_new_product(): void

    {

        $user = User::factory()->create();

        $this->actingAs($user);

        $response = $this->post(route('products.store'), [

            'name' => 'New Product',

            'description' => 'Description',

            'price' => 2999,

            'stock' => 10,

        ]);

        $response->assertRedirect(route('products.index'));

        $this->assertDatabaseHas('products', [

            'name' => 'New Product',

            'user_id' => $user->id,

        ]);

    }

    public function test_it_validates_required_fields(): void

    {

        $this->actingAs(User::factory()->create());

        $this->post(route('products.store'), [])

            ->assertSessionHasErrors(['name', 'price']);

    }

    public function test_users_cannot_modify_others_products(): void

    {

        $owner = User::factory()->create();

        $attacker = User::factory()->create();

        $product = Product::factory()->create(['user_id' => $owner->id]);

        $this->actingAs($attacker)

            ->delete(route('products.destroy', $product))

            ->assertForbidden();

    }

}

JSON API Testing

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\Product;

use App\Models\User;

use Illuminate\Foundation\Testing\RefreshDatabase;

use Tests\TestCase;

class ProductApiTest extends TestCase

{

    use RefreshDatabase;

    public function test_unauthenticated_requests_are_rejected(): void

    {

        $this->getJson('/api/products')->assertUnauthorized();

    }

    public function test_it_lists_paginated_products(): void

    {

        $user = User::factory()->create();

        Product::factory()->count(5)->create(['user_id' => $user->id]);

        $response = $this->actingAs($user)->getJson('/api/products');

        $response->assertOk();

        $response->assertJsonCount(5, 'data');

        $response->assertJsonStructure([

            'data' => [['id', 'name', 'price']],

            'meta' => ['current_page', 'last_page', 'total'],

        ]);

    }

    public function test_it_creates_a_product(): void

    {

        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/products', [

            'name' => 'API Product',

            'price' => 4999,

        ]);

        $response->assertCreated();

        $response->assertJsonPath('data.name', 'API Product');

    }

    public function test_users_cannot_delete_others_products(): void

    {

        $owner = User::factory()->create();

        $attacker = User::factory()->create();

        $product = Product::factory()->create(['user_id' => $owner->id]);

        $this->actingAs($attacker)

            ->deleteJson("/api/products/{$product->id}")

            ->assertForbidden();

    }

}

Sanctum API Auth Testing

namespace Tests\Feature\Http\Controllers\Api;

use App\Models\User;

use Illuminate\Foundation\Testing\RefreshDatabase;

use Illuminate\Support\Facades\Hash;

use Tests\TestCase;

class AuthControllerTest extends TestCase

{

    use RefreshDatabase;

    public function test_users_can_register(): void

    {

        $response = $this->postJson('/api/register', [

            'name' => 'Test User',

            'email' => 'test@example.com',

            'password' => 'Password123!',

            'password_confirmation' => 'Password123!',

        ]);

        $response->assertCreated();

        $response->assertJsonStructure(['data' => ['user', 'token']]);

    }

    public function test_users_can_login(): void

    {

        User::factory()->create([

            'email' => 'test@example.com',

            'password' => Hash::make('Password123!'),

        ]);

        $response = $this->postJson('/api/login', [

            'email' => 'test@example.com',

            'password' => 'Password123!',

        ]);

        $response->assertOk();

        $response->assertJsonStructure(['data' => ['token']]);

    }

    public function test_users_cannot_login_with_wrong_password(): void

    {

        User::factory()->create(['email' => 'test@example.com']);

        $this->postJson('/api/login', [

            'email' => 'test@example.com',

            'password' => 'wrong',

        ])->assertUnprocessable();

    }

    public function test_token_bearer_authenticates_requests(): void

    {

        $user = User::factory()->create();

        $token = $user->createToken('test')->plainTextToken;

        $this->withToken($token)

            ->getJson('/api/user')

            ->assertOk()

            ->assertJsonPath('data.email', $user->email);

    }

}

Mocking and Fakes

HTTP Fake

use Illuminate\Support\Facades\Http;

public function test_it_handles_successful_payment(): void

{

    Http::fake([

        'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),

    ]);

    $result = (new PaymentService())->charge(2999);

    $this->assertTrue($result->success);

}

public function test_it_handles_gateway_failure(): void

{

    Http::fake([

        'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402),

    ]);

    $this->expectException(PaymentFailedException::class);

    (new PaymentService())->charge(2999);

}

public function test_it_retries_on_timeout(): void

{

    Http::fake([

        'api.stripe.com/*' => Http::sequence()

            ->pushStatus(408)

            ->pushStatus(200),

    ]);

    $this->assertTrue((new PaymentService())->charge(2999)->success);

}

Mail Fake

Mail::fake();

$order->sendConfirmation();

Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {

    return $mail->hasTo($order->user->email);

});

Notification Fake

Notification::fake();

$user->notify(new WelcomeUser());

Notification::assertSentTo($user, WelcomeUser::class);

Queue Fake

Queue::fake();

ProcessImage::dispatch($product);

Queue::assertPushed(ProcessImage::class, function ($job) use ($product) {

    return $job->product->id === $product->id;

});

Storage Fake

Storage::fake('public');

$file = UploadedFile::fake()->image('photo.jpg', 200, 200);

$response = $this->actingAs($user)->post('/avatar', [

    'avatar' => $file,

]);

$response->assertSessionHasNoErrors();

Storage::disk('public')->assertExists('avatars/' . $file->hashName());

Event Fake

Event::fake();

$order->markAsShipped();

Event::assertDispatched(OrderShipped::class, function ($event) use ($order) {

    return $event->order->id === $order->id;

});

Artisan Command Tests

public function test_it_sends_newsletters(): void

{

    Mail::fake();

    User::factory()->count(5)->create(['subscribed' => true]);

    $this->artisan('newsletter:send')

        ->expectsOutput('Sending newsletter to 5 subscribers...')

        ->assertExitCode(0);

    Mail::assertSent(NewsletterMail::class, 5);

}

public function test_it_handles_no_subscribers(): void

{

    $this->artisan('newsletter:send')

        ->expectsOutput('No subscribers found.')

        ->assertExitCode(0);

}

Authorization Tests

public function test_users_can_update_own_posts(): void

{

    $user = User::factory()->create();

    $post = Post::factory()->create(['user_id' => $user->id]);

    $this->actingAs($user)

        ->put(route('posts.update', $post), ['title' => 'Updated'])

        ->assertRedirect();

}

public function test_users_cannot_update_others_posts(): void

{

    $post = Post::factory()->create();

    $this->actingAs(User::factory()->create())

        ->put(route('posts.update', $post), ['title' => 'Hacked'])

        ->assertForbidden();

}

public function test_gate_before_grants_super_admin_full_access(): void

{

    $super = User::factory()->create(['role' => 'super-admin']);

    $post = Post::factory()->create();

    $this->actingAs($super)

        ->delete(route('posts.destroy', $post))

        ->assertRedirect();

    $this->assertSoftDeleted($post);

}

Pest Feature Tests

<?php

use App\Models\Product;

use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {

    $this->user = User::factory()->create();

    $this->actingAs($this->user);

});

it('lists products', function () {

    Product::factory()->count(3)->create(['user_id' => $this->user->id]);

    $this->get(route('products.index'))

        ->assertOk()

        ->assertViewHas('products');

});

it('creates a product with valid data', function () {

    $this->post(route('products.store'), [

        'name' => 'Test Product', 'price' => 1999,

    ])->assertRedirect();

    $this->assertDatabaseHas('products', ['name' => 'Test Product']);

});

it('fails validation without required fields', function () {

    $this->post(route('products.store'), [])

        ->assertSessionHasErrors(['name', 'price']);

});

it('authorizes updates', function () {

    $other = User::factory()->create();

    $product = Product::factory()->create(['user_id' => $other->id]);

    $this->put(route('products.update', $product), ['name' => 'Hacked'])

        ->assertForbidden();

});

Coverage

# PHPUnit (use clover output for CI threshold checks)

vendor/bin/phpunit --coverage-html coverage --coverage-clover clover.xml

# Pest (built-in threshold support)

vendor/bin/pest --coverage --min=80

Coverage Goals

ComponentTarget
Models95%+
Actions/Services90%+
Form Requests90%+
Controllers85%+
Policies95%+
Overall80%+

Testing Best Practices

DO

  • Use factories over manual create() calls
  • One logical assertion per test
  • Descriptive names: test_guests_cannot_create_products
  • Test edge cases and authorization boundaries
  • Mock external services with Http::fake(), Mail::fake()
  • Use RefreshDatabase for clean state

DON'T

  • Don't test Laravel internals (trust the framework)
  • Don't make tests dependent on each other
  • Don't over-mock — mock only service boundaries
  • Don't test private methods — test through the public interface
  • Don't couple tests to HTML structure

Quick Reference

PatternUsage
RefreshDatabaseReset database between tests
$this->actingAs($user)Authenticate as user
$this->withToken($token)Bearer token auth for APIs
Model::factory()->create()Create model with factory
Model::factory()->count(5)->create()Create multiple records
Http::fake([...])Mock HTTP calls
Mail::fake()Trap sent mail
Notification::fake()Trap sent notifications
Queue::fake()Trap queued jobs
Event::fake()Trap dispatched events
Storage::fake('public')Trap file operations
assertDatabaseHasAssert DB row exists
assertSoftDeletedAssert soft-delete
assertSessionHasErrorsAssert validation errors
assertForbiddenAssert 403 status

Related Skills

  • laravel-patterns — Laravel architecture, Eloquent, routing, and API patterns
  • laravel-security — Laravel authentication, authorization, and secure coding
  • tdd-workflow — The repo-wide RED -> GREEN -> REFACTOR loop
  • backend-patterns — General backend API and database patterns
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card