鄧 Emblem
Kevin Dang

Test Data Factories and Builders: Smarter Ways to Set Up Your Tests

A practical look at test data factories and builders — what they are, how they reduce duplication, and when to use one over the other in your test.Published on: May 23, 2025

A factory? Like the ones with smoke coming out of them?? ...not quite!

I first came across the concept of test data factories when I noticed a recurring pattern at work where the same "shape" of data was being stubbed across different test files. This led to a lot of duplicated boilerplate code and naturally, people's "code smell" senses started tingling...

The logical first step to try to fix this was to extract out the repeated data into a single const in a shared utility file. Doing this allowed us to import the same stub wherever it was needed.

I'm going to use cakes as the theme for my examples — don't ask why 🥲

/* src/testing/utils/stubData.ts */
type FoodRecipe = {
  type: 'starter' | 'main' | 'dessert';
  ingredients: Ingredients;
}

type Ingredients = Record<string, IngredientAmount>;

type IngredientAmount = {
  amount: number;
  unit: string;
}

/* Your stub */
export const CHOCOLATE_CAKE_RECIPE: FoodRecipe = {
  type: 'dessert',
  ingredients: {
    cocoaPowder: {
      amount: 50,
      unit: 'g',
    },
    flour: {
      amount: 200,
      unit: 'g',
    },
  },
  ...
};

/* src/app/someExample.spec.ts */
import { CHOCOLATE_CAKE_RECIPE } from '@testing/utils';

describe('Cake baking', () => {
  it('should bake a chocolate cake', () => {
    const result = bakeCake(CHOCOLATE_CAKE_RECIPE);

    expect(result).toBe(...);
  });
});

A quick summary on the above: we're creating a stub called CHOCOLATE_CAKE_RECIPE that lives in a shared location and is exported. We then import it in a test file to help with creating an "entity" - in this case, a chocolate cake.

The idea here is that there will be other test files that may also need to create a cake with this specific configuration, and so you would just import the stub rather than redefining it in multiple places.

Nice, that solves the duplication problem! You define the data once, reuse it, and if it changes, you update it in one place ...But wait a minute — that’s not always a good thing. Changing a shared stub could unintentionally break other test suites that rely on the original values.

Say you're writing a test that needs to make assertions about a chocolate cake recipe that uses 100g of cocoaPowder instead of 50g? You can't just go and change the existing CHOCOLATE_CAKE_RECIPE stub, because that could break other tests expecting the original amount.

To allow for this, and keeping with the theme of the approach, you'd need to define a new separate stub in stubData.ts, like this:

/* src/testing/utils/stubData.ts */
export const CHOCOLATE_CAKE_RECIPE: FoodRecipe = {
  type: 'dessert',
  ingredients: {
    cocoaPowder: {
      amount: 50,
      unit: 'g',
    },
    flour: {
      amount: 200,
      unit: 'g',
    },
  },
  ...
}

/* ✨ New stub! ✨ */
export const CHOCOLATE_CAKE_100g_RECIPE: FoodRecipe = {
  type: 'dessert',
  ingredients: {
    cocoaPowder: {
      amount: 100, // <-- yay! more chocolate! 🍫
      unit: 'g',
    },
    flour: {
      amount: 200,
      unit: 'g',
    },
  },
  ...
}

You might start to see a new problem emerging. Although creating these stubs helps us reduce code duplication, we still end up with duplication — just in a different form.

Another downside — especially in a real-world work setting — is that creating these stub objects often requires domain knowledge. Chocolate cakes and recipes are universal, so it’s relatively easy to guess the shape of the object. But when you're dealing with domain-specific data, it's not nearly as intuitive.

This makes things harder for newcomers or developers from other teams who are just dipping into the project to make a small change. They might not know what shape the data should take, or which fields are important — which adds friction to writing tests.

Defining stubs like this feels more like an interim solution. It fixes the issue of having to redefine the same type of data across multiple test files, but the developer experience doesn't feel so clean.

So how do we fix that? This is where test data factories shine.

Test data factories

A test data factory is a function that returns structured test data — often with sane defaults — while letting you override only what you care about.

There's nothing "special" about the implementation itself — it's essentially just a function that makes it easier to compose your data. Take a look at the below:

export function makeChocolateCake(ingredientOverrides: Partial<Ingredients> = {}) {
  const baseIngredients: Ingredients = {
    cocoaPowder: {
      amount: 50,
      unit: 'g',
    },
    flour: {
      amount: 200,
      unit: 'g',
    },
    ...
  };

  return {
    type: 'dessert',
    ingredients: {
      ...baseIngredients,
      ...ingredientOverrides,
    },
  };
}

If we revisit the example from earlier, we can now use this test data factory to construct stubs like this:

const CHOCOLATE_CAKE_100g_RECIPE = makeChocolateCake({
  cocoaPowder: { amount: 100, unit: 'g' },
});

const CHOCOLATE_CAKE_200g_RECIPE = makeChocolateCake({
  cocoaPowder: { amount: 200, unit: 'g' },
});

Much simpler and quicker, wouldn't you agree?

If you're writing a test and need a "chocolate cake", you can just use makeChocolateCake() — and override only what matters! This is important because developers can tweak just what they need, without redefining every field just to get the right data shape.

This is pretty good, and works really well for our chocolate cake example but there is one caveat... This pattern works great for simple data, but in real world cases, you will find yourself working with a lot more complex objects (e.g. nested properties), and these factory functions can start to feel clunky.

This isn't to say that factories are no good, they work really well in the appropriate setting. But there's a way to elevate test data creation even more and that's where builders come in.

Enter "builders" ...no, not construction people!

Builders let you build up test data in a more composable and readable way, particularly for more complex objects.

I’ll stick with the cake theme however — it still does a great job illustrating the concept. Here’s what a builder could look like:

const DEFAULT_RECIPE: FoodRecipe = {
  ... // Some suitable defaults
};

class ChocolateCakeFactory {
  protected state: FoodRecipe;

  public constructor(defaults: FoodRecipe = DEFAULT_RECIPE) {
    this.state = structuredClone(defaults);
  }

  public withCocoaPowder(amount: IngredientAmount): this {
    this.defaults.ingredients.cocoaPowder = amount;
    return this;
  }

  public withFlour(amount: IngredientAmount): this {
    this.defaults.ingredients.flour = amount;
    return this;
  }

  public build(): FoodRecipe {
    return structuredClone(this.state);
  }
}

To use this, your code might end up looking like this:

const chocolateCake = new ChocolateCakeFactory()
  .withCocoaPowder({ amount: 100, unit: 'g' })
  .withFlour({ amount: 200, unit: 'g' })
  .build();

I really love the chaining here as it drives home the feeling of constructing something bit by bit - this incremental composition is what distinguishes it as the builder pattern. It also reads like prose which has benefits for both the writer and reader in my opinion.

💡 There's a name for this chaining approach - it's called the Fluent Interface style!

You'll notice that the builder chain always ends with a .build() method — that's the part that returns the final object. All the withX() methods return the builder instance itself, which enables fluent chaining.

Typically, for every field in the defaults, you would create a corresponding method to set it. This does mean that the initial setup can be a little tedious, but it's a one time setup affair so it's not too bad.

Additionally, you're not limited to methods that only update one field at a time. You can create more tailored methods that have a bigger responsibility of updates. For example, if you wanted to increase the quantity of cake, you'd normally need to multiply your ingredient amounts by a ratio. It can be quite frustrating then to imagine a really long chain of methods to build this up for all your ingredients. However, there's nothing stopping you from defining a new method — say something called withScaledIngredients — that handles calculating the final amounts and applying it to all the fields in one go:

class ChocolateCakeFactory {
  ... // Other methods still exist

  public withScaledIngredients(multiplier: number): this {
    for (const key in this.defaults.ingredients) {
      this.defaults.ingredients[key].amount *= multiplier;
    }

    return this;
  }
}

const lotsOfChocolateCake = new ChocolateCakeFactory()
  .withScaledIngredients(2)
  .build();

I find these utility methods really helpful. In practice, it can be hard to anticipate and define them when you first create your builder class. Instead, they tend to emerge naturally as the need for them becomes apparent during development.

Closing thoughts

I hope you’ve learned something new here and can see the benefits of employing these patterns! I believe they help make your tests easier to read and simplify managing test data.

I would also recommend creating a base factory class to handle common boilerplate, since you’ll often repeat setup across different builders. For example:

export class BaseFactoryBuilder<T> {
  protected state: T;

  public constructor(defaults: T) {
    this.state = structuredClone(defaults);
  }

  public build(): T {
    return structuredClone(this.state);
  }
}

/* Then in some separate file */
export class ChocolateCakeFactory extends BaseFactoryBuilder<FoodRecipe> {
  ...
}

Thanks for dropping by and taking the time to read through this!

Back to all posts