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
RefreshDatabasetrait: 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) ormake()(in-memory). - Factory state: A named modifier like
published()ordraft()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 testDatabaseTransactions- Wraps each test in a transaction
Database Assertions
phpuse 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
bashphp 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
phpuse 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
phppublic 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
- Using
DatabaseMigrationswhenRefreshDatabasewould do —DatabaseMigrationsre-runs every migration before every test, which is orders of magnitude slower. Only use it when you genuinely need a fresh schema per test. - 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
- Use SQLite
:memory:for the test database — it's the fastest option and the transactions-basedRefreshDatabaseapproach works perfectly. - Prefer factory states over inline attributes —
Post::factory()->published()->create()is more expressive thanPost::factory()->create(['published_at' => now()])and centralizes the shape of a "published post".
Summary
RefreshDatabaseis the fast default: migrations once, transaction per test.- Factories generate realistic model instances; states name common variants.
assertDatabaseHas/Missing/CountandassertModelMissing/SoftDeletedare the core assertions.- SQLite
:memory:is the idiomatic test database for Laravel.