Why shouldn't a test call the real server?
A test that talks to a real server depends on things your code doesn't control. The server might be down, slow, or holding different data from yesterday. The Wi-Fi might drop. Each of those turns a test red even though loadSightings() is fine, and after a few false alarms people stop trusting the suite.
There is a quieter problem too. A real server only gives you the answers it happens to give today. You can't easily make it reply with a 503, an empty list or a sighting with a count of zero, and those are exactly the cases where code goes wrong. With a fake, you choose the answer for each test.
The practice runner makes the point for you: it has no network at all, so a test that forgets to replace fetch fails with an error. On your own machine the same test might pass on a good day, which is worse, because it hides the problem until the day it doesn't.
How do you replace fetch in a test?
Code that calls fetch() looks it up on the global object when it runs. vi.stubGlobal('fetch', fakeFn) puts your function there instead, so the code under test calls your fake without knowing. Make the fake with vi.fn() so it records every call, and tell it what to return.
fetch() returns a promise, so the fake needs mockResolvedValue() for a normal answer. Undo the stub after each test with vi.unstubAllGlobals() in afterEach, so one test's fake never leaks into the next. Vitest can also do that for you with the unstubGlobals setting in its config.
const fetchMock = vi.fn().mockResolvedValue(fakeResponse([
{ species: 'Grey heron', count: 2 },
]));
vi.stubGlobal('fetch', fetchMock);
const list = await loadSightings('Mill Pond');

What should the fake response look like?
Only as much as the code uses. loadSightings() reads res.ok, res.status and res.json(), so the fakeResponse() helper returns an object with those three things and nothing else. json is an async function because the real one returns a promise. Setting ok from the status keeps the fake honest: a 503 can never come back with ok: true by mistake.
A small helper like this beats writing the object out in every test. Each test then reads as the situation it describes, fakeResponse([], 503) for a busy server, and if the code later reads another field such as headers, you add it in one place.
In Node 18 and later you can also build a real Response with new Response(JSON.stringify(body), { status }). That is closer to the real thing and catches code that relies on parts your helper left out. Either is fine. The helper is easier to read while you are learning, and it works the same in the practice runner.
Which things should a fetch test check?
Three separate questions, usually in three tests. Did the code ask for the right thing? Did it handle a good answer correctly? Did it handle a bad answer correctly? The starting test covers only the second one, which is why the bugs in Check my work slip past it.
The first question is about the request, so the assertion is about the mock: toHaveBeenCalledWith() with the exact URL, and toHaveBeenCalledTimes(1) if a second request would be a bug. For a POST, the second argument holds the method, headers and body, and you can check those with expect.objectContaining({ method: 'POST' }).
The other two are about what your function gives back, so the assertion is about its result or its error, the same as any other test.
- The URL, method and body the code sends
- What it returns for a normal answer, including an empty list
- What happens for an error status such as 404 or 503
- What happens when the network fails and
fetchitself rejects
Why does the site name need encoding?
A URL can't contain a plain space, and characters such as & and ? have special meanings in it. encodeURIComponent('Mill Pond') turns the name into Mill%20Pond, which the server reads back as the original text. Without it, a site called Bank & Weir would split into two query values and the server would look up the wrong place.
A test that only uses a one-word site such as Heronry can't tell the difference, because encoding changes nothing. Pick test data that would expose the bug: a name with a space, and if you like, one with an ampersand. This is the edge-case habit from earlier lessons applied to a request.
How do you test a server error?
fetch() only rejects when no answer arrives at all. A 404 or a 503 still resolves, with ok set to false, which surprises a lot of people. Code has to check res.ok itself, and a test has to prove that it does by handing back a failing status.
Make the fake answer fakeResponse({}, 503), then use .rejects to say the promise should fail. await expect(loadSightings('Mill Pond')).rejects.toHaveProperty('message', 'Sightings unavailable (503)') checks the message too, because the message is what a member sees on the page when the club's server is busy.
In Vitest you would usually write .rejects.toThrow('Sightings unavailable (503)'). The practice runner accepts that too. The tests here check the error's message property, which pins the whole text. Both forms work in Vitest, Jest and the practice runner, and both fail when the promise resolves.
test('a server error rejects with the status code', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(fakeResponse({}, 503)));
await expect(loadSightings('Mill Pond')).rejects.toHaveProperty('message', 'Sightings unavailable (503)');
});How is a network failure different?
When the phone loses signal, fetch() never gets an answer and rejects with a TypeError. You fake that with mockRejectedValue(new TypeError('Failed to fetch')). loadSightings() doesn't catch it, so the right behaviour is to pass the error on, and the test checks the promise rejects with a TypeError using .rejects.toBeInstanceOf(TypeError).
If your code does catch it, perhaps to show "You seem to be offline", that message gets its own test. The two paths often need different words on the page: "try again later" for a busy server, "check your connection" for no network. Testing both keeps them from collapsing into one vague message.
What do AI-written fetch tests often get wrong?
The most common one is a mock that is set up and never asked about. The test stubs fetch, calls the function, and asserts expect(fetchMock).toBeDefined() or nothing at all. The Break It challenge shows this: the URL could be anything and the test stays green.
The second is an error test written with try and catch, with the only expect() inside catch. When the code fails to throw, catch never runs, no assertion happens, and the test passes. Use .rejects, or add expect.assertions(1) at the top so a test with no assertions fails.
Watch for fake responses that are too generous as well. If every fake says ok: true whatever the status, the error path can never run. And look out for tests that forget to undo the stub, so the result of one test depends on which test ran before it.
Where does the practice runner differ from Vitest?
The runner here supports vi.stubGlobal() and vi.unstubAllGlobals() the same way, and has no network, so a forgotten stub fails loudly. In Vitest on Node, a forgotten stub would try a real request, and a relative URL such as /api/sightings would fail with an invalid URL error instead. The fix is the same in both: always stub.
Larger projects often use a library that intercepts requests at the network level instead of replacing fetch by hand. That lets one set of fake answers serve many tests and catches code that calls fetch in an unexpected way. The ideas carry over unchanged: choose the answer, check the request, test the failure paths.
Field notes
- A fake
fetchlets you choose the answer, including errors a real server rarely gives. - Check the request itself with
toHaveBeenCalledWith()and the exact URL. - A 404 or 503 still resolves, so your code must check
res.okitself. - Pick test data with a space in the name, so missing encoding shows up.
Easy to get wrong
A mock always exists once you create it. Assert how it was called, or the request could be completely wrong and the test would still pass.
If the code resolves instead of rejecting, the catch block never runs and the test passes with no assertions. .rejects fails in that case.
Without await, the test ends before the promise settles, so a failure arrives too late to count. Vitest warns about this, and the test may still show as passed.
A fake that always says ok: true can never exercise the error path. Work ok out from the status, the way a real Response does.
Questions people ask
Should I mock fetch or the function that calls it?
Mock fetch when you are testing the function that calls it, like loadSightings(). When you test code further up, such as the page that shows sightings, mock loadSightings() itself, so those tests don't need to know about URLs at all.
Does fetch reject for a 404?
No. Any answer from the server resolves, whatever its status. Only a network failure, such as no connection, makes fetch() reject. Check res.ok in your code and test both cases.
Is vi.stubGlobal the same as setting globalThis.fetch?
It does the same job, and Vitest remembers the original so vi.unstubAllGlobals() can put it back. Assigning globalThis.fetch yourself works, but you have to restore it yourself too, which is easy to forget.
How do I test a loading message shown while waiting?
Give the fake a promise you resolve later, check the loading text is on the page, then resolve it and check the results appear. The page lessons later in this course show how to look at the page from a test.
Can I use a real Response object in the fake?
Yes, in Node 18 and later: new Response(JSON.stringify(body), { status: 503 }). It behaves like the real thing, including ok and json(). A plain object is shorter and enough when the code only reads a few fields.