How to Properly Mock Typed Variables in Unit Tests with TypeScript
Zhiyue Yi
Posted on August 16, 2020
Visit my Blog for the original post: How to Properly Mock Typed Variables in Unit Tests with TypeScript
The Problem I Faced
When we are doing unit tests, most of the time, we have to create some variables with existing types and some mock values to test our functions. However, in TypeScript, you might have a headache creating the variable, if the mocked variable has many properties and you only want to mock some of the property values for specific tests.
Let's illustrate this problem with an example. (Jump to the bottom if you want to check the solution directly).
If you have the Order
type has the following structure:
interface Order {
id: number;
buyer: string;
items: {
id: number;
description: string;
unit: number;
unitPrice: number;
discount: number;
}[];
promoCode: string;
totalPrice: number;
buyerMessage: string;
remarks: string;
createdAt: Date;
updatedAt: Date;
}
And you want to test function called calculateTotalPrice(order: Order): boolean
, which takes an order object with Order
type as an input, calculate total price for all the items, compare the result with the totalPrice
property, and lastly output the boolean of comparison result, which identifies if the calculation matches the existing total price record.
So, it's very simple to write a test for it:
describe('# calculateTotalPrice', () => {
it('should return true if total price matches', () => {
const mockOrder: Order = {
// mock some values with total price matching with calculation from records
}
expect(calculateTotalPrice(mockOrder)).toBe(true);
});
it('should return false if total price does not match', () => {
const mockOrder: Order = {
// mock some values with total price NOT matching with calculation from records
}
expect(calculateTotalPrice(mockOrder)).toBe(false);
});
});
Now the problem is, I ONLY need items
and totalPrice
properties for the test, but TypeScript linter yells to me for a complete data structure like this, how should I do?
The Not-so-good Ways to Solve It
Well, we can directly jump to the correct answer for this problem, because it's simple to adopt. But, I think it's more educational if we can learn from the bad cases and understand why they are bad.
Basically, there are 3 not-so-good ways to solve it, which I've tried them all for the past a few years. They are ranked by how bad it is in descending order.
#1: Make them optional, why bother? [BAD]
When I was an absolute beginner to TypeScript, I tend to use this simple and brutal method to solve this issue, marking all the properties optional like this:
interface Order {
id?: number;
buyer?: string;
items?: {
id?: number;
description?: string;
unit?: number;
unitPrice?: number;
discount?: number;
}[];
promoCode?: string;
totalPrice?: number;
buyerMessage?: string;
remarks?: string;
createdAt?: Date;
updatedAt?: Date;
}
This is definitely a BIG no no!
That's because the properties are not really optional! They are only optional when you are doing tests. While in the actual code base, these properties can be mandatory. Marking them optional makes you convenient, but the consequence can be disastrous. In this way, you lose the capability to do null / undefined check for the properties, which results your code to likely have careless mistakes not being able to uncover!
In a word, never give in with your actual codes just because you want to have some conveniences in testing, or whatever other aspects!
#2: ANY way, we did it [Not-so-good]
The second way, is just turning TypeScript to "AnyScript" by adding any
to the mocked variable, so that the type check can be ignored and less annoying.
it('should return true if total price matches', () => {
const mockOrder: any = {
items: [
{
unit: 2,
unitPrice: 10,
discount: 0,
},
{
unit: 1,
unitPrice: 5,
discount: 1,
}
],
totalPrice: 24,
};
expect(calculateTotalPrice(mockOrder)).toBe(true);
});
Well, it actually works, at least in our case. However, the order type is lost. It is not convenient especially when you want to have the intellisense feature when you are constructing the mockOrder
variable, as the text editor (mine is VS Code) won't be able to intelligently notify you what properties an Order
object should have, which can be less convenient and annoying most of the time.
#3: I say it is AS it is! [Looks probably ok]
The third way, it is one step smarter than using any
, which is to use as
keyword to force adding the Order
type at the variable declaration stage.
it('should return true if total price matches', () => {
const mockOrder = {
items: [
{
unit: 2,
unitPrice: 10,
discount: 0,
},
{
unit: 1,
unitPrice: 5,
discount: 1,
}
],
totalPrice: 24,
} as Order;
expect(calculateTotalPrice(mockOrder)).toBe(true);
});
It actually works very well, and I really thought it was the ultimate answer to this problem because It has types and does not affect actual code base.
But I still feel something is missing here.
Hey, what if I put the property which doesn't belong to the Order
object? For instance:
const mockOrder = {
iShouldNotBeHereButHereIam: true,
items: [
{
unit: 2,
unitPrice: 10,
discount: 0,
},
{
unit: 1,
unitPrice: 5,
discount: 1,
}
],
totalPrice: 24,
} as Order;
In this case, you can't really lint the "illegal" property inside of the object, because you have forced the object to be an Order
object, which is obviously not so right here.
The Good Way to Solve It: Use the Native Type Partial in TypeScript!
So here comes the best approach to this problem, which I just discovered it today.
It is to use the native type Partial
in TypeScript.
According to TypeScript: Handbook - Utility, Partial
constructs a type with all properties of Type set to optional. This utility will return a type that represents all subsets of a given type.
However, we cannot solely use partial here, because our object is nested.
// Lint error occurs:
// Type '{ unit: number; unitPrice: number; discount: number; }' is missing the following properties from type 'Item': id, description ts(2739)
const mockOrder: Partial<Order> = {
items: [
{
unit: 2,
unitPrice: 10,
discount: 0,
},
{
unit: 1,
unitPrice: 5,
discount: 1,
}
],
totalPrice: 24,
};
It's because the Partial
only applies to the first level (totalPrice
level), but not to the second level (unitPrice
level). Hence, we can consider to add a custom RecursivePartial
type to a global declaration file like src/declarations.d.ts
. (Solution referred to use Partial in nested property with typescript - Stack Overflow)
type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
Then you can complete the test like:
describe("# calculateTotalPrice", () => {
it("should return true if total price matches", () => {
const mockOrder: RecursivePartial<Order> = {
items: [
{
unit: 2,
unitPrice: 10,
discount: 0,
},
{
unit: 1,
unitPrice: 5,
discount: 1,
},
],
totalPrice: 24,
};
// Now we can safely transform the type to Order here
expect(calculateTotalPrice(mockOrder as Order)).toBe(true);
});
});
This method is good in our circumstances because your object is typed all the time (both in creation and in use) and actual code based does not get affected!
GitHub Demo is here ZhiyueYi/demo-in-blog
Cheers!
Featured image is credited to Chokniti Khongchum from Pexels
Posted on August 16, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 25, 2024
November 9, 2024