QA Interview Hub · Knowledge base · Quizzes

Patterns

Builder and Test Data Builder

Test Data Builder: the problem and the solution

Test objects in fintech are large: a payment has an amount, a currency, accounts, a status, dates, metadata. If each test assembles such an object by hand, you get noise, and when a required field is added all the tests break.

A Test Data Builder creates an object with valid default values and lets you change only what matters for a specific test.

export class PaymentBuilder {
  private data: Payment = {
    amount: 100,
    currency: 'EUR',
    status: 'pending',
    from: 'UA001',
    to: 'UA002',
  };

  withAmount(amount: number) { this.data.amount = amount; return this; }
  withCurrency(c: Currency) { this.data.currency = c; return this; }
  failed() { this.data.status = 'failed'; return this; }

  build(): Payment {
    return { ...this.data };
  }
}
const payment = new PaymentBuilder().withAmount(0).failed().build();

The test immediately shows what matters in it: a zero amount and the status failed. The other fields are simply valid. The methods return this, so a fluent chain works. Named methods like failed() express the domain language better than withStatus('failed').

build() returns a copy, so that a repeated call doesn't hand out the same mutable object.

In an interview: A builder gives a valid object by default, and the test changes only the fields that matter for the scenario. That way the test reads better, and I add a new required field in one place.

A Builder with faker, determinism and when a Builder is unnecessary

Defaults are often generated through @faker-js/faker to avoid conflicts on unique fields in parallel runs:

import { faker } from '@faker-js/faker';

export class UserBuilder {
  private data: NewUser = {
    email: faker.internet.email(),
    firstName: faker.person.firstName(),
    password: 'Str0ng!Pass',
  };

  withEmail(email: string) { this.data.email = email; return this; }
  build() { return { ...this.data }; }
}

Pitfalls:

  • Non-reproducibility. A test failed on a specific name with an apostrophe, and on a rerun the data is different. The solution: log the generated data or use faker.seed() for reproduction.
  • Randomness in values the check depends on. If a test checks an amount, the amount must be set explicitly, not taken from faker.
  • Defaults at the class field level are generated when an instance is created, so each new UserBuilder() gives a new user. If you move a default into a static constant, all tests get the same email.

When a Builder is NOT needed: an object with 2-3 fields. Then a function with Partial is enough: makeUser({ email }). In TypeScript object spread does much of what a Builder is needed for in Java. In an interview exactly this awareness sounds good, not "I make builders everywhere".

In an interview: For unique fields I use faker, but the values a test verifies I set explicitly, and I log the generated data so a failure can be reproduced. For simple objects I get by with a factory function with Partial, because a Builder is unnecessary there.