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.