Backend

Automated Testing in Laravel

Laravel ships with testing built in, so setup takes minutes. The hard part is rarely the setup; it is knowing what to test, with which tool, and at which layer.

  • By TETA Team
  • 8 min read
  • Updated:
Automated Testing in Laravel

Why write tests for a Laravel project?

Automated tests are above all a safety net: when you change a discount rule or rename a column, you learn within seconds what broke, not two weeks later from a customer. On team projects, tests double as executable documentation of how the system behaves.

You do not need 100% coverage. Start with the paths where a failure costs money or trust: sign-up and login, payments, permissions, and any API other clients depend on. If you use JWT authentication, the test for what an unauthenticated caller sees should exist from day one.

PHPUnit or Pest?

Laravel supports both, and both run on PHPUnit underneath. The difference is in how the code reads and how it feels to write:

Comparing the two test runners in Laravel
CriterionPHPUnitPest
StyleClasses and methods (test_...)Closures via it() and test()
BoilerplateMoreLess
Ecosystem and familiarityThe long-standing standard most teams knowNeeds a small API to learn
Best forExisting projects and larger teamsNew projects and teams that value readability

Our advice: if the project already uses PHPUnit, do not rewrite it; both styles can live side by side. Running the suite is the same either way:

bash
# run everything
php artisan test

# run in parallel (needs the brianium/paratest package)
php artisan test --parallel

# run a single test by name
php artisan test --filter=test_author_can_publish_post

Feature tests vs unit tests

Laravel gives you tests/Feature and tests/Unit, and the boundary is simple: a unit test exercises one class or function without the framework or a database; a feature test runs a slice of the application together, typically from an HTTP request down to a database write.

What belongs in a unit test?

Pure logic: price calculations, discount rules, format conversions, custom validators. These run in milliseconds because they touch nothing external.

php
<?php

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

class PriceCalculatorTest extends TestCase
{
    public function test_discount_is_applied_before_tax(): void
    {
        $calc = new PriceCalculator(taxRate: 0.09);

        // 1000 - 10% = 900, then 9% tax = 981
        $this->assertSame(981, $calc->total(1000, discountPercent: 10));
    }
}

What belongs in a feature test?

Anything that depends on routes, middleware, validation, models and the database, which is most of a typical API. Because bugs usually appear where pieces connect rather than inside a simple function, much of your confidence comes from these tests.

Illustration of a layered pyramid of colourful test blocks with green check marks rising over a database icon
In Laravel, feature tests usually buy the most confidence per line of test code.

Factories and RefreshDatabase

Every test should start from a known state and never depend on the previous one. The RefreshDatabase trait handles that: it runs your migrations once and wraps each test in a transaction that is rolled back at the end. Factories then build the sample data:

php
<?php

class PostFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id'      => User::factory(),
            'title'        => fake()->sentence(),
            'body'         => fake()->paragraph(),
            'published_at' => null,
        ];
    }

    // a named state keeps tests readable
    public function published(): static
    {
        return $this->state(fn () => ['published_at' => now()]);
    }
}

In a test you write Post::factory()->published()->count(3)->create() and the intent stays clear instead of six lines of hand-built data. For speed, phpunit.xml commonly points at in-memory SQLite; if you rely on MySQL- or PostgreSQL-specific queries, run the tests against that same engine so behavioural differences do not surprise you.

Testing HTTP requests

Methods such as get and postJson send requests into the app without a real server, and you assert on the response and the database. A complete test for creating a post:

php
<?php

namespace Tests\Feature;

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

class CreatePostTest extends TestCase
{
    use RefreshDatabase;

    public function test_author_can_publish_post(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user)
            ->postJson('/api/posts', ['title' => 'Hello', 'body' => 'World'])
            ->assertCreated()
            ->assertJsonPath('data.title', 'Hello');

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

    public function test_title_is_required(): void
    {
        $this->actingAs(User::factory()->create())
            ->postJson('/api/posts', ['body' => 'No title'])
            ->assertUnprocessable()
            ->assertJsonValidationErrors(['title']);
    }
}

The same test reads shorter in Pest:

php
<?php

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

uses(RefreshDatabase::class);

it('requires a title', function () {
    $this->actingAs(User::factory()->create())
        ->postJson('/api/posts', ['body' => 'No title'])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['title']);
});

For every endpoint cover at least three cases: the happy path, invalid input, and an unauthorised caller (401 or 403). Those three catch most real API bugs; the design principles behind them are in designing scalable APIs.

Faking external dependencies

A test that sends real email, calls a bank API or pushes onto a real queue is slow and flaky. Laravel ships fakes for exactly this:

php
use Illuminate\Support\Facades\{Mail, Http, Queue};

Mail::fake();
Queue::fake();
Http::fake([
    'api.payment.test/*' => Http::response(['status' => 'paid'], 200),
]);

// ... run the code under test ...

Mail::assertSent(WelcomeMail::class);
Queue::assertPushed(SendInvoice::class);
Http::assertSent(fn ($request) => $request->url() === 'https://api.payment.test/charge');

When heavy work goes to a queue, test that the job is dispatched, then run the job class separately with sample data. For cache and Redis, either use the array driver in tests or follow the Redis caching guide and start a separate instance just for testing.

Checklist for a healthy test suite

  • Each test checks one behaviour and its name states that behaviour.
  • Tests are independent and can run in any order, including in parallel.
  • No test depends on the internet, the real clock or an external service; use $this->travelTo() for time.
  • Tests run on every push in CI and a failure blocks the merge. For an identical environment, see deploying with Docker.
  • For every production bug, write a failing test before the fix so it cannot return.

The official Laravel documentation lists every assertion and fake in its Testing section. If you want help setting up tests and CI for your own project, see our web development service.

Frequently asked questions

What is the difference between feature and unit tests in Laravel?

A unit test checks one class or function in isolation from the framework and database, and runs very fast. A feature test runs several parts together, such as an HTTP request, validation and a database write, and shows real behaviour better.

What does RefreshDatabase do?

The trait runs your migrations so the schema is ready, then wraps each test in a transaction that is rolled back afterwards. Every test therefore starts with a clean database and cannot see data created by other tests.

Is Pest or PHPUnit better for a new Laravel project?

Both run on the same engine, so the main difference is readability. Pest has less boilerplate and suits new projects; if your team knows PHPUnit or the project already has tests, staying with it is perfectly reasonable.

How much code coverage should Laravel tests have?

There is no fixed number. Cover critical paths such as login, payments and permissions first, then add a test with every new bug. Chasing 100% coverage usually costs more than it is worth.

Comments

    No comments yet. Be the first to share your thoughts.

    Comments are published after review.