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:
| Criterion | PHPUnit | Pest |
|---|---|---|
| Style | Classes and methods (test_...) | Closures via it() and test() |
| Boilerplate | More | Less |
| Ecosystem and familiarity | The long-standing standard most teams know | Needs a small API to learn |
| Best for | Existing projects and larger teams | New 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:
# 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_postFeature 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
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.

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
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
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
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:
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.