Reach for this skill when you need to manufacture valid, realistic test data and the pain is fixtures that rot or tests coupled to a giant shared dataset:
-
Prefer factories over shared fixtures — fixtures are the anti-pattern you're replacing. A global users.yml/seed SQL becomes load-bearing: tests depend on user(:admin) having exactly these fields, so any edit ripples across the suite and tests silently couple to unrelated data ("mystery guest"). Factories invert this: a build(:user) is valid by default, and each test overrides only the attributes it asserts on. Pick the idiomatic library:
| Stack | Library | Build vs persist |
|---|
| Ruby / Rails | factory_bot | build(:user) (RAM) · create(:user) (DB) · build_stubbed (no DB, fake id) · attributes_for (hash) |
| Python / Django | factory_boy (DjangoModelFactory) or model-bakery (baker.make/baker.prepare) | .build() vs .create() / prepare vs make |
| Python (plain) | factory_boy Factory + faker | .build() only |
| JS / TS | Fishery (.build()), @mswjs/data, Fabbrica (Prisma), @faker-js/faker | build returns object; persist via your ORM |
| PHP / Laravel | Foundry or Eloquent factories + fakerphp/faker | Model::factory()->make() vs ->create() |
| Java / Kotlin | instancio, easy-random, datafaker (Java-faker successor) | POJO in memory |
| Go | go-faker/gofakeit + hand-rolled builder funcs | struct in memory |
-
Make the default object minimally valid; override per test. The factory's defaults must pass all model validations on their own so create(:user) never fails for an unrelated reason. Then a test passes only what it cares about:
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
role { "member" }
end
admin = create(:user, role: "admin")
const userFactory = Factory.define<User>(({ sequence }) => ({
id: sequence,
email: `user${sequence}@example.test`,
role: 'member',
}));
userFactory.build({ role: 'admin' });
Use the reserved example.test/example.com domains and the +tag trick for emails so generated data never hits a real inbox.
-
Use Faker for realism, but never for fields you assert on. Faker gives plausible names/emails/addresses/companies so data looks real and surfaces formatting bugs. The discipline: if a test checks a value, set it explicitly; let Faker fill the rest. Asserting against a Faker-generated value is a guaranteed flake. Pin locale (Faker::Config.locale = :en / faker.setLocale) so address/phone formats are stable across machines, and use unique generators (Faker::Internet.unique.email, faker.helpers.unique — note @faker-js/faker removed unique; use a sequence or faker.string.uuid instead) for columns under a UNIQUE constraint.
-
Sequences for unique/monotonic fields; transient params for build-time knobs that aren't attributes. Sequences (sequence(:email) { |n| "user#{n}@example.test" }) guarantee uniqueness without a global mutable counter. Transient/transient-params are inputs that shape the build but aren't columns:
factory :order do
transient { line_item_count { 3 } }
after(:create) do |order, ev|
create_list(:line_item, ev.line_item_count, order: order)
end
end
create(:order, line_item_count: 5)
factory_boy calls these Params/class Params: ... with factory.Trait; Foundry uses ->with()/states. Reach for them whenever a test wants "an order with N items" without N being an order column.
-
Model variants with traits, not a forest of sub-factories. A trait is a named bundle of attribute overrides; compose several in one call. This beats factory :admin_user, factory :suspended_admin_user, … which explodes combinatorially:
factory :user do
trait(:admin) { role { "admin" } }
trait(:suspended) { suspended_at { Time.current } }
factory :premium { plan { "premium" } }
end
create(:user, :admin, :suspended)
factory_boy → class Params: + factory.Trait; Fishery → .params() + transient transientParams, or named factory variants; model-bakery → recipes. One base factory + traits is the maintainable shape; deep factory inheritance is not.
-
Build object graphs through associations — don't hand-wire foreign keys. Declare relations so the factory creates the whole graph and back-references resolve automatically:
| Lib | Association syntax |
|---|
| factory_bot | association :customer · customer { create(:customer) } · create_list(:item, 3, order:) |
| factory_boy | customer = factory.SubFactory(CustomerFactory) · factory.RelatedFactory (reverse) · factory.List |
| Fishery | customer: customerFactory.build() inside the generator, or associations param |
| Foundry | CustomerFactory::new() as an attribute; ->many(3) for collections |
Build the minimal graph the test needs — pulling in 4 levels of associations slows every test and recreates the fixture problem. Use build/build_stubbed (no DB) for pure-logic tests; reserve create for tests that actually query the DB. Guard against accidental N× object creation in before hooks.
-
Seed dev/E2E databases idempotently — upsert, never blind insert. A seed script that INSERTs breaks on the second run (unique-constraint violation) and corrupts state. Make it re-runnable:
User.find_or_create_by!(email: "demo@example.test") { |u| u.name = "Demo" }
INSERT INTO plans (code, name) VALUES ('pro','Pro')
ON CONFLICT (code) DO UPDATE SET name = EXCLUDED.name;
Rules: key every seed row on a stable natural key (slug/code/email), not an auto-id; wrap the whole seed in a transaction; make db:seed / prisma db seed / php artisan db:seed safe to run N times with identical end state. Separate dev seed (rich demo data, may be large) from E2E/test seed (minimal, deterministic, reset between runs via truncate or transactional rollback). For E2E, prefer planting rows via the factory/seeder directly over driving the UI — orders of magnitude faster and less flaky.
-
Make data deterministic in CI — pin the RNG seed and locale. Random factory data that flakes is worse than fixtures. Pin a seed so a failing CI run is reproducible:
| Tool | Seed control |
|---|
| Faker (Ruby) | Faker::Config.random = Random.new(RSEED) |
| @faker-js/faker | faker.seed(12345) (per-suite, in a beforeEach/global setup) |
| faker (Python) | Faker.seed(0) / fake.seed_instance(0) |
| factory_boy | factory.random.reseed_random('seed') |
| RSpec / Jest | --seed/config.seed; Jest --testSequencer + faker seed |
Print the seed on every run and let CI re-run with a fixed seed to reproduce a failure. Pin the locale too — default-locale drift changes address/phone formats and breaks format-sensitive assertions. Don't share mutable factory state across parallel test workers; each worker reseeds.
-
For prod-like datasets, anonymize — synthesize or mask, never copy raw PII. Staging/perf data that mirrors prod shape without leaking real people: (a) synthesize from factories at volume (create_list(:user, 100_000) / a Faker loop) when you only need realistic shape; (b) mask/anonymize a prod snapshot when you need real distributions — replace names/emails/SSNs with Faker values, deterministically (hash the original → same fake every time, so foreign keys stay consistent), null or tokenize free-text, and shift dates by a constant offset. Tools: pg anon extension, pganonymize, Snaplet/@snaplet/seed, faker + a mapping table. Never load an un-anonymized prod dump into a lower environment — that's a PII breach. Keep the anonymization mapping out of the lower environment.
-
Keep factories close to the code and validated. Co-locate factories with the test suite (spec/factories, test/factories, src/test-utils/factories), auto-load them, and add a CI lint that builds every factory and asserts it's valid (factory_bot's FactoryBot.lint) so a schema/validation change that breaks a factory fails fast instead of in 50 unrelated tests. When a column is added with a NOT NULL/validation, fix it in the one factory, not across the suite.
Done = brittle shared fixtures are gone, each test declares only the fields it cares about against a valid-by-default factory, Faker fills the rest with a pinned seed+locale so CI is reproducible, object graphs and traits compose without sub-factory explosion, the dev/E2E seed is idempotent on a natural key, and any prod-like data is deterministically anonymized — all proven by the factory lint, the twice-with-same-seed run, and the double-seed idempotence check.