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
DatabaseTransactionsinstead ofRefreshDatabasefor 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