It seems that there is no such project that does not need to make requests to the server. And it is always a good idea to write a separate abstraction to describe such communication. This not only adds readability and reusability to the code, but also makes it possible to quickly and efficiently write tests for such functionality.
class NetworkService {
constructor(private readonly fetch: typeof fetch) {}
getUser: (): Promise<IUser> => this.fetch('/api/user').then(d => d.json())
}
function getUsername(api: Pick<NetworkService, 'getUser'>) {
return api.getUser().then(user => user.name);
}
test('getUsername', async () => {
const name = await getUsername({
getUser: () => Promise.resolve({ name: 'test' } as IUser)
});
expect(name).toBe('test');
});
And for an average project, this approach will be sufficient. But sometimes there are requirements that you need to redefine the fetch
itself. For example, in our project, we are developing an extension for the browser and we want to make requests through the serviceWorker. And there is a requirement to reimplement the Fetch API
. As we know, fetch returns a Response
primitive, for the creation of which you need to pass the ReadableStream
as the first argument, and the response characteristics as the second. Thus, we can use the following construction.
function buildFetchEmulator(
controller: (url: RequestInfo, init: RequestInit | undefined) =>
Promise<[ResponseInit, (add: (msg: object) => void) => Promise<void>]>
) {
const cFetch: typeof fetch = async (url, init) => {
// extract the response characteristics and the controller that will generate the data
const [responseInit, ctrl] = await controller(url, init);
// describe the behavior of ReadableStream
const stream = new ReadableStream({
async start(controller) {
try {
const enc = new TextEncoder();
await ctrl((msg) => {
// convert the object to a string
const message = JSON.stringify(msg);
// string in Uint8Array
const buf = enc.encode(message);
// transfer data to the ReadableStream controller
controller.enqueue(buf);
});
controller.close();
} catch (err) {
controller.error(err);
controller.close();
}
},
});
return new Response(stream, responseInit);
};
return cFetch;
}
test('getUser', () => {
const customFetch = buildFetchEmulator(async (url, init) => {
if (url === '/api/user') {
return [{
headers: new Headers({ 'content-type': contentType }),
status: 200,
statusText: 'OK',
}, async (add) => {
// Here we can use any logic for data transport
add({ name: 'test' })
}];
}
throw new Error('not implemented');
})
const network = new NetworkService();
const name = await getUsername(network);
expect(name).toBe('test');
});
Here I would like to emphasize once again that for cases when there is no need to emulate fetch
and write custom adapters, the first method is much more concise and convenient, but with this approach we can test even external libraries that use fetch
directly, for example like @microsoft/fetch-event-source
.