Testing

Laravel Testing: Feature and Unit Tests

Mayur Dabhi
Mayur Dabhi
May 21, 2026
15 min read

Writing tests for your Laravel applications is one of the most impactful investments you can make as a developer. A well-tested codebase catches regressions before they reach production, documents intended behavior, and gives you the confidence to refactor without fear. Laravel ships with PHPUnit out of the box and layers on top of it a rich set of testing helpers that make both unit tests and full HTTP feature tests a pleasure to write.

In this guide, we'll walk through setting up your testing environment, writing unit and feature tests, using model factories for realistic test data, mocking services, and organizing your test suite to scale with your application. Whether you're new to automated testing or looking to level up your existing Laravel tests, you'll leave with practical patterns you can apply immediately.

Why Invest in Tests?

Studies show that well-tested applications have up to 40% fewer production bugs. Laravel's testing tools — including HTTP test helpers, database factories, and service fakes — drastically reduce the cost of writing tests so that confidence is always worth the time. The Red-Green-Refactor loop of TDD also tends to produce simpler, more modular designs.

Setting Up Your Testing Environment

Laravel includes PHPUnit by default, but a few configuration steps ensure your tests run in a safe, isolated environment separate from your development database.

Configuring phpunit.xml

The root phpunit.xml file controls how PHPUnit discovers and runs your tests. The most important section is the environment variables block, which overrides your .env for the test run:

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"
         stopOnFailure="false"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>

    <source>
        <include>
            <directory>app</directory>
        </include>
    </source>
</phpunit>

Using an in-memory SQLite database (:memory:) makes tests blazing fast because no disk I/O is involved and the database is automatically wiped between test runs.

1

Install PHPUnit (already included)

Laravel ships with PHPUnit as a dev dependency. Confirm with ./vendor/bin/phpunit --version.

2

Configure the test database

Set DB_DATABASE=:memory: and DB_CONNECTION=sqlite in phpunit.xml so tests never touch your real database.

3

Generate a test class

Run php artisan make:test UserTest for feature tests or php artisan make:test UserTest --unit for unit tests.

4

Run the test suite

Execute all tests with php artisan test (Laravel's wrapper) or ./vendor/bin/phpunit. Add --filter MethodName to run a single test.

Unit Tests — Testing Logic in Isolation

Unit tests target a single class or method without touching the database, file system, or any external service. They are the fastest tests to run and are ideal for validating business logic in service classes, value objects, and helper functions.

What Makes a Good Unit Test?

Testing a Service Class

Consider an OrderPricingService that calculates a total with optional discounts. This is pure business logic with no database calls — a perfect candidate for a unit test.

app/Services/OrderPricingService.php
<?php

namespace App\Services;

class OrderPricingService
{
    public function calculate(array $items, float $discountPercent = 0): array
    {
        $subtotal = array_sum(array_column($items, 'price'));
        $discount = $subtotal * ($discountPercent / 100);
        $total    = $subtotal - $discount;

        return [
            'subtotal' => round($subtotal, 2),
            'discount' => round($discount, 2),
            'total'    => round($total, 2),
        ];
    }
}
tests/Unit/OrderPricingServiceTest.php
<?php

namespace Tests\Unit;

use App\Services\OrderPricingService;
use PHPUnit\Framework\TestCase;

class OrderPricingServiceTest extends TestCase
{
    private OrderPricingService $service;

    protected function setUp(): void
    {
        parent::setUp();
        $this->service = new OrderPricingService();
    }

    /** @test */
    public function it_calculates_subtotal_correctly(): void
    {
        $items = [
            ['name' => 'Widget', 'price' => 10.00],
            ['name' => 'Gadget', 'price' => 25.50],
        ];

        $result = $this->service->calculate($items);

        $this->assertEquals(35.50, $result['subtotal']);
        $this->assertEquals(0.00,  $result['discount']);
        $this->assertEquals(35.50, $result['total']);
    }

    /** @test */
    public function it_applies_percentage_discount(): void
    {
        $items = [['name' => 'Widget', 'price' => 100.00]];

        $result = $this->service->calculate($items, 20);

        $this->assertEquals(100.00, $result['subtotal']);
        $this->assertEquals(20.00,  $result['discount']);
        $this->assertEquals(80.00,  $result['total']);
    }

    /** @test */
    public function it_handles_empty_cart(): void
    {
        $result = $this->service->calculate([]);

        $this->assertEquals(0.00, $result['total']);
    }
}

Notice that this test extends PHPUnit\Framework\TestCase, not Laravel's Tests\TestCase. This keeps the test pure — no Laravel application is booted, no database connection opened. The tests run almost instantly.

Unit Test Arrange / Act / Assert Class Under Test Pure business logic Mocked Dependency (DB / Mail / API) Assertions assertEquals / assertTrue PASS Unit Test Execution: no database, no HTTP, millisecond speed

Unit test execution flow — dependencies are mocked, not real

Feature Tests — Testing HTTP Endpoints

Feature tests in Laravel simulate real HTTP requests hitting your application. They boot the entire framework, run middleware, execute controllers, interact with the database (via the test connection), and return a response you can assert against. They're slower than unit tests but give you end-to-end confidence that a user journey works correctly.

Making HTTP Requests in Tests

Laravel's Tests\TestCase gives you methods like get(), post(), put(), and delete() that return a TestResponse object loaded with assertion helpers.

tests/Feature/UserRegistrationTest.php
<?php

namespace Tests\Feature;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class UserRegistrationTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function a_user_can_register_with_valid_data(): void
    {
        $response = $this->post('/register', [
            'name'                  => 'Jane Doe',
            'email'                 => 'jane@example.com',
            'password'              => 'password123',
            'password_confirmation' => 'password123',
        ]);

        $response->assertRedirect('/dashboard');
        $this->assertDatabaseHas('users', ['email' => 'jane@example.com']);
        $this->assertCount(1, User::all());
    }

    /** @test */
    public function registration_fails_with_duplicate_email(): void
    {
        User::factory()->create(['email' => 'jane@example.com']);

        $response = $this->post('/register', [
            'name'                  => 'Jane Duplicate',
            'email'                 => 'jane@example.com',
            'password'              => 'password123',
            'password_confirmation' => 'password123',
        ]);

        $response->assertSessionHasErrors('email');
        $this->assertCount(1, User::all());
    }

    /** @test */
    public function registration_requires_all_fields(): void
    {
        $response = $this->post('/register', []);

        $response->assertSessionHasErrors(['name', 'email', 'password']);
    }
}

Testing JSON APIs

For API endpoints that return JSON, swap HTML-oriented assertions for JSON-specific ones. Use actingAs() to authenticate as a specific user without going through the login flow.

tests/Feature/Api/PostApiTest.php
<?php

namespace Tests\Feature\Api;

use App\Models\Post;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostApiTest extends TestCase
{
    use RefreshDatabase;

    private User $user;

    protected function setUp(): void
    {
        parent::setUp();
        $this->user = User::factory()->create();
    }

    /** @test */
    public function authenticated_user_can_list_their_posts(): void
    {
        Post::factory()->count(3)->for($this->user)->create();
        Post::factory()->count(2)->create(); // another user's posts

        $response = $this->actingAs($this->user, 'sanctum')
            ->getJson('/api/posts');

        $response->assertOk()
            ->assertJsonCount(3, 'data')
            ->assertJsonStructure([
                'data' => [
                    '*' => ['id', 'title', 'body', 'created_at'],
                ],
            ]);
    }

    /** @test */
    public function user_can_create_a_post(): void
    {
        $response = $this->actingAs($this->user, 'sanctum')
            ->postJson('/api/posts', [
                'title' => 'My First Post',
                'body'  => 'This is the post content.',
            ]);

        $response->assertCreated()
            ->assertJsonPath('data.title', 'My First Post');

        $this->assertDatabaseHas('posts', [
            'title'   => 'My First Post',
            'user_id' => $this->user->id,
        ]);
    }

    /** @test */
    public function unauthenticated_request_returns_401(): void
    {
        $this->getJson('/api/posts')->assertUnauthorized();
    }
}

Useful TestResponse Assertions

Assertion Method What it Checks
assertOk() HTTP status 200
assertCreated() HTTP status 201
assertRedirect('/path') 3xx redirect to given URL
assertSessionHasErrors(['field']) Validation errors in session
assertJsonCount(3, 'data') JSON array at key has exactly N items
assertJsonPath('key', $value) Nested JSON key equals value (dot notation)
assertUnauthorized() HTTP status 401
assertForbidden() HTTP status 403
assertViewIs('view.name') Response renders the given Blade view

Database Testing with Model Factories

Writing tests by hand-crafting every piece of database state is tedious and fragile. Laravel's model factories let you define a blueprint for each model and generate realistic test data with a single call. They integrate with Faker to produce varied, human-readable values.

Creating a Factory

database/factories/PostFactory.php
<?php

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id'    => User::factory(),
            'title'      => $this->faker->sentence(6),
            'body'       => $this->faker->paragraphs(4, true),
            'published'  => false,
            'created_at' => $this->faker->dateTimeBetween('-1 year', 'now'),
        ];
    }

    // State: published post
    public function published(): static
    {
        return $this->state(fn (array $attributes) => [
            'published'    => true,
            'published_at' => now(),
        ]);
    }

    // State: post with a specific author
    public function byAuthor(User $user): static
    {
        return $this->state(['user_id' => $user->id]);
    }
}

Using Factories in Tests

Factory Usage Examples
// Create a single user (saves to DB)
$user = User::factory()->create();

// Create a user without saving (in-memory only)
$user = User::factory()->make();

// Create 10 users at once
$users = User::factory()->count(10)->create();

// Create user with specific attributes
$admin = User::factory()->create(['role' => 'admin']);

// Create using a state
$post = Post::factory()->published()->create();

// Create with an associated model (auto-creates User)
$post = Post::factory()->for($user)->create();

// Create nested relationships
$user = User::factory()
    ->has(Post::factory()->published()->count(5))
    ->create();

// Override specific fields
$post = Post::factory()->create([
    'title' => 'Exact Title for This Test',
]);

RefreshDatabase vs DatabaseTransactions

Laravel offers two traits to manage database state between tests:

Trait How it Works Best Used When
RefreshDatabase Runs migrations fresh before the test suite; wraps each test in a transaction that rolls back Most feature tests — reliable, works with SQLite :memory:
DatabaseTransactions Wraps each test in a database transaction, rolls back after the test When you need to test against a persistent database (e.g., MySQL)
DatabaseMigrations Runs migrations up before each test and rolls them back after Rarely — very slow; only when you need to test migrations themselves
Test Isolation Warning

Never share state between tests. Each test method should stand alone — create its own data, make assertions, and leave no trace. If tests pass in isolation but fail when run together, you have hidden state leaking between tests. Use RefreshDatabase consistently to prevent this.

Mocking and Faking Laravel Services

Real applications send emails, dispatch jobs, fire events, and call external APIs. You never want tests to actually do these things. Laravel provides two strategies: service fakes for Laravel's own services, and Mockery (bundled with PHPUnit) for custom dependencies.

Laravel's Built-in Fakes

Call the static fake() method on any of Laravel's facade-based services before your test runs. The fake intercepts calls and lets you assert they happened.

tests/Feature/OrderPlacedTest.php
<?php

namespace Tests\Feature;

use App\Events\OrderPlaced;
use App\Jobs\SendOrderConfirmation;
use App\Mail\OrderConfirmationMail;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class OrderPlacedTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function placing_order_queues_confirmation_email(): void
    {
        Queue::fake();

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

        $this->actingAs($user)
            ->postJson('/api/orders', [
                'items' => [['product_id' => 1, 'quantity' => 2]],
            ])
            ->assertCreated();

        Queue::assertPushed(SendOrderConfirmation::class, function ($job) use ($user) {
            return $job->user->is($user);
        });
    }

    /** @test */
    public function placing_order_fires_order_placed_event(): void
    {
        Event::fake([OrderPlaced::class]);

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

        $this->actingAs($user)
            ->postJson('/api/orders', [
                'items' => [['product_id' => 1, 'quantity' => 2]],
            ]);

        Event::assertDispatched(OrderPlaced::class);
    }

    /** @test */
    public function order_confirmation_email_goes_to_correct_address(): void
    {
        Mail::fake();

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

        $this->actingAs($user)
            ->postJson('/api/orders', [
                'items' => [['product_id' => 1, 'quantity' => 1]],
            ]);

        Mail::assertSent(OrderConfirmationMail::class, 'buyer@example.com');
    }
}

Mocking Custom Dependencies with Mockery

For classes you wrote — like a payment gateway wrapper or a third-party API client — use $this->mock() to swap a real instance for a test double that returns whatever you specify.

Mocking a Payment Gateway
use App\Services\PaymentGateway;

/** @test */
public function order_is_saved_when_payment_succeeds(): void
{
    // Replace the real PaymentGateway in the container with a mock
    $this->mock(PaymentGateway::class, function ($mock) {
        $mock->shouldReceive('charge')
             ->once()
             ->with(1000, 'USD')          // assert it is called with correct args
             ->andReturn(['status' => 'success', 'transaction_id' => 'txn_123']);
    });

    $response = $this->actingAs(User::factory()->create())
        ->postJson('/api/checkout', ['amount_cents' => 1000]);

    $response->assertCreated();
    $this->assertDatabaseHas('orders', ['transaction_id' => 'txn_123']);
}

/** @test */
public function order_is_not_saved_when_payment_fails(): void
{
    $this->mock(PaymentGateway::class, function ($mock) {
        $mock->shouldReceive('charge')
             ->andReturn(['status' => 'failed', 'message' => 'Insufficient funds']);
    });

    $this->actingAs(User::factory()->create())
        ->postJson('/api/checkout', ['amount_cents' => 1000])
        ->assertUnprocessable();

    $this->assertDatabaseCount('orders', 0);
}

Organizing Tests and Best Practices

As your test suite grows to hundreds of tests, organization becomes critical. A consistent structure makes it easy to find tests, understand what's covered, and identify gaps.

Directory Structure

Recommended Test Structure
tests/
├── Unit/
│   ├── Services/
│   │   ├── OrderPricingServiceTest.php
│   │   └── TaxCalculatorTest.php
│   └── Models/
│       └── UserTest.php
├── Feature/
│   ├── Api/
│   │   ├── PostApiTest.php
│   │   └── UserApiTest.php
│   ├── Auth/
│   │   ├── LoginTest.php
│   │   └── RegistrationTest.php
│   └── CheckoutTest.php
└── TestCase.php         ← shared setUp logic, helper traits

Data Providers for Parameterized Tests

When the same assertion needs to run against multiple inputs, use PHPUnit data providers instead of copy-pasting test methods.

Data Provider Example
/** @dataProvider invalidEmailProvider */
public function test_registration_rejects_invalid_emails(string $email): void
{
    $response = $this->post('/register', [
        'name'     => 'Test User',
        'email'    => $email,
        'password' => 'password123',
        'password_confirmation' => 'password123',
    ]);

    $response->assertSessionHasErrors('email');
}

public static function invalidEmailProvider(): array
{
    return [
        'missing @'     => ['notanemail'],
        'missing domain'=> ['user@'],
        'missing tld'   => ['user@domain'],
        'spaces'        => ['user @example.com'],
        'empty string'  => [''],
    ];
}

Test Types at a Glance

Type Speed Scope When to Write
Unit Test Milliseconds Single class / method Business logic, algorithms, value objects
Feature Test Seconds HTTP → DB → Response User-facing routes, API endpoints, auth flows
Integration Test Seconds Multiple classes together Service + repository + DB without HTTP layer
Browser Test (Dusk) 10–30s Real browser, JavaScript Complex UI interactions, Vue/React components

Useful php artisan test Commands

Command Description
php artisan test Run the full test suite with pretty output
php artisan test --filter=OrderTest Run tests matching a class or method name
php artisan test --group=slow Run tests tagged with @group slow
php artisan test --parallel Run tests in parallel across multiple processes
php artisan test --coverage Display code coverage report (requires Xdebug / PCOV)
php artisan test --stop-on-failure Halt the suite on the first test failure
php artisan make:test PostTest Scaffold a new feature test class
php artisan make:test PostTest --unit Scaffold a new unit test class

Conclusion: Building Confidence Through Tests

Laravel's testing toolkit is one of the framework's greatest strengths. PHPUnit integration, expressive HTTP helpers, model factories, database traits, and service fakes work together so that writing tests feels natural rather than burdensome. The key insight is to use the right test type for each layer:

Key Takeaways

  • Unit tests are fast and focused — use them for pure business logic with no external dependencies.
  • Feature tests give you end-to-end confidence by simulating real HTTP requests through your entire application stack.
  • RefreshDatabase is your default database trait — it keeps tests isolated without re-running migrations for every test.
  • Model factories eliminate brittle, hand-crafted test data. Use states to express variations concisely.
  • Service fakes (Mail::fake(), Queue::fake(), Event::fake()) prevent side effects without any mocking boilerplate.
  • Mock custom dependencies with $this->mock() to control external services and assert they are called correctly.
  • Data providers let one test method cover dozens of input variations cleanly.
  • Aim for a test pyramid: many unit tests, a solid layer of feature tests, and selective browser tests only where JavaScript behavior matters.
"Code without tests is broken code by design. In Laravel, there's no excuse — the tools are already in your vendor folder."
— Community wisdom

Start with one test for your most critical endpoint today. Once you experience catching a regression before it reaches production, you'll never want to ship untested code again.

Laravel Testing PHPUnit Feature Tests Unit Tests TDD Factories
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.