E2E Tests Without Brittle Fixtures: Generate Data on the Fly
Your Playwright tests aren’t flaky because of timing issues. They’re flaky because the data they depend on hasn’t existed since last Tuesday’s migration.
You’ve seen this before. A Cypress test that passed for three months suddenly fails on Monday morning. Nobody changed the test. Nobody changed the feature. Somebody added a column to the users table, and the fixture file checked into cypress/fixtures/users.json still has the old shape. Or the shared test database got wiped over the weekend. Or the hardcoded user ID 42 was deleted by another test running in parallel.
The test didn’t break. The data under it did.
This is the fixture maintenance trap, and it quietly consumes more engineering time than most teams realize. You don’t notice it because it doesn’t show up as a single catastrophic failure. It shows up as a slow drip: one flaky test this week, two next week, a developer spending half a day “fixing” tests that were never actually wrong.
The Fixture Maintenance Trap
Fixtures start innocently. You need a user to test the login flow. You create test-user.json:
{
"id": 42,
"email": "testuser@example.com",
"name": "Test User",
"role": "admin"
}{
"id": 42,
"email": "testuser@example.com",
"name": "Test User",
"role": "admin"
}{
"id": 42,
"email": "testuser@example.com",
"name": "Test User",
"role": "admin"
}Then the users table gets a department_id foreign key. The fixture doesn’t have it. The seeded row either fails to insert or inserts with a NULL that the application code doesn’t expect. The E2E test fails with a cryptic error three layers removed from the actual problem.
So someone adds department_id to the fixture. But now you need a department to exist first. So you add departments.json. Then orders need users, and products need categories, and order items need both orders and products. Six months later, you have 30 fixture files forming an implicit dependency graph that nobody fully understands.
cypress/fixtures/
departments.json
users.json # depends on departments
categories.json
products.json # depends on categories
orders.json # depends on users
order_items.json # depends on orders AND products
payments.json # depends on orders
shipping.json # depends on orders AND users
reviews.json # depends on users AND products
cypress/fixtures/
departments.json
users.json # depends on departments
categories.json
products.json # depends on categories
orders.json # depends on users
order_items.json # depends on orders AND products
payments.json # depends on orders
shipping.json # depends on orders AND users
reviews.json # depends on users AND products
cypress/fixtures/
departments.json
users.json # depends on departments
categories.json
products.json # depends on categories
orders.json # depends on users
order_items.json # depends on orders AND products
payments.json # depends on orders
shipping.json # depends on orders AND users
reviews.json # depends on users AND products
Every schema migration is now a fixture migration too. The ORM models change, the API contracts change, and somewhere in that fixture directory, a JSON file silently drifts out of sync. The tests don’t fail immediately — they fail when someone runs them against a fresh database, or when CI spins up a new container, or when the specific combination of stale data and new code produces an edge case.
Five Anti-Patterns That Make It Worse
1. The Shared Test Database
The whole QA team points at one database. Tests pass locally because the data happens to be there. CI fails because somebody truncated the table. Two developers run tests at the same time and step on each other’s data. Nobody can reproduce failures because the database state is different on every machine.
Developer A: INSERT INTO users (id, email) VALUES (1, 'alice@test.com')
Developer B: INSERT INTO users (id, email) VALUES (1, 'bob@test.com')
Developer A: INSERT INTO users (id, email) VALUES (1, 'alice@test.com')
Developer B: INSERT INTO users (id, email) VALUES (1, 'bob@test.com')
Developer A: INSERT INTO users (id, email) VALUES (1, 'alice@test.com')
Developer B: INSERT INTO users (id, email) VALUES (1, 'bob@test.com')
The “fix” is usually adding random suffixes to test data, which makes assertions harder and fixture files messier.
2. Hardcoded IDs and Magic Values
cy.visit('/users/42/orders')
cy.get('[data-testid="order-row"]').should('have.length', 3)
cy.visit('/users/42/orders')
cy.get('[data-testid="order-row"]').should('have.length', 3)
cy.visit('/users/42/orders')
cy.get('[data-testid="order-row"]').should('have.length', 3)This test assumes user 42 exists and has exactly 3 orders. If the seed data changes, if somebody cleans the database, if auto-increment starts from a different number in CI — the test breaks. The failure message says “expected 3, got 0” and tells you nothing about why.
3. Fixture Files Checked Into Git
[
{ "id": 1, "user_id": 42, "total": 99.99, "status": "completed" },
{ "id": 2, "user_id": 42, "total": 149.50, "status": "pending" },
{ "id": 3, "user_id": 42, "total": 29.99, "status": "completed" }
]
[
{ "id": 1, "user_id": 42, "total": 99.99, "status": "completed" },
{ "id": 2, "user_id": 42, "total": 149.50, "status": "pending" },
{ "id": 3, "user_id": 42, "total": 29.99, "status": "completed" }
]
[
{ "id": 1, "user_id": 42, "total": 99.99, "status": "completed" },
{ "id": 2, "user_id": 42, "total": 149.50, "status": "pending" },
{ "id": 3, "user_id": 42, "total": 29.99, "status": "completed" }
]Four months ago, the orders table didn’t have a currency column, a shipping_address_id foreign key, or a NOT NULL constraint on created_at. The fixture doesn’t know about any of these. It inserts silently with defaults — until a default doesn’t exist, or a constraint rejects the row, or the application code assumes a field is present that the fixture never provided.
4. Test-Order Dependencies
describe('Order flow', () => {
it('creates a user', () => {
cy.request('POST', '/api/users', { name: 'Test' }).as('user')
})
it('creates an order for the user', () => {
cy.get('@user').then(user => {
cy.request('POST', '/api/orders', { user_id: user.id })
})
})
it('verifies the order appears in the list', () => {
cy.visit('/orders')
cy.get('[data-testid="order-row"]').should('exist')
})
})describe('Order flow', () => {
it('creates a user', () => {
cy.request('POST', '/api/users', { name: 'Test' }).as('user')
})
it('creates an order for the user', () => {
cy.get('@user').then(user => {
cy.request('POST', '/api/orders', { user_id: user.id })
})
})
it('verifies the order appears in the list', () => {
cy.visit('/orders')
cy.get('[data-testid="order-row"]').should('exist')
})
})describe('Order flow', () => {
it('creates a user', () => {
cy.request('POST', '/api/users', { name: 'Test' }).as('user')
})
it('creates an order for the user', () => {
cy.get('@user').then(user => {
cy.request('POST', '/api/orders', { user_id: user.id })
})
})
it('verifies the order appears in the list', () => {
cy.visit('/orders')
cy.get('[data-testid="order-row"]').should('exist')
})
})If “creates a user” fails or is skipped, every subsequent test fails too. You can’t run tests in parallel. You can’t run a single test in isolation. The test suite is a chain, and any broken link brings down everything after it.
5. The “Just Restore a Dump” Approach
# CI pipeline
- name: Restore test database
run: pg_restore --clean --no-owner -d testdb fixtures/test_dump.sql
# CI pipeline
- name: Restore test database
run: pg_restore --clean --no-owner -d testdb fixtures/test_dump.sql
# CI pipeline
- name: Restore test database
run: pg_restore --clean --no-owner -d testdb fixtures/test_dump.sql
Database dumps are the heavyweight version of fixture files. They’re large, they’re binary (or enormous SQL), they’re painful to diff in code review, and they drift from the schema just as fast. Plus, they often contain data from a developer’s machine — with assumptions about sequences, extensions, and PostgreSQL versions baked in.
The Test Isolation Problem
All five anti-patterns share a root cause: test data is treated as shared mutable state.
When tests share data, they can’t run in isolation. When they can’t run in isolation, they can’t run in parallel. When they can’t run in parallel, your E2E suite takes 45 minutes instead of 8. When it takes 45 minutes, developers stop running it locally. When developers stop running it locally, bugs reach CI. When bugs reach CI, the fix cycle is measured in hours instead of minutes.
The dependency chain:
Shared fixtures
-> Tests depend on each other's data
-> No parallel execution
-> Slow test suite
-> Developers skip it
-> Bugs in CI
-> Slow feedback loopsShared fixtures
-> Tests depend on each other's data
-> No parallel execution
-> Slow test suite
-> Developers skip it
-> Bugs in CI
-> Slow feedback loopsShared fixtures
-> Tests depend on each other's data
-> No parallel execution
-> Slow test suite
-> Developers skip it
-> Bugs in CI
-> Slow feedback loopsEvery serious testing framework — Playwright, Cypress, Vitest, Jest — recommends test isolation as a core principle. But isolation requires each test (or test suite) to set up its own data. And if “set up its own data” means maintaining fixture files, you’re back to square one.
Generate Data, Don’t Maintain It
The alternative is generating fresh data before each test suite run. Not from fixture files. Not from database dumps. From the schema itself.
The workflow:
Start with an empty database (or truncate tables)
Generate the data your tests need
Run the tests
Tear down
Each run gets fresh data that matches the current schema. No drift. No stale fixtures. No hardcoded IDs. If a migration adds a column, the generated data includes it automatically.
Seedfast as a Test Setup Step
# Before your E2E suite runs:
seedfast seed --scope "5 users with 3 orders each, all with payments and shipping addresses"
# Before your E2E suite runs:
seedfast seed --scope "5 users with 3 orders each, all with payments and shipping addresses"
# Before your E2E suite runs:
seedfast seed --scope "5 users with 3 orders each, all with payments and shipping addresses"
Seedfast reads your actual database schema — tables, columns, types, constraints, foreign keys — and generates data that satisfies all of them. When you add that currency column with a NOT NULL constraint, the generated data includes a currency value. No fixture file to update.
The --scope flag describes what you need in plain language. Seedfast figures out the dependency graph: users need departments, orders need users, payments need orders. You don’t maintain a loading order or a dependency map. The schema is the dependency map.
Practical Example: E-Commerce Test Suite
Say you have a Playwright test suite for an e-commerce application. The tests cover:
User registration and login
Product browsing and search
Cart operations
Checkout flow
Order history
Each of these needs different data. The traditional approach: five fixture files, carefully ordered, manually maintained. The generated approach:
# test-setup.sh
#!/bin/bash
set -e
# Truncate all tables (clean slate)
psql $DATABASE_URL -c "TRUNCATE users, products, categories, orders, order_items, payments, cart_items CASCADE"
# Generate fresh data for this test run
seedfast seed --scope "seed 10 users, 50 products across 5 categories, 20 orders with order items and payments" --output plain
# test-setup.sh
#!/bin/bash
set -e
# Truncate all tables (clean slate)
psql $DATABASE_URL -c "TRUNCATE users, products, categories, orders, order_items, payments, cart_items CASCADE"
# Generate fresh data for this test run
seedfast seed --scope "seed 10 users, 50 products across 5 categories, 20 orders with order items and payments" --output plain
# test-setup.sh
#!/bin/bash
set -e
# Truncate all tables (clean slate)
psql $DATABASE_URL -c "TRUNCATE users, products, categories, orders, order_items, payments, cart_items CASCADE"
# Generate fresh data for this test run
seedfast seed --scope "seed 10 users, 50 products across 5 categories, 20 orders with order items and payments" --output plain
Now your Playwright tests query for data instead of assuming it:
test('user can view their order history', async ({ page }) => {
const response = await page.request.get('/api/users?has_orders=true&limit=1')
const user = (await response.json())[0]
await page.goto(`/users/${user.id}/orders`)
await expect(page.getByTestId('order-row')).toHaveCount.above(0)
})
test('user can view their order history', async ({ page }) => {
const response = await page.request.get('/api/users?has_orders=true&limit=1')
const user = (await response.json())[0]
await page.goto(`/users/${user.id}/orders`)
await expect(page.getByTestId('order-row')).toHaveCount.above(0)
})
test('user can view their order history', async ({ page }) => {
const response = await page.request.get('/api/users?has_orders=true&limit=1')
const user = (await response.json())[0]
await page.goto(`/users/${user.id}/orders`)
await expect(page.getByTestId('order-row')).toHaveCount.above(0)
})No hardcoded IDs. No assumption about how many orders exist. The test verifies behavior — “a user with orders can see their order history” — not specific data values.
Scenario-Specific Seeding
Different test files can seed different scenarios. The scope is just a string — describe what you need:
# For testing empty states
seedfast seed --scope "3 users with no orders"
# For testing pagination
seedfast seed --scope "1 user with 50 orders"
# For testing search and filtering
seedfast seed --scope "100 products across 10 categories with varied prices from 5 to 500 dollars"
# For testing admin dashboards
seedfast seed --scope "50 users with mixed roles: 2 admins, 5 managers, 43 regular users, each with activity logs"
# For testing empty states
seedfast seed --scope "3 users with no orders"
# For testing pagination
seedfast seed --scope "1 user with 50 orders"
# For testing search and filtering
seedfast seed --scope "100 products across 10 categories with varied prices from 5 to 500 dollars"
# For testing admin dashboards
seedfast seed --scope "50 users with mixed roles: 2 admins, 5 managers, 43 regular users, each with activity logs"
# For testing empty states
seedfast seed --scope "3 users with no orders"
# For testing pagination
seedfast seed --scope "1 user with 50 orders"
# For testing search and filtering
seedfast seed --scope "100 products across 10 categories with varied prices from 5 to 500 dollars"
# For testing admin dashboards
seedfast seed --scope "50 users with mixed roles: 2 admins, 5 managers, 43 regular users, each with activity logs"
Each scope produces data that matches your current schema. You never specify column names, data types, or foreign key values. The schema provides those constraints; Seedfast respects them.
CI/CD Integration: Seed, Test, Clean
The workflow fits naturally into a CI pipeline. Here’s a GitHub Actions example:
name: E2E Tests
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Apply migrations
run: npm run db:migrate
env:
DATABASE_URL: postgres:
- name: Seed test data
run: seedfast seed --scope "10 users with orders, products across categories, and reviews" --output plain
env:
SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}
DATABASE_URL: postgres:
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: postgres:
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
name: E2E Tests
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Apply migrations
run: npm run db:migrate
env:
DATABASE_URL: postgres:
- name: Seed test data
run: seedfast seed --scope "10 users with orders, products across categories, and reviews" --output plain
env:
SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}
DATABASE_URL: postgres:
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: postgres:
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
name: E2E Tests
on: [pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Apply migrations
run: npm run db:migrate
env:
DATABASE_URL: postgres:
- name: Seed test data
run: seedfast seed --scope "10 users with orders, products across categories, and reviews" --output plain
env:
SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}
DATABASE_URL: postgres:
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: postgres:
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
The pipeline is straightforward: apply the current schema, generate data that matches it, run the tests. If a migration changes the schema, the generated data changes with it. No fixture file to update. No PR to fix the test data. The pipeline stays green (assuming your tests are correct).
For test suites that need isolation between test files, you can truncate and reseed between groups:
import { defineConfig } from '@playwright/test'
export default defineConfig({
globalSetup: './tests/global-setup.ts',
globalTeardown: './tests/global-teardown.ts',
})
import { defineConfig } from '@playwright/test'
export default defineConfig({
globalSetup: './tests/global-setup.ts',
globalTeardown: './tests/global-teardown.ts',
})
import { defineConfig } from '@playwright/test'
export default defineConfig({
globalSetup: './tests/global-setup.ts',
globalTeardown: './tests/global-teardown.ts',
})
import { execSync } from 'child_process'
export default async function globalSetup() {
execSync('psql $DATABASE_URL -c "TRUNCATE users, orders, products CASCADE"', {
stdio: 'inherit',
})
execSync(
'seedfast seed --scope "10 users with orders and reviews, 50 products across 5 categories" --output plain',
{ stdio: 'inherit' }
)
}
import { execSync } from 'child_process'
export default async function globalSetup() {
execSync('psql $DATABASE_URL -c "TRUNCATE users, orders, products CASCADE"', {
stdio: 'inherit',
})
execSync(
'seedfast seed --scope "10 users with orders and reviews, 50 products across 5 categories" --output plain',
{ stdio: 'inherit' }
)
}
import { execSync } from 'child_process'
export default async function globalSetup() {
execSync('psql $DATABASE_URL -c "TRUNCATE users, orders, products CASCADE"', {
stdio: 'inherit',
})
execSync(
'seedfast seed --scope "10 users with orders and reviews, 50 products across 5 categories" --output plain',
{ stdio: 'inherit' }
)
}The key constraint: seeding happens once per suite run, not once per test. Seedfast typically completes in under two minutes, which is fast for a setup step but too slow for per-test isolation. Structure your tests to read data rather than depend on specific records, and you won’t need per-test seeding.
Factory Pattern vs. Fixture Files vs. Generated Data
There are three common approaches to test data. Each has trade-offs.
Fixture Files (JSON, SQL, YAML)
[
{ "id": 1, "email": "admin@test.com", "role": "admin", "department_id": 1 },
{ "id": 2, "email": "user@test.com", "role": "user", "department_id": 2 }
]
[
{ "id": 1, "email": "admin@test.com", "role": "admin", "department_id": 1 },
{ "id": 2, "email": "user@test.com", "role": "user", "department_id": 2 }
]
[
{ "id": 1, "email": "admin@test.com", "role": "admin", "department_id": 1 },
{ "id": 2, "email": "user@test.com", "role": "user", "department_id": 2 }
]Pros: Simple to understand. Deterministic. Fast to load.
Cons: Drift from schema. Manual maintenance on every migration. Hardcoded IDs create coupling. Foreign key dependencies must be managed manually. Doesn’t scale beyond a dozen tables.
Breaks when: Any schema change that adds a NOT NULL column, changes a foreign key, or alters a constraint. Also breaks when two fixture files disagree about shared state (e.g., both reference department ID 1 but expect different department names).
Factory Pattern (Factory Bot, Fishery, test containers)
import { Factory } from 'fishery'
const userFactory = Factory.define(({ sequence }) => ({
id: sequence,
email: `user-${sequence}@test.com`,
name: `User ${sequence}`,
role: 'user',
department_id: departmentFactory.create().id,
}))
import { Factory } from 'fishery'
const userFactory = Factory.define(({ sequence }) => ({
id: sequence,
email: `user-${sequence}@test.com`,
name: `User ${sequence}`,
role: 'user',
department_id: departmentFactory.create().id,
}))
import { Factory } from 'fishery'
const userFactory = Factory.define(({ sequence }) => ({
id: sequence,
email: `user-${sequence}@test.com`,
name: `User ${sequence}`,
role: 'user',
department_id: departmentFactory.create().id,
}))Pros: Programmatic. Handles relationships. Each test creates its own data. Good test isolation.
Cons: You write and maintain a factory for every model. Every schema change requires updating the factory. Complex relationships (polymorphic associations, multi-level nesting) get messy. Factory code is yet another representation of your schema that can drift.
Breaks when: Factories get out of sync with migrations. Happens less often than fixtures because factories tend to live closer to the model code, but it still happens — especially for tables that don’t have a corresponding model (join tables, audit logs, materialized views).
AI-Generated Data (Seedfast)
seedfast seed --scope "10 users with orders across 5 product categories"
seedfast seed --scope "10 users with orders across 5 product categories"
seedfast seed --scope "10 users with orders across 5 product categories"
Pros: Zero maintenance. Reads the actual schema. Handles foreign keys, constraints, and dependency ordering automatically. Schema changes are picked up on the next run. Works for any table count.
Cons: Non-deterministic — you can’t assert on specific values, only on structural properties (e.g., “user has at least one order” rather than “user 42 has order 101”). Requires a running database and network access to Seedfast’s backend. Slower than loading a fixture file (seconds vs. milliseconds).
Breaks when: Seedfast’s backend is unavailable (mitigated by API key caching and retry logic). Also requires restructuring tests to be data-shape-aware rather than data-value-aware, which is a genuine migration effort for existing test suites.
When to Use What
Unit tests with no database — Factory pattern or in-memory fakes
Integration tests (few tables) — Factory pattern
E2E tests (full schema) — Generated data
Performance/load testing — Generated data at scale
Existing suite with hundreds of fixture files — Gradual migration: generated data for new tests, factories for legacy
The approaches aren’t mutually exclusive. Many teams use factories for unit and integration tests (where speed and determinism matter) and generated data for E2E and performance tests (where schema coverage and realistic relationships matter).
Making Tests Data-Shape-Aware
The biggest shift when moving from fixtures to generated data is how tests reference data. Instead of asserting on known values, tests query for records that match the properties they need.
Before (fixture-dependent):
test('admin can delete a user', async ({ page }) => {
await loginAs(1)
await page.goto('/admin/users/2')
await page.click('[data-testid="delete-user"]')
await expect(page.getByText('User deleted')).toBeVisible()
})test('admin can delete a user', async ({ page }) => {
await loginAs(1)
await page.goto('/admin/users/2')
await page.click('[data-testid="delete-user"]')
await expect(page.getByText('User deleted')).toBeVisible()
})test('admin can delete a user', async ({ page }) => {
await loginAs(1)
await page.goto('/admin/users/2')
await page.click('[data-testid="delete-user"]')
await expect(page.getByText('User deleted')).toBeVisible()
})After (data-shape-aware):
test('admin can delete a user', async ({ page, request }) => {
const admins = await request.get('/api/users?role=admin&limit=1')
const admin = (await admins.json())[0]
const users = await request.get('/api/users?role=user&limit=1')
const targetUser = (await users.json())[0]
await loginAs(admin.id)
await page.goto(`/admin/users/${targetUser.id}`)
await page.click('[data-testid="delete-user"]')
await expect(page.getByText('User deleted')).toBeVisible()
})test('admin can delete a user', async ({ page, request }) => {
const admins = await request.get('/api/users?role=admin&limit=1')
const admin = (await admins.json())[0]
const users = await request.get('/api/users?role=user&limit=1')
const targetUser = (await users.json())[0]
await loginAs(admin.id)
await page.goto(`/admin/users/${targetUser.id}`)
await page.click('[data-testid="delete-user"]')
await expect(page.getByText('User deleted')).toBeVisible()
})test('admin can delete a user', async ({ page, request }) => {
const admins = await request.get('/api/users?role=admin&limit=1')
const admin = (await admins.json())[0]
const users = await request.get('/api/users?role=user&limit=1')
const targetUser = (await users.json())[0]
await loginAs(admin.id)
await page.goto(`/admin/users/${targetUser.id}`)
await page.click('[data-testid="delete-user"]')
await expect(page.getByText('User deleted')).toBeVisible()
})The second version is more resilient. It doesn’t care which specific user is the admin or which user gets deleted. It tests the behavior: “an admin can delete a non-admin user.” This test survives schema changes, data reseeds, and parallel execution.
Is it more code? Yes. Is it better? Also yes. The fixture version tests that a specific sequence of actions works with specific data. The data-shape version tests that the feature works with any valid data. One catches regressions in the feature. The other catches regressions in the fixture file.
A Pragmatic Migration Path
If you have an existing E2E suite built on fixtures, you don’t need to rewrite everything at once. Here’s a gradual approach:
Week 1: Add a seeding step to your CI pipeline alongside existing fixtures. Run both.
- name: Load legacy fixtures
run: psql $DATABASE_URL -f fixtures/seed.sql
- name: Seed additional data
run: seedfast seed --scope "seed 20 extra users with varied roles and order histories" --output plain
env:
SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}- name: Load legacy fixtures
run: psql $DATABASE_URL -f fixtures/seed.sql
- name: Seed additional data
run: seedfast seed --scope "seed 20 extra users with varied roles and order histories" --output plain
env:
SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}- name: Load legacy fixtures
run: psql $DATABASE_URL -f fixtures/seed.sql
- name: Seed additional data
run: seedfast seed --scope "seed 20 extra users with varied roles and order histories" --output plain
env:
SEEDFAST_API_KEY: ${{ secrets.SEEDFAST_API_KEY }}Week 2-4: Write new tests using the data-shape-aware pattern. Don’t rewrite old tests yet.
Month 2: Identify the most frequently broken fixture files (git blame tells you which ones get updated every sprint). Migrate those tests first.
Month 3+: As old fixture-dependent tests break naturally (due to schema changes), rewrite them using the generated data pattern instead of updating the fixture.
The end state: your fixture directory shrinks over time. New tests never add to it. Old tests get migrated when they break. Eventually, the only test setup is a single seedfast seed command and whatever application-level factory code you need for unit tests.
What This Gets You
The payoff isn’t just fewer flaky tests. It’s a structural change in how your team thinks about test data:
Schema changes stop breaking tests. A migration adds a column, and the next test run generates data with that column populated. No fixture update PR. No “fix tests” commit after every schema change.
Test isolation becomes the default. Each CI run gets a fresh database with fresh data. No shared state. No ordering dependencies. Tests can run in parallel without stepping on each other.
New developers can run the suite immediately. No “ask Sarah for the test database dump.” No “you need to run these 5 seed scripts in this order.” Clone, migrate, seed, test.
Your tests document your features, not your fixtures. When a test says “a user with orders can view their order history,” it’s testing that claim against any valid user with any valid orders. The test is a specification, not a script that happens to pass with one specific dataset.
Fixtures were a reasonable default when schemas were simple and teams were small. Schemas aren’t simple anymore.
Get Started | Documentation | Pricing
Seedfast generates realistic test data from your schema. No fixtures to maintain, no dumps to restore, no factories to keep in sync.