Kensio Software
Home Open Source Blog Discuss a Project
Home Expertise Technologies Open Source Blog Discuss a Project
  1. Home
  2. Blog
  3. What to include in a test object factory

What to include in a test object factory

A test object factory can define all of the values that a given test case does not care about, so that the test can focus on the relevant values.

Hugh Grigg · Kensio Software · Wednesday 29 Jul 2026

  • TypeScript
  • Node.js

The previous post was about making it easy to set up test state inside each test case, to prevent the complexity of the whole test suite growing out of control. The @kensio/part-factory package helps with that approach.

A factory provides a complete set of default values, with recursively nested strong typing. The defaults can be static or dynamic on each new creation, and the factory can optionally take dependencies so that it can interact with other parts of the system, for example by fetching or inserting data in a database.

Test data often has a common structure, which leads to a lot of boilerplate in test cases. That makes tests harder to read, because it’s not immediately clear what a test case is about.

interface OrderEvent {
  version: string;
  type: string;
  sentAt: string;
  data: {
    orderId: string;
    customerId: string;
    status: "pending" | "shipped" | "cancelled";
  };
  // ... data structure continues ad infinitum ...
}

If we’re writing a test for handling a cancelled order, we might have to write out dozens or even hundreds of lines of boilerplate just to set up all the different objects involved in the tests.

const response = await postOrderEvent(
  JSON.stringify({
    version: "1",
    type: "order.updated",
    sentAt: "2026-01-01T09:00:00Z",
    data: {
      orderId: "order-1",
      customerId: "cust-1",
      status: "cancelled",
    },
  }),
);
// ... dozens of further lines defining other test object ...

That then becomes a maintainability problem when we need to update data structures. Often this leads to teams making all new fields optional or defaulted, even when that’s not truly appropriate in the data model.

In the example above, it might be only a single line that needs to vary from the defaults for that test case. It could even be zero lines that need to differ. That’s common when there are certain object that crop up across many tests, for example a standard user entity. In those cases the repetitive boilerplate is even more detrimental to maintainability.

In the example scenario above, a VariantFactory could be helpful. We want to create an OrderEvent with a particular cancelled status.

import { DynamicFactory, VariantFactory } from "@kensio/part-factory";
import { faker } from "@faker-js/faker";

// baseline default OrderEvent factory
const orderEventFactory = new DynamicFactory<OrderEvent>(() => ({
  version: "1",
  type: "order.updated",
  sentAt: faker.date.recent(),
  data: {
    orderId: faker.string.uuid(),
    customerId: faker.string.uuid(),
    status: faker.helpers.arrayElement(["pending", "cancelled"]),
  },
}));

// cancelled OrderEvent variant factory
const cancelledOrderEventFactory = new VariantFactory(orderEventFactory, {
  data: { status: "cancelled" },
});

Note how for the VariantFactory, we only need to define the nested field that differs for that variant.

Now we can share and reuse those factories across tests:

const orderEvent = orderEventFactory.make();
const cancelledOrderEvent = cancelledOrderEventFactory.make();

const orderEventYesterday = orderEventFactory.make({
  sentAt: faker.date.recent({ days: 1 })
});

const customerId = faker.string.uuid();
const customerCancelledOrderEvent = cancelledOrderEventFactory.make({
  data: { customerId }
});

The reusable factories make it easy to define only what is relevant in the test case, and to build up test state in a readable and maintainable way.

If we later change the structure of OrderEvent, we only need to update the factory definitions, which avoids a large change across the whole test suite.

Overrides go down through the nesting

The override object that can be passed to .make() is a deep partial of a nested object. It merges with the defaults rather than replacing them whole. If we only override data.status, then the defaults for orderId and customerId survive.

For arrays, the overrides are applied by array index, so an override of ["z"] over a default value of ["a", "b"] results in ["z", "b"]. That does mean that trying to override with an empty array [] will leave the defaults untouched. For that reason, it can be better to have a baseline factory with an empty array for the default, and then define variant factories on top of that to provide particular array values as necessary.

Named parts instead of positional arguments

Another common situation is where we’ve got a test helper function that builds an object, and we need a little bit of construction logic to set up the object.

function makeOrder(
  lineItems: LineItem[],
  status: OrderStatus = "pending",
): Order {
  return new Order({
    id: "order-1",
    customerId: "cust-1",
    status,
    lineItems,
    total: lineItems.reduce((sum, item) => sum + item.price, 0),
  });
}

This tends to accumulate more and more parameters over time, until it becomes difficult to read and difficult to maintain.

We can address that situation with a MappedFactory. A mapped factory takes an input type describing the object structure (as with DynamicFactory), and also a mapper function to apply further construction logic before returning the built object.

import { MappedFactory } from "@kensio/part-factory";
import { faker } from "@faker-js/faker";

interface OrderInput {
  id: string;
  customerId: string;
  status: OrderStatus;
  lineItems: LineItem[];
}

const orderFactory = new MappedFactory<OrderInput, Order>(
  () => ({
    id: faker.string.uuid(),
    customerId: faker.string.uuid(),
    status: faker.helpers.arrayElement(["pending", "cancelled"]),
    lineItems: [],
  }),
  (input) =>
    new Order({
      ...input,
      total: input.lineItems.reduce((sum, item) => sum + item.price, 0),
    }),
);

This kind of split input / output shape is quite common in a lot of projects. The mapped factory lets us keep the maintainable defaults structure and map it into a related output structure. As with the other factories, the fields can be partially overridden in each test case as necessary.

const order = orderFactory.make({
  status: "shipped",
  lineItems: [lineItemFactory.make({ price: 5000 })],
});

The derived total is out of the way in the mapping function, so it doesn’t become a distraction in test cases. Each test case can define its line items and other fields in a straightforward way, and the mapped factory handles the construction of a valid Order object accordingly.

Keep the subject of the test explicit

Just as it’s beneficial to keep test object construction defined in one place in a test factory, it’s also beneficial to make the subject of each test case clear and explicit. Rather than defining tons of variants in separate files, it’s better to simply write out the particular field values that matter in each test case.

it("sums the line item prices", () => {
  // Given an order with two line items totalling 2000.
  const order = orderFactory.make({
    lineItems: [
      lineItemFactory.make({ price: 1200 }),
      lineItemFactory.make({ price: 800 }),
    ],
  });

  // When we calculate tax for the order...
});

That is clearer and easier to maintain than having a orderWithTwoLineItemsFactory that is only used by that single test case.

The prices are stated in the test case, because the following assertions are based on them. Other fields like an order id and a customer id can stick with default generated values from the factory, because they’re not relevant to that test case.

Test state interactions with the wider system

It’s quite common that test state is not a simple object that only exists in the test scope. An order might have to be inserted into a database or service before a test can act on it.

AsyncMappedFactory covers that by allowing us to pass in dependencies to the test factory, and having an async make() method.

import { AsyncMappedFactory } from "@kensio/part-factory";

const storedOrderFactory = new AsyncMappedFactory<
  OrderInput,
  Order,
  { orders: OrderStore }
>(
  () => ({ customerId: "cust-1", status: "pending", lineItems: [] }),
  (newOrder, { orders }) => orders.insert(newOrder),
);

const order = await storedOrderFactory.make({ status: "shipped" }, { orders });

The overrides and input / output types work the same as for MappedFactory (mapped factories can also take dependencies in the same way as async mapped factories, by the way).

We pass in the dependencies like the OrderStore when we call .make(). This is so that the factory definition stays relatively simple, and does not need to share state. We want test state to belong to test cases.

This way, a test case can own its state including things like databases and services, and pass them into the factory. This decouples test cases from each other, as well as decoupling the factories from the rest of the test suite.

This works best when the factory dependencies are as narrow as possible. For example, it’s easier to maintain if the factory only takes an instance of OrderStore, rather than a whole system or multiple services.

Related posts

  • Video: Automated documentation website updates on GitHub and AWS
  • Keep test state inside each test case
  • @kensio/yulin adds simulated Secrets Manager
  • @kensio/yulin v0.38.0 adds simulated KMS
  • @kensio/colophon social meta image npm package
  • @kensio/yulin v0.34.2 adds simulated Lambda

Latest posts

  • July 2026 @kensio/yulin v0.34.2 adds simulated IAM
  • July 2026 Video: Simulating AWS locally for website hosting with Yulin
  • July 2026 @kensio/yulin v0.24.0 adds simulated Route53
  • July 2026 @kensio/smartass assertions ESLint config rules
Kensio Software Home Open Source Blog Contact
Expertise MVP Development Web Development System Integrations APIs Database Development Cloud Software Serverless Systems E-Commerce
Technologies AWS TypeScript AWS Lambda Node.js Python Terraform PostgreSQL GCP Firebase MySQL
Elsewhere YouTube npm GitHub LinkedIn X

Kensio Software 2026