While writing unit tests in Javascript for a browser extension, I came across an issue: the browser's API provides browser or chrome in the global namespace, but Jest only allows me to mock a module import. Since chrome isn't imported, I couldn't mock it, and since the tests aren't being run in an extension environment but in a node environment, nobody was providing my functions with a global chrome.
Although there doesn't seem to be a function in Jest for this, a solution—that probably works in Javascript in general regardless of test runner—is to simply define the global variable inside a test using globalThis (which is an environment agnostic window). For example, say that we want to test this function written in Typescript:
// my-downloader.ts
function downloadUrl(url: string): void {
chrome.downloads.download({ url });
}
In this case, we need to mock chrome.downloads.download in our test. We can do so with this code:
// my-downloader.test.ts
import { downloadUrl } from "./my-downloader";
test('downloadUrl', () => {
globalThis.chrome = {
downloads: {
download() {}
}
} as any;
const test_url = 'https://www.virtualcuriosities.com/';
jest.spyOn(chrome.downloads, 'download').mockImplementationOnce(
async (options: chrome.downloads.DownloadOptions) => {
expect(options.url).toBe(test_url);
return 1;
}
);
downloadUrl(test_url);
});
Let's understand what is happening above.
First, globalThis.chrome defines the global variable that was missing. We can just replace it on every test we use it. In my case, I have the types for the Chrome API loaded using @types/chrome and Typescript will be very angry if I don't mock the entire API. To get around this, we tell Typescript to interpret the object as any, so it will ignore the type mismatch.
Then we use jest.spyOn to replace the download method once. It's worth noting that this function will not work if we haven't defined the method when we were defining chrome. I suppose that you could just put the implementation above, replacing download() {}, instead of using jest.spyOn, but I feel this way makes it clearer what we're trying to do and will match other mocks we could have in our tests.
Inside the mock, we check if the URL we passed downloadUrl is actually being passed down to the API. I believe untested code is broken code, so even something like this needs to be test automatically somehow, or you have to test it manually, and you really don't want to test this manually every single time you bump the version. Normally, this granularity isn't necessary since if something like this breaks, something else is going to break, and a test will catch that. Unfortunately for this specific API the only way to properly test it would be using something like Puppeteer or Selenium to see if the new download was actually created, and that sounds more complicated than just mocking the API, so I'm just going to mock the API. The mock also returns 1 in order to match chrome.downloads.download signature, which returns the download numeric ID in a promise.
Finally we call the function we want to test.
Mocking Existing Global Variables
For completeness, if you have a module as a global variable without an import statement, but it's included in the environment of your test, you can use jest.spyOn to mock its functions as well. For example:
// my-blob-downloader.test.ts
import { downloadBlob } from "./my-blob-downloader";
test('downloadBlob', async () => {
const test_blob = new Blob();
const test_blobUrl = "https://www.virtualcuriosities.com/";
jest.spyOn(URL, 'createObjectURL').mockImplementationOnce((b) => {
expect(b).toBe(test_blob);
return test_blobUrl;
});
// mock other things here
downloadBlob(test_blob);
});
This time, we're mocking URL.createObjectURL, which is a function that exists in the browser environment, and in the node environment as well for some reason. Actually, now that I think about it, why does this exist inside node? Does it even work? Why would you need a blob URL inside of node? You can't pass that to the frontend, and one the backend you should be able to just pass the blob around, so I'm really not sure. Parity, I guess? Maybe to help with libraries that download things from URLs from inside node?
Anyway, this is how you mock global variables in Jest.