After finishing the proof of concept I had the idea in mind that I would like to start by extracting the fetch
process. By the end of the exercise I had the following code (with garbage comments removed):
let previousController: AbortController | undefined = undefined;
const fetcher = async (href: string) => {
const currentController = new AbortController();
if (previousController) {
previousController.abort();
}
previousController = currentController;
const { url, status, statusText, ok, headers } = await fetch(href, {
method: "head",
redirect: "manual",
signal: currentController.signal,
});
if (ok) {
currentController.abort();
previousController = undefined;
}
const location = headers.get("location");
const redirected = null !== location;
let nextLocation = undefined;
try {
if (redirected) {
const hrefToUrl = new URL(location);
}
} catch (e) {}
return { url, status, statusText, ok, headers };
};
Code language: TypeScript (typescript)
It worked to the point that the proof of concept did fetch links. But blurghh, that’s horrible.
As part of the proof of concept I have learned a few things about this code:
HEAD
won’t behave the way I expect, I have to useGET
or manual redirects don’t always work.- The
AbortController
idea is needed, but that implementation above isn’t right.
Being comfortable with Jest, mocking, and fetch
generally, I’m going to start with the idea that I will spy on calls to fetch
, and iterate from there.
import { fetcher } from "./fetcher";
const fakeUrl = "http://some.fake.url";
describe("fetcher", () => {
afterEach(() => {
jest.resetAllMocks();
});
test("should call the native fetch function in the expected manner", async () => {
const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
url: fakeUrl,
status: 200,
statusText: "something",
ok: true,
} as unknown as Response);
const result = await fetcher(fakeUrl);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(fakeUrl, {
redirect: "manual",
});
expect(result).toEqual({
ok: true,
status: 200,
statusText: "something",
url: "http://some.fake.url",
});
});
});
Code language: JavaScript (javascript)
Code to make this test pass:
export const fetcher = async (href: string) => {
const { url, status, statusText, ok } = await fetch(href, {
redirect: "manual",
});
return { url, status, statusText, ok };
};
Code language: TypeScript (typescript)
So really, at this stage, nothing more than a restrictive wrapper around the native fetch
function.
Worrying About Headers
This does miss out the headers
from the response, and as we know we will want them for being able to probe into, and examine the full request flow.
There is a gotcha in here though. I can destructure the headers
out of the return from fetch
, but the type of the headers object is not a primitive:
url
=string
status
=number
statusText
=string
ok
=boolean
headers
, however, is of type Headers
. More on that on the MDN docs.
If we hadn’t spotted this yet, we would do when we came to factor this in to our unit tests:
test("should call the native fetch function in the expected manner", async () => {
const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
url: fakeUrl,
status: 200,
statusText: "something",
ok: true,
headers: ???
} as unknown as Response);
...
});
Code language: TypeScript (typescript)
We need to mock the value that headers
would actually be, and that flags up that hey, we would be using this extra concept of some object that conforms to a Headers
interface here.
Adding A Return Type Definition
Given that this is our wrapper around fetch
, I’m going to use this opportunity to define a new TypeScript type to enforce the values that is returned by this fetcher
wrapper function.
// types.d.ts
export type FetcherResponse = {
url: string;
status: number;
statusText: string;
ok: boolean;
headers: Record<string, string>;
}
Code language: JavaScript (javascript)
The Record
type allows me to define a key value pairing, where I have said both the key and value will be of type string
. You can get more advanced with this, but for quickly providing an inline description of a JavaScript object that will have string keys, and string values, this will suffice.
OK, so new problem.
Our FetcherResponse
expects us to return headers
in the format Record<string, string>
.
But we directly return headers
from the fetch
call, which we know would be an implementation of the Headers
interface.
Some kind of conversion needs to take place in our fetcher
function.
An initial exploration of the headers
object does make it appear that it has standard object-like behaviour.
In fact, it looks a lot like a Map
:
But anyway. Until I know better, I am going to go with a conversion of the headers
object to a plain old JavaScript object:
export const fetcher = async (href: string): Promise<FetcherResponse> => {
const { url, status, statusText, ok, headers } = await fetch(href, {
redirect: "manual",
});
const headersObject = Object.fromEntries(headers);
return { url, status, statusText, ok, headers: headersObject };
};
Code language: TypeScript (typescript)
Note the fix in the code above. It now correctly returns Promise<FetcherResponse>
, whereas the screenshots above do not include the Promise
wrapper.
Refactoring The Test Code
And so the test code becomes:
test("should call the native fetch function in the expected manner", async () => {
const headers = new Headers();
const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
url: fakeUrl,
status: 200,
statusText: "something",
ok: true,
headers,
} as unknown as Response);
const result = await fetcher(fakeUrl);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(fakeUrl, {
redirect: "manual",
});
expect(result).toEqual({
ok: true,
status: 200,
statusText: "something",
url: "http://some.fake.url",
headers: {},
});
});
Code language: TypeScript (typescript)
I’m pretty happy with that.
But equally I’d love to confirm that headers
continues to behave as expected when there are some key / values returned.
With two test cases for the same test, I would probably go with this in the real world:
describe("fetcher", () => {
afterEach(() => {
jest.resetAllMocks();
});
[
{
description: "empty headers",
givenHeaders: new Headers(),
expectedHeaders: {},
},
{
description: "with headers",
givenHeaders: new Headers({ some: "data" }),
expectedHeaders: { some: "data" },
},
].forEach(({ description, givenHeaders, expectedHeaders }) => {
test(`should call the native fetch function in the expected manner: ${description}`, async () => {
const fetchSpy = jest.spyOn(global, "fetch").mockResolvedValueOnce({
url: fakeUrl,
status: 200,
statusText: "something",
ok: true,
headers: givenHeaders,
} as unknown as Response);
const result = await fetcher(fakeUrl);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith(fakeUrl, {
redirect: "manual",
});
expect(result).toEqual({
ok: true,
status: 200,
statusText: "something",
url: "http://some.fake.url",
headers: expectedHeaders,
});
});
});
});
Code language: TypeScript (typescript)
This feels good enough for a first pass. Certainly enough to get us moving and looking at what to tackle next.