Mocking in TypeScript with `factoree`
Gal Schlezinger
Posted on April 6, 2021
A couple of days ago I had a fantastic experience. Two ambitious developers asked me to review an open source project they are working on in a short video chat. I was flattered and happily accepted the offer.
We found ourselves talking about mocks in TypeScript. Since I started using TypeScript, I adopted a practice where I try to push as much as I can to the type system and use tools like io-ts
to hook just enough runtime validations to ensure I can trust it.
A couple of months ago I needed to mock something in one of our tests. We have a pretty big config, generated from a confirmation system, and I needed to use a property from it in my test.
The first idea was to do something like setAppConfig({ myKey: value } as any)
. This worked well but it stinks from the any
, which also have a very big drawback: what if the test is implicitly using a property I did not set?
Enter factoree
. A simple factory generator function which will immediately throw an error when accessing a missing property. So, the previous example would be something like:
import { factory } from "factoree";
const createAppConfig = factory<AppConfig>();
setAppConfig(createAppConfig({ myKey: value }));
Can you see that we no longer have as any
? Instead of skipping the type system, we generate an object which will throw an error if we accessed a missing key. Instead of playing pretend — we specify rules for the computer to enforce at runtime:
import { factory } from "factoree";
const createAppConfig = factory<AppConfig>();
const config = createAppConfig({ myKey: "hello" });
config.myKey; // => 'hello'
config.otherKey; // => Error: Can't access key 'otherKey' in object { myKey: 'hello' }
Why does it matter?
Imagine the following code under test:
export type User = {
firstName: string;
lastName: string;
// more data
website: string;
};
export function printName(user: User): string {
return `${user.firstName} ${user.lastName}`;
}
And when we test it, we can use as unknown as User
to provide only the things that are in use in our function:
test(`prints the name`, () => {
const userDetails = ({
firstName: "Gal",
lastName: "Schlezinger",
} as unknown) as User;
const result = printName(userDetails);
});
Now, the product manager asked us to add another feature: allow a user’s name to be written in reverse. So, our code changes into:
export type User = {
firstName: string;
lastName: string;
prefersReversedName: boolean;
// more data
website: string;
};
export function printName(user: User): string {
if (user.prefersReversedName) {
return `${user.lastName} ${user.firstName}`;
}
return `${user.firstName} ${user.lastName}`;
}
After the code change, tests should still pass. This is a problem because we access a property (prefersReversedName
) which should be a non-null boolean
, but we don’t pass value into it, which is undefined
. This creates a blind spot in our test. When using factoree, this would not happen: if you forget to define a property, factoree will throw an error — ensuring you can trust your types.
This tiny library helped us make more maintainable code with easier assertions and better tests. No more undefined
errors in tests if types have changed. Let the computer do the thinking for us.
Try it, and let me know how that worked for you!
Posted on April 6, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 4, 2024