Type-safe tests are a blessing but come with a price: Correctly mocking modules and functions can be cumbersome and becomes even more complicated when a function has multiple overload signatures.
A quick recap on overloads by the TypeScript Handbook:
Some JavaScript functions can be called in a variety of argument counts and types. For example, you might write a function to produce a Date that takes either a timestamp (one argument) or a month/day/year specification (three arguments).
When there are more than two "descriptions" of a function, they are called overload signatures.
function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
Sometimes when we mock functions, we need to force TypeScript to use the correct overload signature because the compiler does not know which signature we want to mock. In many cases I had experienced, it would simply try to enforce the first signature.
A Google Drive example
The following example is taken from a more extensive project and simplified. The link to the repo with more examples can be found at the end of the article.
Our example function below makes a request to the Google Drive API and return a list of folders for a given user.
First, there is some setup for the Google Drive client.
// src/services/google.ts
import { auth, drive } from '@googleapis/drive';
import config from '../config';
const oAuth2Client = new auth.OAuth2(
config.GOOGLE.client_id,
config.GOOGLE.client_secret
);
oAuth2Client.setCredentials({ refresh_token: config.GOOGLE.refresh_token });
export const googleDrive = drive({ version: 'v3', auth: oAuth2Client });
The client is used somewhere in our app to make a request. This is what we want to test.
// src/routes/getFolders
async function getFolders(){
const folders = await googleDrive.files.list({
pageSize: 10,
fields: 'files(id, name)',
q: "mimeType = 'application/vnd.google-apps.folder'",
});
return folders
}
For unit tests, we want to mock googleDrive.files.list
and return a custom response in order not to hit the actual API.
Let's look at the type definition file for the googleDrive.files
class:
class Resource$Drives {
// ...
list(
params: Params$Resource$Files$List,
options: StreamMethodOptions
): GaxiosPromise<Readable>;
list(
params?: Params$Resource$Files$List,
options?: MethodOptions
): GaxiosPromise<Schema$FileList>;
list(
params: Params$Resource$Files$List,
options: StreamMethodOptions | BodyResponseCallback<Readable>,
callback: BodyResponseCallback<Readable>
): void;
list(
params: Params$Resource$Files$List,
options: MethodOptions | BodyResponseCallback<Schema$FileList>,
callback: BodyResponseCallback<Schema$FileList>
): void;
list(
params: Params$Resource$Files$List,
callback: BodyResponseCallback<Schema$FileList>
): void;
list(callback: BodyResponseCallback<Schema$FileList>): void;
//...
}
Woah. Six overload signatures.
Some expect a callback and return nothing, some return a Promise, some return a Stream.
When we write the implementation, we can choose any signature to stick to and TypeScript will not complain. But for unit tests, we usually need to mock a very specific signature.
Enforcing type safety
We know that our implementation above in getFolders
returns a Promise. To be precise, the second signature is the one we want:
list(
params?: Params$Resource$Files$List,
options?: MethodOptions
): GaxiosPromise<Schema$FileList>;
A way to write a spy for the Google functionality would be the following:
import { drive_v3,
GaxiosPromise,
MethodOptions } from '@googleapis/drive';
// Mock the module that exports the googleDrive client:
jest.mock('../src/services/google');
// Typing our spy according to the signature that we use
type DriveListSpy = (
params?: drive_v3.Params$Resource$Files$List,
options?: MethodOptions
) => GaxiosPromise<drive_v3.Schema$FileList>;
// Setting the right method overloads manually
const driveListSpy = jest.spyOn(
googleDrive.files,
'list'
) as unknown as jest.MockedFunction<DriveListSpy>;
Note how the DriveListSpy
type is more or less copy-paste from the type definition file provided by the library. It may take some time to figure out from where you can import the types. Here, the MethodOptions
interface is a direct named export, the params and Promise result type are part of the drive_v3
export.
Now we have complete type safety!
Let's provide a custom response with:
driveListSpy.mockResolvedValue({
config: {},
data: { files: [{ name: 'folder', id: 'id' }] },
status: 200,
statusText: 'OK',
headers: {},
request: { responseURL: 'test' },
});
Example test case:
it('lists folders', async () => {
await getFolders();
expect(driveListSpy).toHaveBeenCalledTimes(1);
expect(driveListSpy.mock.calls[0][0]).toMatchSnapshot();
});
If we forget or misspell a property, e.g. type "Name" instead of "name", TypeScript will complain:
Now we can provide any sort of mock implementation that adheres to our Promise-based signature, e.g. resolve different values for different tests (with mockResolvedValueOnce
).
Final notes
Setting overload signatures manually is generally not nice because we are re-writing (or copy-pasting) implementation details of a third-party library. Upgrading the Google Drive dependency may result in different signatures.
Also, as unknown as <type>
assertions are error-prone since we tell the compiler not to infer types and instead use our custom type.
Nevertheless, setting overload signatures manually is sometimes the only way out. Take your time to setup your spies and enjoy the benefit of rock-solid test.
Happy mocking!
Thanks for sticking through - here's a full example: https://github.com/eegli/spotify-history/blob/main/test/backup.test.ts
Top comments (1)
Thanks for the article!
In my opinion, overloading signatures is mostly an anti-pattern; I don't remember the need from the last 5-6 years using it ever. If my function does something else based on a new parameter, I name it differently. Express.js also has this terrible pattern.