Introduction

Any non-trivial feature test touches the database. Laravel gives you three building blocks that make database testing fast and deterministic: RefreshDatabase for clean state between tests, factories for realistic fixtures, and database-specific assertions like assertDatabaseHas that read much nicer than raw SQL checks.

Key Concepts

  • RefreshDatabase trait: Runs migrations once at the start of the suite, then wraps each test in a transaction that rolls back.
  • Factory: A class that generates realistic model instances with create() (persisted) or make() (in-memory).
  • Factory state: A named modifier like published() or draft() that overrides specific attributes.
  • Database assertions: assertDatabaseHas, assertDatabaseMissing, assertDatabaseCount, assertSoftDeleted, assertModelMissing.

Real World Context

Without factories, every test writes 20 lines of User::create([...]) boilerplate just to get a valid user. Without RefreshDatabase, tests see each other's data and flake randomly. Without assertDatabaseHas, you're writing $this->assertEquals(1, DB::table('posts')->where(...)->count()) constantly. The three together turn database tests from drudgery into one-liners.

Deep Dive

RefreshDatabase Trait

Reset the database between tests:

php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostTest extends TestCase
{
    use RefreshDatabase;  // Resets database for each test

    public function test_posts_can_be_created(): void
    {
        // Database is fresh for this test
    }
}

Options:

  • RefreshDatabase - Runs migrations once, uses transactions (fastest)
  • DatabaseMigrations - Runs migrations before each test
  • DatabaseTransactions - Wraps each test in a transaction

Database Assertions

php
use App\Models\Post;

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

    $this->actingAs($user)->post('/posts', [
        'title' => 'Test Post',
        'body' => 'Content',
    ]);

    // Assert record exists
    $this->assertDatabaseHas('posts', [
        'title' => 'Test Post',
        'user_id' => $user->id,
    ]);

    // Assert record doesn't exist
    $this->assertDatabaseMissing('posts', [
        'title' => 'Nonexistent Post',
    ]);

    // Assert count
    $this->assertDatabaseCount('posts', 1);

    // Assert empty
    $this->assertDatabaseEmpty('comments');
}

public function test_soft_deleted_posts(): void
{
    $post = Post::factory()->create();
    $post->delete();

    // Record still exists (soft deleted)
    $this->assertSoftDeleted('posts', ['id' => $post->id]);

    // Or with model
    $this->assertSoftDeleted($post);

    // Not soft deleted
    $this->assertNotSoftDeleted('posts', ['id' => $post->id]);
}

public function test_model_was_deleted(): void
{
    $post = Post::factory()->create();
    $postId = $post->id;

    $this->actingAs($post->user)
        ->delete("/posts/{$postId}");

    $this->assertModelMissing($post);
}

Model Factories

Factories generate test data:

php
// Create a single model (persisted to database)
$user = User::factory()->create();

// Create multiple models
$users = User::factory()->count(3)->create();

// Create with specific attributes
$admin = User::factory()->create([
    'name' => 'Admin User',
    'is_admin' => true,
]);

// Make (without persisting)
$user = User::factory()->make();

// Create with relationships
$post = Post::factory()
    ->for(User::factory(), 'author')
    ->has(Comment::factory()->count(3))
    ->create();

Creating Factories

bash
php artisan make:factory PostFactory
php
<?php

namespace Database\Factories;

use App\Models\Post;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;

class PostFactory extends Factory
{
    protected $model = Post::class;

    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title' => fake()->sentence(),
            'slug' => fake()->slug(),
            'body' => fake()->paragraphs(3, true),
            'published_at' => fake()->optional()->dateTime(),
        ];
    }

    // States for different scenarios
    public function published(): static
    {
        return $this->state(fn (array $attributes) => [
            'published_at' => now(),
        ]);
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes) => [
            'published_at' => null,
        ]);
    }

    public function byUser(User $user): static
    {
        return $this->state(fn (array $attributes) => [
            'user_id' => $user->id,
        ]);
    }
}

Usage:

php
// Using states
$post = Post::factory()->published()->create();
$draft = Post::factory()->draft()->create();

// Chaining states
$post = Post::factory()
    ->published()
    ->byUser($user)
    ->create();

Factory Relationships

php
// Create with related models
$user = User::factory()
    ->has(Post::factory()->count(3))
    ->create();

// Shorthand: hasPosts
$user = User::factory()
    ->hasPosts(3)
    ->create();

// With customized related models
$user = User::factory()
    ->has(
        Post::factory()
            ->count(3)
            ->state(['published_at' => now()])
    )
    ->create();

// Belonging to relationship
$posts = Post::factory()
    ->count(3)
    ->for(User::factory()->state(['name' => 'John']))
    ->create();

// Many-to-many
$post = Post::factory()
    ->hasAttached(
        Tag::factory()->count(3),
        ['added_by' => $user->id]  // Pivot data
    )
    ->create();

Seeders in Tests

php
use Database\Seeders\CategorySeeder;

public function test_posts_have_categories(): void
{
    // Run specific seeder
    $this->seed(CategorySeeder::class);

    // Or run DatabaseSeeder
    $this->seed();

    // Now test with seeded data
    $categories = Category::all();
    $this->assertGreaterThan(0, $categories->count());
}

In-Memory SQLite

Fastest testing with in-memory database:

xml
<!-- phpunit.xml -->
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>

Testing Model Events

php
public function test_post_creates_slug_on_saving(): void
{
    $post = Post::factory()->create(['title' => 'My Test Post']);

    $this->assertEquals('my-test-post', $post->slug);
}

public function test_deleting_user_cascades_to_posts(): void
{
    $user = User::factory()->hasPosts(3)->create();
    $postIds = $user->posts->pluck('id');

    $user->delete();

    $postIds->each(fn ($id) => 
        $this->assertDatabaseMissing('posts', ['id' => $id])
    );
}

Common Pitfalls

  1. Using DatabaseMigrations when RefreshDatabase would do — DatabaseMigrations re-runs every migration before every test, which is orders of magnitude slower. Only use it when you genuinely need a fresh schema per test.
  2. Creating too many fixture rows — User::factory()->count(1000)->create() runs 1000 INSERTs. If your test is about pagination at 20-per-page, 25 rows is enough.

Best Practices

  1. Use SQLite :memory: for the test database — it's the fastest option and the transactions-based RefreshDatabase approach works perfectly.
  2. Prefer factory states over inline attributes — Post::factory()->published()->create() is more expressive than Post::factory()->create(['published_at' => now()]) and centralizes the shape of a "published post".

Summary

  • RefreshDatabase is the fast default: migrations once, transaction per test.
  • Factories generate realistic model instances; states name common variants.
  • assertDatabaseHas/Missing/Count and assertModelMissing/SoftDeleted are the core assertions.
  • SQLite :memory: is the idiomatic test database for Laravel.
✓ Completed