How To Write Unit Tests - Organize Test Data
Tests are code too, and they deserve the same attention as production code. When production code changes, numerous test files often require updates, and that is a maintenance penalty you need to pay. Test quality issues often have multiple origins. Sometimes poorly written production code itself is the issue, but sometimes it's how test data is organized and structured.
This article explores best practices for organizing test data to enhance both readability and maintainability of your test suite.
Scenario
- A use case that returns (removes) a LineItem from an Order for a provided ReturnReason
- OrderRepository interface that is injected into the use case and is of no further importance
- A unit test that verifies all scenarios for returning a LineItem
class ReturnLineItemUseCase {
// ...
async execute(params: {
orderId: string;
lineItemId: number;
returnQuantity: number;
reason: ReturnReason;
}): Promise<Order> {
const order = await this.orderRepository.get(params.orderId);
const updatedOrder = order.returnLineItem(
params.lineItemId,
params.returnQuantity,
params.reason
);
return this.orderRepository.save(updatedOrder);
}
}
it("should return a line item from an order - VERBOSE", async () => {
// ...
const initialOrder = Order.create({
id: "1234567890",
customerId: "95446fe0-5177-427b-bac4-bd8b7cc7f4ab",
lineItems: [
{
id: 1,
orderedQuantity: 5,
returned: [{ quantity: 1, reason: "DAMAGED" }],
unitPrice: 10.35,
sku: "SKU123",
name: "Product Name",
},
],
});
// ...
const expected = Order.create({
id: "1234567890",
customerId: "95446fe0-5177-427b-bac4-bd8b7cc7f4ab",
lineItems: [
{
id: 1,
orderedQuantity: 5,
returned: [{ quantity: 3, reason: "DAMAGED" }],
unitPrice: 10.35,
sku: "SKU123",
name: "Product Name",
},
],
});
const actual = await useCase.execute({
orderId: "1234567890",
lineItemId: 1,
returnQuantity: 2,
reason: "DAMAGED",
});
expect(actual).toEqual(expected);
});
Test Data Very Verbose
The data example of Order in this article is a stripped down version of a real-life object. Real-life versions can have:
- Many properties
- Many nested objects
- Frequent changes to structure
Every time the structure of an object changes, all the test data that depends on it needs to be updated as well. It can happen that an addition of a single property requires changing a dozen files.
Tests should ideally test one thing only. The test setup should make it clear which thing that is. If the test data is too verbose, it is difficult for the reader to understand what is being tested and which part of the data is relevant for the test.
Build Test Data
The following setup allows for a more concise test that focuses only on the relevant test data.
export const buildOrder = (
overrides?: PartialDeep<CreateOrderProps>
): Order => {
const defaults: CreateOrderProps = {
id: "1234567890",
customerId: "95446fe0-5177-427b-bac4-bd8b7cc7f4ab",
lineItems: [
{
id: 1,
orderedQuantity: 5,
returned: [],
unitPrice: 10.35,
sku: "SKU123",
name: "Product Name",
},
{
id: 2,
orderedQuantity: 10,
returned: [],
unitPrice: 20.5,
sku: "SKU456",
name: "Another Product",
},
],
};
const merged = merge({}, defaults, overrides) as CreateOrderProps;
return Order.create(merged);
};
it("should return a line item from an order - CONCISE", async () => {
// ...
const initialOrder = buildOrder({
lineItems: [
{
returned: [{ quantity: 1, reason: "DAMAGED" }],
},
],
});
const orderId = initialOrder.id;
const lineItemId = initialOrder.lineItems[0].id;
// ...
const actual = await useCase.execute({
orderId,
lineItemId,
returnQuantity: 2,
reason: "DAMAGED",
});
const expected = buildOrder({
lineItems: [
{
returned: [{ quantity: 3, reason: "DAMAGED" }],
},
],
});
expect(actual).toEqual(expected);
});
- Use builder functions to create test data
- Specify sensible default values for all properties
- Allow to override specific properties when needed
This setup allows the reader to focus on the scenario being tested, without the noise of the data that is required for the creation of objects, but not for the test itself.
If the structure of an object changes, only the builder function needs to be updated, and potentially some of the tests that describe scenarios connected to the change.
Full code samples and more test scenarios can be found on GitHub.