Test Data Management: Factories, Fixtures, and Seeding at Scale

Philip Rehberger Mar 11, 2026 7 min read

Poor test data management causes slow tests, flaky tests, and tests that are impossible to understand. Here's how to build a test data strategy that stays maintainable as your codebase grows.

Test Data Management: Factories, Fixtures, and Seeding at Scale

Why Test Data Is Hard

Every test needs data. A test for invoice generation needs a client, a project, some line items, a user with the right permissions. As your application grows, the relationships between entities become complex, and setting up that data for every test becomes a significant burden.

Teams respond to this burden in predictable ways: they share test data across tests (causing order-dependent failures), they use production database snapshots (causing slow and nondeterministic tests), or they write enormous setUp methods that obscure what the test is actually about.

There's a better way.

Factories: The Right Default

Laravel's model factories are the right tool for most test data needs. They provide:

  • Sensible defaults for every required field
  • Easy customization for specific test cases
  • Relationship creation through factory states
  • Realistic fake data via Faker

A well-designed factory makes tests readable:

// Immediately clear what this test needs
$invoice = Invoice::factory()
    ->for(Client::factory()->premium())
    ->has(InvoiceItem::factory()->count(3))
    ->overdue()
    ->create();

Compare this to setting up data manually:

// Tedious and obscures the test's intent
$client = Client::create([
    'name' => 'Test Client',
    'tier' => 'premium',
    'created_at' => now(),
]);
$invoice = Invoice::create([
    'client_id' => $client->id,
    'status' => 'overdue',
    'due_date' => now()->subDays(30),
    'total' => 0,
]);
// ... more setup

Writing Factories That Communicate Intent

A factory's job is to make valid, reasonable data with minimal effort. Invest time in getting defaults right:

class InvoiceFactory extends Factory
{
    public function definition(): array
    {
        return [
            'client_id'    => Client::factory(),
            'status'       => InvoiceStatus::Draft,
            'issue_date'   => $this->faker->dateTimeBetween('-30 days', 'now'),
            'due_date'     => $this->faker->dateTimeBetween('now', '+30 days'),
            'total'        => $this->faker->numberBetween(10000, 500000), // cents
            'currency'     => 'USD',
            'reference'    => strtoupper($this->faker->bothify('INV-####')),
            'notes'        => null,
        ];
    }

    // States communicate business scenarios
    public function overdue(): static
    {
        return $this->state(fn (array $attrs) => [
            'status'   => InvoiceStatus::Sent,
            'due_date' => now()->subDays($this->faker->numberBetween(1, 90)),
        ]);
    }

    public function paid(): static
    {
        return $this->state(fn (array $attrs) => [
            'status'     => InvoiceStatus::Paid,
            'paid_at'    => now()->subDays($this->faker->numberBetween(1, 14)),
            'paid_amount' => $attrs['total'],
        ]);
    }

    public function largeDeal(): static
    {
        return $this->state(fn (array $attrs) => [
            'total' => $this->faker->numberBetween(500000, 5000000),
        ]);
    }
}

States are named after business concepts (overdue, paid, largeDeal), not implementation details. A test that needs an overdue invoice says ->overdue(), not ->state(['due_date' => now()->subDays(30), 'status' => 'sent']).

The Make vs. Create Distinction

Always prefer make() over create() when you don't need database persistence:

// create() hits the database
$invoice = Invoice::factory()->create();

// make() builds the object in memory, no database call
$invoice = Invoice::factory()->make();

Unit tests that test pure logic don't need database records. A test for InvoiceTotalCalculator doesn't need a real database row; it needs an Invoice object with the right values. Using make() in these cases makes unit tests dramatically faster.

As a rule: use create() in integration tests, use make() in unit tests.

Fixtures for Stable Reference Data

Some data shouldn't be randomly generated: status codes, permission definitions, country lists, plan tiers. This reference data should be seeded as fixtures that every test can rely on.

Create a dedicated seeder for test fixtures:

class TestFixtureSeeder extends Seeder
{
    public function run(): void
    {
        // Stable IDs that tests can reference directly
        Plan::insert([
            ['id' => 1, 'slug' => 'starter', 'price_cents' => 4900],
            ['id' => 2, 'slug' => 'professional', 'price_cents' => 9900],
            ['id' => 3, 'slug' => 'enterprise', 'price_cents' => 29900],
        ]);

        Permission::insert([
            ['id' => 1, 'name' => 'invoices.create'],
            ['id' => 2, 'name' => 'invoices.send'],
            ['id' => 3, 'name' => 'clients.manage'],
        ]);
    }
}

Run this seeder once per test suite, not per test. In TestCase.php:

public static function setUpBeforeClass(): void
{
    parent::setUpBeforeClass();

    // Run once for the entire test suite
    Artisan::call('db:seed', ['--class' => 'TestFixtureSeeder']);
}

Tests can now reference Plan::find(2) for the professional plan without creating it in every test.

Database Strategies: RefreshDatabase vs. Transactions

Choosing the right database reset strategy has significant performance implications.

RefreshDatabase runs all migrations at the start and truncates all tables between tests. It's thorough but slow on large schemas.

DatabaseTransactions wraps each test in a transaction and rolls it back when the test ends. It's much faster but doesn't work with:

  • Tests that commit their own transactions
  • Tests that use multiple database connections
  • Tests that test transaction behavior itself
// Prefer DatabaseTransactions for most tests
use Illuminate\Foundation\Testing\DatabaseTransactions;

class InvoiceServiceTest extends TestCase
{
    use DatabaseTransactions;

    // Each test is wrapped in a transaction and rolled back
    public function test_creates_invoice_with_line_items(): void
    {
        $invoice = Invoice::factory()->create();
        // ...
    }
}

For a test suite with 500 tests, switching from RefreshDatabase to DatabaseTransactions can cut runtime from 4 minutes to under a minute.

Managing Complex Relationship Graphs

Complex entities with many required relationships are a common pain point. A Project might require a Client, which requires a User, which requires Roles. Every test for project-related features has to set up this chain.

Solve this with factory sequences and relationship factories:

// A factory that creates the entire relationship graph
class ProjectWithFullContextFactory extends Factory
{
    public function definition(): array
    {
        $admin = User::factory()->admin()->create();
        $client = Client::factory()->active()->create();
        $client->users()->attach($admin);

        return [
            'client_id'  => $client->id,
            'created_by' => $admin->id,
            'status'     => ProjectStatus::Active,
            'name'       => $this->faker->words(3, true),
        ];
    }
}

Or use a test helper trait shared across related tests:

trait CreatesProjectContext
{
    protected User $admin;
    protected Client $client;
    protected Project $project;

    protected function setUpProjectContext(): void
    {
        $this->admin = User::factory()->admin()->create();
        $this->client = Client::factory()->active()->create();
        $this->project = Project::factory()
            ->for($this->client)
            ->create(['created_by' => $this->admin->id]);
    }
}

Tests that need this context call $this->setUpProjectContext() in their setUp method, getting all three models in one readable call.

Seeding Strategies for Development

Your test factories should be the source of truth for development seed data too. Create a development seeder that reuses your factories:

class DevelopmentSeeder extends Seeder
{
    public function run(): void
    {
        // Create admin user with known credentials
        $admin = User::factory()->admin()->create([
            'email' => 'admin@example.com',
            'password' => Hash::make('password'),
        ]);

        // Create realistic-looking clients
        $clients = Client::factory()->count(10)->active()->create();

        // Each client gets projects and invoices
        $clients->each(function (Client $client) {
            $projects = Project::factory()
                ->count(fake()->numberBetween(1, 5))
                ->for($client)
                ->create();

            Invoice::factory()
                ->count(fake()->numberBetween(2, 8))
                ->for($client)
                ->sequence(
                    ['status' => InvoiceStatus::Paid],
                    ['status' => InvoiceStatus::Sent],
                    ['status' => InvoiceStatus::Draft],
                )
                ->create();
        });
    }
}

This ensures development data exercises the same code paths as your tests, making it easier to catch issues before they become test failures.

Avoiding Common Anti-Patterns

Anti-pattern: Shared mutable test data

// WRONG: Tests that modify $this->invoice affect other tests
public function setUp(): void
{
    $this->invoice = Invoice::factory()->create();
}

Instead, create fresh data in each test or use DatabaseTransactions.

Anti-pattern: Magic IDs in tests

// WRONG: Assumes invoice with ID 1 exists
$invoice = Invoice::find(1);

Instead, create the data and use the returned model's ID.

Anti-pattern: Over-specifying factory data

// WRONG: Specifies every field when only status matters
$invoice = Invoice::factory()->create([
    'client_id' => 1,
    'status' => 'overdue',
    'due_date' => now()->subDays(30),
    'total' => 10000,
    'currency' => 'USD',
    // ... 10 more fields
]);

Instead, use states and let the factory handle the rest:

// RIGHT: Communicate only what matters for this test
$invoice = Invoice::factory()->overdue()->create();

Practical Takeaways

  • Use factory states named after business concepts, not implementation details
  • Use make() for unit tests that don't need database persistence
  • Use DatabaseTransactions instead of RefreshDatabase for significant speed improvements
  • Create stable fixture seeders for reference data (plans, permissions, roles)
  • Encapsulate complex relationship setup in traits or dedicated factory classes
  • Keep your development seed data in sync with your test factories

Need help building reliable systems? We help teams architect software that scales. scopeforged.com

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.