Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Laravel Feature Tests That Don't Break Every Week

About Post

You rename a column. Nothing changes for the user. The API returns the same data, the app works exactly as before. And fourteen tests go red.

That's the moment a lot of teams quietly stop trusting their test suite. Tests that break on every refactor aren't protecting you. They're a tax. People start "fixing" them by updating the expected values until they pass, and at that point the tests check nothing at all.

The fix isn't fewer tests. It's tests that check the right thing. Here's how I write Laravel feature tests that survive refactors and still catch real bugs.

The rule underneath everything

Test what the user or the client can observe, not how the code does it.

A feature test should read like a short story: given this user and this data, when they make this request, then they get this response and this lasting effect happened. Whether the controller calls an Action class, a service or a job is none of the test's business. If you can rewrite the inside of a feature without changing what it does, your tests should stay green.

A brittle test, and why it breaks

Here's a test I've seen many versions of. It's for an endpoint where a tenant submits a repair request:

public function test_store(): void
{
    $user = User::create([
        'name' => 'Test', 'email' => '[email protected]',
        'password' => bcrypt('secret'), 'role' => 'tenant',
    ]);

    $response = $this->actingAs($user)->post('/api/repairs', [
        'title' => 'Leaking tap', 'priority' => 'high',
    ]);

    $response->assertExactJson(['data' => [
        'id' => 1, 'title' => 'Leaking tap', 'priority' => 'high',
        'status' => 'open', 'user_id' => 1,
    ]]);
}

Count the ways this breaks for reasons that have nothing to do with bugs:

  • Add a required column to users and every test that builds a user by hand fails.
  • Add a created_at field to the response and assertExactJson fails, even though nothing broke for clients.
  • Hard-coded IDs depend on what ran before. With MySQL, rolled-back transactions don't reset auto-increment, so id 1 is not guaranteed.
  • post() instead of postJson() means a validation error returns a redirect, not a JSON error, so failures are confusing.

The same test, written to last

use RefreshDatabase;

public function test_tenant_can_submit_a_repair_request(): void
{
    Event::fake([RepairRequested::class]);
    $tenant = User::factory()->tenant()->create();

    $response = $this->actingAs($tenant)->postJson('/api/repairs', [
        'title' => 'Leaking tap',
        'priority' => 'high',
    ]);

    $response->assertCreated()
        ->assertJsonPath('data.title', 'Leaking tap')
        ->assertJsonPath('data.status', 'open')
        ->assertJsonStructure(['data' => ['id', 'title', 'priority', 'status']]);

    $this->assertDatabaseHas('repair_requests', [
        'user_id' => $tenant->id,
        'title' => 'Leaking tap',
    ]);

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

The test name says what behaviour it protects. Each assertion checks something a client relies on. New fields in the response don't break it. A new column on users doesn't break it. A refactor of the controller doesn't break it. But if the endpoint stops saving the request, returns the wrong status, or forgets to fire the event that notifies the maintenance team, it fails. That's the balance you want.

Factories: build users in one place

Factories are what make the first problem disappear. When the users table gets a new required column, you update UserFactory once and every test keeps working. Use states for the variations your tests care about:

// database/factories/UserFactory.php
public function tenant(): static
{
    return $this->state(fn (array $attributes) => ['role' => 'tenant']);
}

Now User::factory()->tenant()->create() reads like the business language, and only the attributes the test cares about are visible in the test. If a value matters for the assertion, pass it explicitly. If it doesn't, let the factory decide.

RefreshDatabase, and why order should never matter

The RefreshDatabase trait migrates the test database once and wraps each test in a transaction that's rolled back afterwards. Every test starts clean, and tests can run in any order, or in parallel with php artisan test --parallel.

If a test only passes when another test runs first, that's a hidden dependency waiting to break. Each test should create everything it needs.

Assert JSON shape, not JSON text

Laravel gives you several ways to check a response. Pick the loosest one that still catches the bug:

  • assertJsonPath('data.status', 'open') for the values that matter.
  • assertJsonStructure([...]) to guarantee the fields clients depend on exist, without fixing their values.
  • assertJsonCount(3, 'data') for lists.
  • assertJsonMissingPath('data.password') to make sure something sensitive is never exposed.
  • assertExactJson only when the exact payload is the contract, which is rare.

Fake the outside world

Feature tests should never send real emails, hit a real payment gateway or depend on the clock. Laravel's fakes handle all of it:

  • Mail::fake(), Notification::fake(), Queue::fake() and Event::fake() record what would have happened, so you can assert on it.
  • Http::fake() returns canned responses for external APIs. Add Http::preventStrayRequests() so any unfaked call fails loudly instead of reaching the internet.
  • $this->freezeTime() or $this->travel(31)->days() for anything involving due dates or expiry. A test that passes on the 15th and fails on the 31st is the worst kind of flaky.

One caution: fake at the edges, not in the middle. Faking your own classes to check that method A called method B brings back exactly the brittleness we're trying to remove.

The refactor test: if you could rewrite the feature's internals without changing its behaviour, and your tests would go red, those tests are checking implementation. Rewrite them to check outcomes.

A quick checklist for every feature test

  • The name describes behaviour: test_tenant_cannot_view_another_tenants_contract.
  • Data comes from factories with meaningful states.
  • RefreshDatabase, and no dependency on other tests.
  • postJson/getJson for APIs, and a status assertion first.
  • Check the values that matter, the structure clients need, and the database effect.
  • Fakes for mail, queues, HTTP and time.
  • Test the unhappy paths too: validation errors (assertJsonValidationErrors) and authorisation (assertForbidden).

What's the most fragile test you've ever had to maintain, and what finally made it stable?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close