0

Feature tests vs unit tests in a Laravel app

Beginner5 min read·lv-26-002
interview

Concept

Database testing in Laravel makes it safe to mutate the database in tests without polluting test data across test runs. The two main strategies are transactions and fresh migrations.

RefreshDatabase trait: The standard approach. In test environments with SQLite, it runs migrate once and then wraps each test in a database transaction that rolls back after the test. On MySQL/PostgreSQL (or when transactions can't be used), it calls migrate:fresh before each test. The rollback approach is very fast because no actual migration is run per-test.

DatabaseMigrations trait: Runs migrate:fresh before EVERY test. Slower but more accurate — some tests (involving events, triggers, or testing migrations themselves) require real runs.

DatabaseTransactions trait: Wraps each test in a transaction and rolls back. Use when you need more control (e.g., you're also using DatabaseMigrations).

assertDatabaseHas($table, $conditions): Check a row matching conditions exists. Fails if zero rows match.

assertDatabaseMissing($table, $conditions): Check NO row matches conditions.

assertDatabaseCount($table, $count): Assert exact row count.

assertSoftDeleted($table, $conditions): Assert the row exists but deleted_at is set.

assertNotSoftDeleted($table, $conditions): Assert deleted_at is null.

Factories in tests: User::factory()->create() inserts a real DB row. User::factory()->make() returns a model without inserting. User::factory()->count(5)->create() inserts 5 rows. Use factory states and sequences for variations.

Code Example

php
<?php
namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use App\Models\User;
use App\Models\Post;

class PostDatabaseTest extends TestCase
{
    use RefreshDatabase;

    public function test_creating_post_stores_in_database(): void
    {
        $user = User::factory()->create();
        $post = Post::factory()->for($user)->create([
            'title'  => 'Test Post',
            'status' => 'published',
        ]);

        $this->assertDatabaseHas('posts', [
            'id'      => $post->id,
            'title'   => 'Test Post',
            'user_id' => $user->id,
            'status'  => 'published',
        ]);
        $this->assertDatabaseCount('posts', 1);
    }

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

        $this->assertSoftDeleted('posts', ['id' => $post->id]);
        $this->assertDatabaseHas('posts', ['id' => $post->id]); // row still exists
        $this->assertDatabaseMissing('posts', ['id' => $post->id, 'deleted_at' => null]);
    }

    public function test_restoring_soft_deleted_post(): void
    {
        $post = Post::factory()->trashed()->create();
        $post->restore();
        $this->assertNotSoftDeleted('posts', ['id' => $post->id]);
    }

    public function test_post_factory_creates_with_author(): void
    {
        // Factory with relationship
        $posts = Post::factory()
            ->count(3)
            ->for(User::factory(), 'author')  // named relationship
            ->published()                      // factory state
            ->create();

        $this->assertDatabaseCount('posts', 3);
        $this->assertDatabaseCount('users', 1);  // one shared author, or 3 if factory creates per-post
    }
}