Testing Laravel Apps
Pest/PHPUnit feature tests hitting a route, factories, and a complete worked example.
Pest vs PHPUnit
Laravel ships test support for both PHPUnit (the traditional PHP testing framework, class-based with public function test_...() methods) and Pest (a newer, more concise layer built on top of PHPUnit, using plain functions instead of test classes). A fresh Laravel project defaults to Pest, but both run the exact same underlying test suite — the choice is almost entirely about syntax preference, not capability.
<?php
// PHPUnit style — tests/Feature/PostTest.php
namespace Tests\Feature;
use Tests\TestCase;
class PostTest extends TestCase
{
public function test_guest_cannot_create_a_post(): void
{
$response = $this->post('/posts', ['title' => 'New Post']);
$response->assertRedirect('/login');
}
}
<?php
// Pest style — tests/Feature/PostTest.php
test('guest cannot create a post', function () {
$response = $this->post('/posts', ['title' => 'New Post']);
$response->assertRedirect('/login');
});
Both examples above make an identical assertion; Pest's test(...) is a thin wrapper that still runs on PHPUnit underneath, which is why the same $this->post(...) and assertion methods work in either style.
A complete feature test hitting a route
A feature test exercises a full route through the real HTTP-like request cycle (routing, middleware, controller, database) — as opposed to a narrower unit test targeting one class or method in isolation.
<?php
// tests/Feature/PostTest.php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('an authenticated user can create a post', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/posts', [
'title' => 'My First Post',
'body' => 'Hello, world.',
]);
$response->assertRedirect('/posts');
$this->assertDatabaseHas('posts', [
'title' => 'My First Post',
'author_id' => $user->id,
]);
});
test('a guest is redirected to login when creating a post', function () {
$response = $this->post('/posts', ['title' => 'My First Post']);
$response->assertRedirect('/login');
$this->assertDatabaseMissing('posts', ['title' => 'My First Post']);
});
php artisan test
actingAs($user) authenticates the test request as that user for the duration of the request, without needing a real login form submission first. assertDatabaseHas()/assertDatabaseMissing() check the actual database state after the request completes — verifying the side effect, not just the HTTP response. RefreshDatabase migrates a fresh test database once and wraps each test in a transaction rolled back afterward, so tests never leak data into each other — Laravel automatically points this at a separate testing database configuration (.env.testing, or an in-memory SQLite database set via phpunit.xml) rather than the real one.
Factories
Manually constructing a User or Post with every column filled in by hand, in every test, is tedious and brittle against schema changes. A factory defines realistic fake data for a model once, reusable everywhere:
<?php
// database/factories/PostFactory.php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class PostFactory extends Factory
{
public function definition(): array
{
return [
'title' => fake()->sentence(),
'body' => fake()->paragraphs(3, true),
'published' => fake()->boolean(),
'author_id' => User::factory(),
];
}
}
$post = Post::factory()->create(); // one saved Post, with a freshly created related User too
$posts = Post::factory()->count(5)->create(); // five saved Posts
$published = Post::factory()->create(['published' => true]); // override a specific column
User::factory() as the value for author_id tells Laravel to create a related User automatically if one isn't supplied — exactly what the an authenticated user can create a post test above relies on implicitly through User::factory()->create().
Common mistakes
- Running feature tests against the real development database instead of a dedicated test database (or
RefreshDatabaseplus SQLite in-memory) — a bug in a test can corrupt real data, and leftover rows from a previous run can make tests fail unpredictably. - Hand-building model instances field-by-field in every test instead of a factory — brittle against any schema change, and it obscures which fields actually matter to the specific test versus which are just required boilerplate.
- Asserting only on the HTTP response and skipping a database assertion (
assertDatabaseHas) when the point of the test is a side effect (a row being created) — a redirect can look correct even if the underlyingINSERTsilently failed.
Interview questions
Q: What's the practical difference between a feature test and a unit test in Laravel?
A feature test (tests/Feature) exercises a full route through the actual HTTP-like request cycle — routing, middleware, controller, validation, and the database — verifying the whole stack behaves correctly together. A unit test (tests/Unit) targets one class or method in isolation, with dependencies typically mocked out, and doesn't boot the full framework request cycle at all. Most Laravel test suites lean heavily on feature tests, since they verify what actually matters to a user: whether hitting a real route produces the right response and side effects.
Q: Why use a model factory instead of manually constructing test data?
A factory defines what a "realistic" instance of a model looks like once (via fake() values and any required relationships), and every test that needs one just calls Model::factory()->create(), optionally overriding only the specific fields that test cares about. This keeps tests focused on what they're actually verifying, and it means a schema change (a new required column) only needs to be handled in one place — the factory — instead of in every individual test that constructs that model.