Stop hardcoding test data: TypeScript Builders + Faker.js for Playwright

I keep finding the same pattern in every Playwright codebase I audit. Someone wrote a test six months ago with firstName: 'Test' and email: 'test@example.com', and now that exact object is copy-pasted across 40 spec files with minor variations that nobody remembers the reason for.
Then a product owner asks to add a phone number to the contact form. One new required field, and suddenly 40 tests fail because none of them provide a phone property. You spend half a day doing find-and-replace across the repo, and the real work (testing the actual feature) hasn't even started.
Sound familiar? Maybe because it is?
What's actually wrong with hardcoded test data
The copy-paste problem is obvious, but there are subtler issues that hurt more over time.
When every test uses test@example.com as the email, you can't run tests in parallel. Two tests try to create an account with the same address, one fails, and you spend an hour debugging what turns out to be a data collision. Flaky tests are expensive, and shared test data is one of the most common causes.
There's also the realism problem. Real users aren't called "Test User". They're called François O'Brien and María José García-López. They paste 200-character messages into fields that your front-end caps at 150. They use email addresses with dots, hyphens, and subdomains that your regex didn't anticipate. When every test uses test@example.com, you're only testing the happy path of the happy path, which makes it super happy I guess.
The fix isn't more discipline about maintaining shared constants. It's a pattern that generates valid, realistic, unique data on every run, and that only requires changes in one place when the data shape evolves.
Three files, that's the whole pattern
The idea is to combine two things: the builder pattern (a creational design pattern where you construct objects step by step through a fluent API instead of passing everything to a constructor) and Faker.js (a library that generates realistic fake data: names, emails, addresses, phone numbers, dates, anything you'd find in a real form). The builder handles the shape of your test data, Faker handles the content. Together, they need exactly three files. A type definition, an abstract base builder, and a concrete builder per data shape. Everything else is just usage.
The type
This is the contract. It mirrors what your application's form expects.
// src/data/types/index.ts
export type ContactSubject =
| "customer-service"
| "webmaster"
| "return"
| "payments";
export interface ContactFormData {
firstName: string;
lastName: string;
email: string;
subject: ContactSubject;
message: string;
}
These are test-side types. They represent the data shape your tests work with, not the API response schema. Keep them aligned with the application, but they're yours to control.
The base builder
A generic abstract class that every builder extends. It provides build() and buildMany(), nothing else.
// src/data/builders/base.builder.ts
export abstract class Builder<T> {
protected data: Partial<T>;
protected constructor(defaults: T) {
this.data = { ...defaults };
}
build(): T {
return { ...this.data } as T;
}
buildMany(count: number, overrides?: (index: number) => Partial<T>): T[] {
return Array.from({ length: count }, (_, i) => {
const base = this.build();
return overrides ? { ...base, ...overrides(i) } : base;
});
}
}
Two design decisions worth explaining here. The protected constructor forces subclasses to expose a static create() factory method, which reads better than new ContactBuilder() in test code. And buildMany() takes an index-based override function, which is useful when you need sequential IDs or unique emails across a list.
The concrete builder
This is where Faker comes in. The constructor provides realistic defaults for every field.
// src/data/builders/contact.builder.ts
import { faker } from "@faker-js/faker";
import type { ContactFormData, ContactSubject } from "../types";
import { Builder } from "./base.builder";
const SUBJECTS: ContactSubject[] = [
"customer-service",
"webmaster",
"return",
"payments",
];
export class ContactBuilder extends Builder<ContactFormData> {
private constructor() {
super({
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
subject: faker.helpers.arrayElement(SUBJECTS),
message: faker.lorem.sentence({ min: 5, max: 20 }),
});
}
static create(): ContactBuilder {
return new ContactBuilder();
}
withEmail(email: string): this {
this.data.email = email;
return this;
}
withSubject(subject: ContactSubject): this {
this.data.subject = subject;
return this;
}
withMessage(message: string): this {
this.data.message = message;
return this;
}
}
Every call to create() generates fresh, unique, realistic data. The with* methods return this for fluent chaining, and you only write them for fields that tests actually need to control. Not every field deserves a setter.
Notice that the SUBJECTS array is typed as ContactSubject[], not string[]. If someone adds a new subject to the union type but forgets to update the builder, the compiler catches it.
How you actually use it
Default everything
The most common case: you don't care what the data is, you just need valid input.
test('should submit successfully with valid data', async ({ contactPage }) => {
await contactPage.fillContactForm(ContactBuilder.create().build());
await contactPage.submitForm();
await contactPage.expectSuccessMessage();
});
This test says "I'm testing that form submission works, not that it works with specific data." Every run uses a different name, a different email, a different message. The behavior is what matters, not the content.
Override only what the test cares about
When a test is specifically about the subject field, make that explicit and let the builder handle the noise.
test('should route to correct department', async ({ contactPage }) => {
const contact = ContactBuilder.create()
.withSubject('customer-service')
.build();
await contactPage.fillContactForm(contact);
await contactPage.submitForm();
await contactPage.expectRoutedTo('customer-service');
});
The reader instantly knows this test is about routing. The first name, last name, email, and message are irrelevant to the assertion, and the code reflects that.
Build a list with unique values
When you need multiple records with guaranteed unique identifiers, buildMany() takes an index-based override function.
const contacts = ContactBuilder.create().buildMany(5, (i) => ({
email: `user-${i}@testing.com`,
}));
// 5 contacts with random names but sequential, unique emails
Semantic methods for readability
Not every override method needs to follow the withX(value) pattern. Sometimes a method that describes the intent reads better than one that describes the field.
// In a ProductBuilder
outOfStock(): this {
this.data.inStock = false;
return this;
}
// In test code
const product = ProductBuilder.create().outOfStock().build();
Compare outOfStock() with withInStock(false). Both do the same thing, but the first one reads like English. Same idea works for methods like asAdmin(), expired(), or withInvalidEmail().
Free fuzzing: how random data catches real bugs
Here's something you don't get with "Test User" and "test@example.com": accidental bug discovery.
Faker generates names from real name databases. That means sooner or later it will produce O'Brien as a last name. If your form validation uses a regex like /^[a-zA-Z\s]+$/, that apostrophe breaks it. You just found a bug that no manual tester thought to check for, because nobody writes O'Brien in their test data on purpose.
I've seen this happen in production. A French-Canadian user named François couldn't submit a contact form because the back-end validation rejected the cedilla. The test suite had been green for months because every test used ASCII-safe names.
Another example: Faker's internet.email() can generate addresses like alejandra.gutiérrez-mendoza@my-company.example.com, which is 53 characters long. If your HTML input has maxlength="50", the browser silently truncates the address, and the user submits a broken email without any visible error. With test@example.com (16 characters), you'd never catch that.
This isn't theoretical. Every run of your test suite becomes a lightweight fuzz test. You're exercising edge cases you didn't even think to write, and it costs you nothing.
Controlling Faker's output
Faker doesn't just generate random noise, you can constrain its output to match your application's actual field requirements. Here are the patterns I use most often.
Text with length constraints
Faker's lorem module works with word and sentence counts, not character counts. If you need a message between 40 and 200 characters, you need a small helper:
function fakerText(minChars: number, maxChars: number): string {
const target = faker.number.int({ min: minChars, max: maxChars });
const raw = faker.lorem.paragraphs(3);
return raw.substring(0, target);
}
// In your builder
message: fakerText(40, 200),
For simpler cases where word count is a good enough proxy, Faker handles it natively:
// Between 3 and 8 words
faker.lorem.words({ min: 3, max: 8 })
// A sentence between 5 and 15 words long
faker.lorem.sentence({ min: 5, max: 15 })
// 2 to 4 full paragraphs
faker.lorem.paragraphs({ min: 2, max: 4 })
Numbers in a range
Prices, quantities, ratings, anything with bounds:
faker.number.int({ min: 1, max: 500 }) // integer
faker.number.float({ min: 0, max: 100, fractionDigits: 2 }) // 2 decimal places
Picking from a typed list
When a field only accepts specific values (dropdowns, enums, radio buttons):
faker.helpers.arrayElement(["customer-service", "webmaster", "return"])
Combined with TypeScript union types, the compiler ensures your list stays in sync with the type definition.
Optional fields
When a field is only present sometimes, maybe() gives you weighted randomness:
phone: faker.helpers.maybe(() => faker.phone.number(), { probability: 0.5 }),
Half the time you get a phone number, half the time you get undefined. This is great for testing that your form handles optional fields correctly in both cases.
Fixed-length strings
For fields like reference codes, postal codes, or identifiers that need a specific format:
faker.string.alphanumeric({ length: 8 }) // "k2F8a3Bc"
faker.string.alpha({ length: { min: 5, max: 10 } }) // "HcVrCf"
faker.string.numeric({ length: 6, allowLeadingZeros: true }) // "034891"
Weighted booleans
When you want a flag to be true most of the time but not always:
faker.datatype.boolean({ probability: 0.8 }) // true 80% of the time
Useful for inStock, isActive, isVerified fields where you want the default to lean towards the common case.
Adding a new builder: the recipe
Once you have the base class, every new data shape follows the same three steps.
Step 1: define the type.
export interface Product {
name: string;
price: number;
category: string;
inStock: boolean;
}
Step 2: create the builder class.
export class ProductBuilder extends Builder<Product> {
private constructor() {
super({
name: faker.commerce.productName(),
price: Number.parseFloat(faker.commerce.price({ min: 1, max: 500 })),
category: faker.commerce.department(),
inStock: faker.datatype.boolean({ probability: 0.8 }),
});
}
static create(): ProductBuilder {
return new ProductBuilder();
}
withPrice(price: number): this {
this.data.price = price;
return this;
}
outOfStock(): this {
this.data.inStock = false;
return this;
}
}
Step 3: use in tests.
const expensive = ProductBuilder.create().withPrice(999.99).build();
const unavailable = ProductBuilder.create().outOfStock().build();
That's the whole rhythm. Type, builder, usage. You can add a new builder in under five minutes once the pattern clicks.
When NOT to use a builder
Builders don't replace all hardcoded data. When a test exists because of a specific bug report ("user with email john+test@gmail.com gets a 500 error"), hardcode that exact value. The test is documentation of the bug, and randomizing the input would defeat its purpose.
Hardcoded data is also the right call for contract tests where the exact payload matters, or for snapshot-based tests where determinism is required. You can always seed Faker with faker.seed(12345) to get repeatable output, but at that point you've lost the fuzzing benefit, so consider whether a hardcoded fixture would be clearer.
The rule of thumb: if the test is about behavior, use a builder. If the test is about specific data, hardcode it.
Where this fits in a Playwright project
In Histrion's architecture, builders sit in the utilities layer alongside API helpers, config, and the logger. They're consumed by tests and occasionally by fixtures for data seeding, but they never depend on pages, components, or anything in the UI layer.
Tests → import builders, call .create().build()
Fixtures → sometimes seed data via builders
Pages → receive data, fill forms
Components → know nothing about test data
Core → knows nothing about test data
Builders → src/data/builders/ (lives here, depends on nothing above)
The separation is clean: the builder generates data, the page object consumes it, and the test orchestrates both. No layer reaches into another's responsibilities.
If you want this whole setup scaffolded automatically (builders, page objects, fixtures, config, CI pipeline, and 16 documentation guides), Histrion generates it with npx histrion create. The builder pattern described in this article is baked in from the start. But the pattern works just as well if you copy base.builder.ts into your existing project and start from there.
Either way, your tests stop depending on "Test User", and they start catching bugs you didn't know you had.






