Laravel Testing: Feature and Unit Tests
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.
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:
<?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.
Install PHPUnit (already included)
Laravel ships with PHPUnit as a dev dependency. Confirm with ./vendor/bin/phpunit --version.
Configure the test database
Set DB_DATABASE=:memory: and DB_CONNECTION=sqlite in phpunit.xml so tests never touch your real database.
Generate a test class
Run php artisan make:test UserTest for feature tests or php artisan make:test UserTest --unit for unit tests.
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?
- Isolated: The class under test has no real external dependencies — those are mocked or stubbed.
- Deterministic: The same input always produces the same output; no reliance on time, random values, or environment state.
- Fast: A suite of 1,000 unit tests should run in under a second.
- Readable: Test names read like sentences:
it_calculates_discount_for_premium_users.
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.
<?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),
];
}
}
<?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 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.
<?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.
<?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
<?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
// 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 |
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.
<?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.
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
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.
/** @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 yourvendorfolder."
— 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.