32.3 Typing Test Fixtures and Factories
Right, let’s talk about the part of testing that everyone loves to hate: setting up data. You’re not here to test your ability to manually craft a User object with seventeen nested properties for the fiftieth time. You’re here to test logic. So we use fixtures and factories. And since we’re in TypeScript, we’re going to do it with types, because we’re not animals.
The core problem is simple: your functions expect well-typed arguments, but in your tests, you often only care about a subset of those properties. You need a way to generate complete, valid-looking objects without specifying every single field every single time. Doing it manually is a recipe for brittle, unreadable tests that shatter the moment your main data model changes.
The Naive (and Terrible) Way: as Abuse
I know what you’re thinking. “It’s a test, I’ll just cast a partial object and shut the compiler up.” I’ve seen it. I’ve done it. We’re all guilty.
// Please, for the love of all that is type-safe, don't do this.
const mockUser = {
id: 1,
name: 'Test User',
} as User;
test('gets user name', () => {
expect(getUserName(mockUser)).toBe('Test User');
});
This is a ticking time bomb. What if the User interface changes and now requires an email field? Your test will still compile happily (as User is a lie you told the compiler, and it believed you), but it will explode at runtime when your function tries to access user.email. You’ve lost your number one ally—the type checker—and gained a cryptic error. We can do better.
The Right Way: Factories with Defaults
The goal is a function, a factory, that returns a valid User object. Its magic is that it allows you to override specific properties for your test case while providing sensible defaults for everything else. This means your test only contains the data relevant to the test itself, making it infinitely more readable.
Here’s how we build one. We use a classic pattern: a function that takes a partial object (Partial<User>) and returns a complete User, merging your overrides with the defaults.
// A simple, effective factory function
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}
const createUser = (overrides: Partial<User> = {}): User => {
return {
id: 1,
name: 'Alice',
email: 'alice@example.com',
isActive: true,
...overrides, // This line is the secret sauce. Overrides win.
};
};
// In your tests, it's pure elegance:
test('user is active by default', () => {
const user = createUser(); // Uses all defaults
expect(user.isActive).toBe(true);
});
test('deactivates a user', () => {
const inactiveUser = createUser({ isActive: false }); // Override just one prop
expect(deactivateUser(inactiveUser).isActive).toBe(false);
});
test('handles users with no email', () => {
// This is safe! The factory ensures we get a full User object,
// even though we're overriding email to be undefined.
const userWithoutEmail = createUser({ email: undefined });
expect(handleUserWithoutEmail(userWithoutEmail)).not.toThrow();
});
See? The tests are clear, focused, and robust. If we add a lastLogin: Date field to the User interface, the TypeScript compiler will yell at us until we add a sensible default to the createUser factory. This is a good thing. It forces us to consider the impact of that change on our entire test suite right now, not later when tests mysteriously fail.
Leveling Up: Using a Library Like @jackfranklin/test-data-bot
You don’t have to write these factories by hand. While it’s a great exercise to understand the pattern, for a large codebase, a library is a wise choice. My personal favorite is @jackfranklin/test-data-bot. It provides a delightful, declarative API for building factories and even generates random data, which is fantastic for property-based testing or just avoiding the “Test User” monotony.
import { build, oneOf, sequence } from '@jackfranklin/test-data-bot';
const userBuilder = build<User>('User', {
fields: {
id: sequence(), // Generates 1, 2, 3, ...
name: 'Testy McTestface',
email: () => `${oneOf(['alice', 'bob'])}@example.com`, // Randomly picks
isActive: oneOf([true, false]), // Randomly picks
},
});
// Usage is identical to our hand-rolled version, but with better data.
const user = userBuilder({ overrides: { isActive: false } });
The beauty here is the built-in randomness. It constantly stress-tests your code with different values, helping you find edge cases you didn’t anticipate. If your function breaks when isActive is false, you’ll find out quickly instead of only testing the happy path.
Handling Complex and Nested Relationships
This pattern shines brightest when you have complex, nested objects. Let’s say a BlogPost has an author of type User.
interface BlogPost {
id: number;
title: string;
content: string;
author: User;
}
const createBlogPost = (overrides: Partial<BlogPost> = {}): BlogPost => {
return {
id: 1,
title: 'A Test Post',
content: 'Lorem ipsum...',
author: createUser(), // <- Reuse the user factory!
...overrides,
};
};
// Now creating a post from a specific author is trivial:
test('post shows author name', () => {
const author = createUser({ name: 'J.R.R. Testolkien' });
const post = createBlogPost({ author });
expect(getPostAuthorName(post)).toBe('J.R.R. Testolkien');
});
By composing factories, you build a powerful, hierarchical system for generating test data. A change in the User model automatically propagates to all BlogPost tests because the author is created by the createUser factory, which has already been updated. This is the kind of DRY principle that doesn’t make you want to scream.
The takeaway is this: stop casting. Start building. A small upfront investment in typed factories pays for itself a hundred times over in test resilience, readability, and sheer developer happiness. It makes writing tests less of a chore and more of a… well, let’s not get carried away. It just makes it less awful. And in the world of testing, that’s a massive win.