Por qué usar factories en lugar de fixtures estáticos
Los fixtures hardcoded generan acoplamiento brutal: cambias un campo de User y rompes 47 tests. Las factories generan datos bajo demanda con valores razonables por default y override quirúrgico donde importa. Un test de login solo necesita email/password válidos; el resto (createdAt, address, preferencias) puede ser ruido faker. Tres ventajas clave: 1) Tests aislados sin dependencias de orden, 2) Setup explícito de lo relevante (user.withRole('admin')), 3) Mantenimiento centralizado cuando el schema evoluciona.
Factory Boy de Python y FactoryBot de Ruby popularizaron el patrón. En JS, libraries como Fishery (TypeScript-first) o Rosie te dan DSLs fluidos. El secret está en los traits: en vez de copypastear userFactory() 20 veces con tweaks, definís .pending(), .verified(), .suspended() y componés. PHPUnit no tiene factory nativa, pero DataProviders + builders custom funcionan igual.
Errores comunes: llenar factories con lógica de negocio (eso va en la entidad), generar IDs que colisionan (usá sequences o UUIDs), y no limpiar side-effects (si la factory persiste, tu tearDown debe borrar).
Anatomía de una factory bien diseñada
Estructura típica: defaults sensatos (faker para nombres, enums válidos, foreign keys null-safe), traits componibles (withOrders, expired, premium), lazy evaluation (createdAt se resuelve al momento de build, no al definir la factory), y hooks (afterBuild para cálculos derivados, afterCreate para llamadas a API externas si es integration test).
Ejemplo en TypeScript con Fishery:
const userFactory = Factory.define<User>(({ sequence, afterBuild }) => ({
id: sequence,
email: faker.internet.email(),
role: 'user',
createdAt: new Date()
})).trait('admin', { role: 'admin' });Ahora userFactory.admin().build() te da un admin, y .build({ email: 'test@foo.com' }) hace override quirúrgico. El sequence garantiza IDs únicos por test run.
Para relaciones: lazy attributes. Si Order tiene customerId, la factory de Order puede recibir un customer opcional o generar uno default con customerFactory.build(). Así evitas explosion combinatoria de fixtures.
Factories para APIs y mocks de red
Testing de frontends o integraciones con APIs implica mockear responses HTTP. Factories aca brillan: definís responseFactory con status 200, headers default, body según schema OpenAPI/GraphQL. Traits para errores: .notFound(), .serverError(), .rateLimited().
Libraries como MSW (Mock Service Workers) se integran perfecto: interceptas fetch(), devolvés responseFactory.success({ data: userFactory.buildList(5) }). Tus tests quedan semánticos: 'when API returns empty list' usa .build({ data: [] }); 'when user is banned' usa userFactory.banned().
Caso real: GraphQL con nodos paginados. Tu factory genera edges con cursor, pageInfo con hasNextPage calculado, y nodes con el schema correcto. Snapshots de Jest validan la forma; factories te dan variedad (página vacía, un item, página llena, error parcial).
Truco pro: exporta factories desde __tests__/factories para reuso cross-suite. Un userFactory usado en 30 archivos es mejor que 30 copias levemente distintas.
Seeders deterministas y tests no-flaky
Faker random causa flakiness: un test pasa con 'John Doe' pero falla con 'X Æ A-12' porque tu validador regex rompe. Solución: seed fijo por test. En beforeEach: faker.seed(12345). Ahora cada run genera la misma secuencia pseudoaleatoria. CI y local dan idénticos resultados.
Alternativa: valores hardcoded para campos sensibles (email siempre 'test@example.com'), faker solo para relleno cosmético (bio, avatar URL). Algunos equipos usan snapshot factories: la primera corrida genera JSON fixtures, commits siguientes los reusan (package jest-serializer-factory).
Para tests de integración que tocan DB real, las factories deben hacer cleanup. En Jest: afterEach(async () => { await cleanupUsers(createdIds); }). Track IDs generados en un Set, borrá todo al final. TestContainers + factories = paraíso: cada test tiene su Postgres efímero, factories populan, test corre, container muere.
Edge cases: factories de dates. Si tu lógica depende de 'hace 3 días', usa subDays(new Date(), 3) en la factory, no un timestamp hardcoded que quedará obsoleto. Lo mismo timezone: factories deben generar UTC o el TZ de tu app, nunca mezclar.