Unit vs Feature Tests in Laravel: When and How to Use Each

Unit vs Feature Tests in Laravel: When and How to Use Each

~ 5 min read

If you’ve worked on a Laravel codebase long enough, you’ve likely heard some version of this debate: “Write more unit tests!” vs “Just write feature tests, they’re closer to reality.” The truth is you need both but not equally, and not everywhere.

This post breaks down the differences, when to pick one over the other, and how to structure your suite so it remains fast, reliable, and helpful to future you.

Quick definitions

  • Unit test
    • Tests one unit in isolation (class/function), typically without framework bootstrapping, DB, or network.
    • Fast, deterministic, cheap to run. Ideal for business rules and small, pure logic.
  • Feature test (a.k.a. integration test in Laravel’s terminology)
    • Boots the app kernel and exercises multiple layers together: routes, middleware, controllers, DB, events, queues, etc.
    • Validates wiring and behaviour the way users (or other systems) hit your app.

Laravel names: The default php artisan make:test has two flavours:

  • --unit puts tests under tests/Unit (no RefreshDatabase by default).
  • Without --unit you get a Feature test under tests/Feature (framework boots per test case).

What each test type is great at

  • Use unit tests for:

    • Pure domain logic: money calculations, policies/abilities logic split from the framework, text parsing, small services.
    • Edge cases that would be hard/slow to trigger through HTTP.
    • Regression tests for bug fixes in isolated classes.
  • Use feature tests for:

    • HTTP flows: routes, middleware, controllers, requests, responses.
    • Database interactions: models, factories, queries, transactions.
    • Cross-cutting behavior: events, listeners, notifications, queues, caching, authorization.

A pragmatic split

  • Aim for 60–80% unit tests for core domain logic.
  • Use feature tests as “safety rails” around user journeys and critical app seams.
  • Don’t try to feature-test every permutation. Cover the happy paths, critical error paths, and authorization boundaries.

This shape keeps the suite fast while giving you confidence that the app wiring actually works.

Examples

Below examples use Pest syntax, but the same concepts apply to PHPUnit. Replace with PHPUnit style if you prefer.

Unit: pure domain service

Suppose you have a tax calculator that shouldn’t care about DB or HTTP.

// app/Domain/Tax/TaxCalculator.php
namespace App\Domain\Tax;

final class TaxCalculator
{
    public function __construct(private float $rate)
    {
        if ($this->rate < 0 || $this->rate > 1) {
            throw new \InvalidArgumentException('Rate must be between 0 and 1.');
        }
    }

    public function addTax(float $amount): float
    {
        return round($amount * (1 + $this->rate), 2);
    }
}
// tests/Unit/TaxCalculatorTest.php (Pest)
use App\Domain\Tax\TaxCalculator;

it('adds tax correctly', function () {
    $calc = new TaxCalculator(0.2);
    expect($calc->addTax(100))->toBe(120.00);
});

it('validates rate bounds', function () {
    expect(fn () => new TaxCalculator(1.5))->toThrow(InvalidArgumentException::class);
});
  • Why unit? It’s deterministic and fast. You don’t need Laravel’s container, DB, or facades.

Feature: HTTP + DB flow

// routes/web.php
Route::post('/orders', [OrderController::class, 'store'])->middleware('auth');
// app/Http/Controllers/OrderController.php
public function store(StoreOrderRequest $request)
{
    $order = DB::transaction(function () use ($request) {
        $order = Order::create($request->validated());
        event(new OrderPlaced($order));
        return $order;
    });

    return to_route('orders.show', $order);
}
// tests/Feature/CreateOrderTest.php (Pest)
use App\Models\User;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('creates an order and redirects', function () {
    $user = User::factory()->create();

    $payload = [
        'sku' => 'ABC-123',
        'quantity' => 2,
    ];

    $response = $this->actingAs($user)->post('/orders', $payload);

    $response->assertRedirect();
    $this->assertDatabaseHas('orders', [
        'sku' => 'ABC-123',
        'quantity' => 2,
        'user_id' => $user->id,
    ]);
});
  • Why feature? We care that routing, validation, auth, DB transaction, and redirect all work together.

Feature: authorization boundary

it('prevents guests from creating orders', function () {
    $this->post('/orders', [])->assertRedirect('/login');
});

One assertion, big value: ensures middleware and guards are wired.

Speed, isolation, and reliability

  • Unit tests
    • Keep them framework-free where possible. Construct classes directly. Avoid facades in domain code.
    • Use test doubles for collaborators (interfaces). Don’t mock Eloquent models; move logic out of models if you want them unit-testable.
  • Feature tests
    • Use RefreshDatabase trait to run each test in a transaction or migrate fresh (depending on DB driver). It keeps state clean and cuts flakiness.
    • Prefer SQLite in-memory for speed when possible, but mirror production types for tricky queries.
    • Use model factories to set up data quickly and realistically.

What not to test (or test sparingly)

  • Implementation details that change often (private helpers, exact query shapes) unless they encode critical logic.
  • Eloquent internals or Laravel framework behavior the framework’s own tests cover that.
  • Every accessor/mutator/toArray path; cover the behavior they enable instead.

When a unit test becomes a feature test

If your test:

  • boots the application kernel,
  • touches the database,
  • goes through HTTP/middleware,
  • relies on events/queues/caching,

it’s a feature test. That’s fine, categorise it as such and keep it focused.

Test data patterns

  • Factories: concise and reliable. Override only what matters to the case.
  • Mother objects/builders: for complex aggregates build small builders to keep tests readable.
  • Fixtures: use sparingly; prefer factories so the state is explicit.

Mocking guidelines

  • Mock external systems (HTTP clients, third‑party SDKs, mail, queue, storage) at the boundary.
  • Prefer fakes/helpers Laravel provides: Http::fake(), Mail::fake(), Queue::fake(), Storage::fake(), Event::fake().
  • Avoid deep stubbing Eloquent. If you need heavy mocking to test a class, that class likely mixes concerns extract the logic.

Coverage and CI

  • Set a reasonable minimum (e.g., 70–80%) but keep an eye on meaningful coverage: are the risky paths covered?
  • Run fast unit suites on every push; run a full feature suite in CI.
  • Use --parallel to speed things up; watch out for shared-state issues.

Commands you might use:

php artisan test --parallel
php artisan test --group=unit
php artisan test --group=feature

With Pest, you can group by file path or use custom groups via annotations.

A simple strategy you can adopt today

  1. Identify 3–5 critical user journeys and write end-to-end feature tests that hit HTTP, auth, DB, and important events.
  2. For each bug you fix in core logic, write a surgical unit test to lock it down.
  3. Keep adding unit tests around domain services and policies while expanding feature tests only for high‑risk flows.
  4. Measure run time regularly; if feature tests dominate time, trim redundancy or split into faster unit tests plus one integration guard.

Takeaway

  • Unit tests keep your domain logic clean and fast to iterate.
  • Feature tests give you confidence that the real app works.
  • Use both intentionally: small, numerous unit tests plus a thin layer of high‑value feature tests.

That balance will make your Laravel test suite both pleasant to maintain and trustworthy when it counts.

all posts →