I'm desperately trying to avoid the XY problem here.
Some context (This is the best use case I could come up with):
Imagine you have a codebase that uses Google Search in order to crawl websites for different search queries inside the web. Meaning you have an API endpoint that takes a search query and will save the HTML of all the websites that Google returns inside the database and then return them as the result of your API query.
Of course, such a system is not a real-time system, because of the network requests being made. So you cannot return the results right away. You may start with a Request Recieved response in the beginning and then the system just does its job of downloading HTML and saving it inside the DB.
Another thing that is important, is the fact that some websites might be broken, meaning you cannot wait forever for a response, so you set a timeout on your request and cancel the promise after a certain time.
import axios from 'axios';
const getHTMLOfWebsite = async (websiteURL: string): Promise<string[]> => {
const { data } = await axios.get(websiteURL);
return data;
};
export const getHTMLOfWebsiteWithTimeout = (websiteURL: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
getHTMLOfWebsite(websiteURL).then(resolve, reject);
setTimeout(() => {
reject(new Error('Timeout'));
}, 30 * 1000);
});
};
Now If you want to test such a system, or run it on your local machine, you have a big problem. which is that every time you wanna test it, your machine will start its fans and suck the network bandwidth and take 2 - 3 minutes in order to give you the result for the query that you just made. This will hinder development time and make your integration tests flaky.
So my question is what is the proper way to handle such a scenario, both for testing and local development.
What I've tried:
So the first thing that I thought was that any request that goes outside of the codebase needs to be mocked because third-party APIs are not part of our codebase, This means the Google API, all of the websites, and the returning HTML for each of them need to be mocked. However doing this by hand is very cumbersome, so I found Nock. Basically what this package does is that it will record all the requests being made from your NodeJS process and will save them inside a file, later when you make the same requests it will check if the JSON file does include that request, if it does, then it will just return the recorded response.
This is great! however there is a problem, earlier we talked about timeout or failed requests. Nock does not record such requests because they are not requests really, they do not have a response, they are just a function that throws after a certain timeout is reached.
So what I did later was that I tried to implement a version of Nock, not for HTTP requests but for promises.
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import serialize from 'serialize-javascript';
import { set } from 'lodash';
const deserialize = (serializedJavascript: string) => {
return eval('(' + serializedJavascript + ')');
};
let areFixturesLoaded = false;
const funcHashToFixturesMap: Record<string, any> = {};
const fixturesFolder = path.resolve(process.cwd(), './fixtures');
export const provideMockOnTest = (func: any): any => {
if (process.env.NODE_ENV !== 'local' && process.env.NODE_ENV !== 'test') {
return func;
}
if (!areFixturesLoaded) {
areFixturesLoaded = true;
const fixtureFolderExists = fs.existsSync(fixturesFolder);
if (!fixtureFolderExists) {
fs.mkdirSync(fixturesFolder);
}
const funcs = fs.readdirSync(fixturesFolder);
for (const cachedFunctionHash of funcs) {
const unparsedJSON = fs.readFileSync(path.resolve(fixturesFolder, cachedFunctionHash)).toString();
const parsedJSON = JSON.parse(unparsedJSON);
funcHashToFixturesMap[cachedFunctionHash] = parsedJSON;
}
}
const cypher = crypto.createHash('sha256');
cypher.update(func.toString());
const funcDigest = cypher.digest('hex');
return async (...args: any[]) => {
const joinedArgs = serialize(args);
const cachedValue = funcHashToFixturesMap?.[funcDigest]?.[joinedArgs];
if (cachedValue) {
if (cachedValue.resolved) {
return deserialize(cachedValue.resolved);
}
throw deserialize(cachedValue.reject);
}
const newCache: Record<string, any> = {};
try {
const newFuncResult = await func(...args);
newCache['resolved'] = serialize(newFuncResult);
} catch (e) {
newCache['reject'] = serialize(e);
}
set(funcHashToFixturesMap, `${funcDigest}.${joinedArgs}`, newCache);
await fs.promises.writeFile(path.resolve(fixturesFolder, funcDigest), JSON.stringify(newCache));
if (newCache.resolved) {
return deserialize(newCache.resolved);
}
throw deserialize(newCache.reject);
};
};
Now I will export my getHTMLOfWebsiteWithTimeout like so:
export const getHTMLOfWebsiteWithTimeoutAndMockProvided = provideMockOnTest(getHTMLOfWebsiteWithTimeout);
However I'm not really sure if this is the right approach to achieve what I'm after.
I searched a lot in order to find a package that does this as I imagine this is a common problem however I didn't find anything.