Mock the SellerMagnet API in Jest tests by faking only the global fetch function, never your own client module: replace fetch with a function that returns a scripted status, JSON body and headers, then run the unchanged client.js, retry.js and lookup.js from the TypeScript quickstart against it. No credit is spent, no network call leaves the test, and the exact parsing and retry logic under test is the code that ships.
Key Takeaways
- Fake
globalThis.fetchonly; a hand-written mock of your own client module can drift from the real one and still pass. - Make the fake fail closed: a request before a test calls
install(route)should reject, not silently reach the real API. - A 429 is free and worth waiting out; a 500 is refunded and worth one retry; a 502/503 is billed on every attempt, so cap the attempts.
- The real backoff in
retry.jsuses small millisecond delays in tests, so there is no need to fake timers for the retry scenarios. - Count every call the fake received (
fakefetch.CALLS) to verify the credit math of a scenario, not just its final return value.
Why not just call the real API from a Jest suite?
Every SellerMagnet API request is billed per the table below, whether it comes from production code or a test. A CI run that fires on every push would spend credits on every build, a slow or flaky network turns a code bug into a failed pipeline, and a 404 or a 429 that a test wants to simulate on demand cannot be forced on the live API at all. A fake fetch makes every status code, including ones that are rare in practice, reproducible on every run.
| Scenario | Charged | Retry? | Test should assert |
|---|---|---|---|
| happy path with real data shapes | billed | no | parse and return |
| 404 product not found | billed | no | report it; do not retry |
| free gate 400 (missing marketplaceId) | free | no | fix the call, no retry |
| 401 insufficient credits | free | no | stop; top up credits |
| 429 concurrency cap | free | yes | honour Retry-After, then retry |
| 500 internal error | refunded | yes | retry once, then stop |
| 502 / 503 upstream | billed | yes | retry up to the attempt cap |
| wrong or missing X-Api-Key header | n/a | no | fail loudly in the test |
One precision worth a dedicated test: a 500 is only refunded when the response carries no Retry-After header; a 500 that does carry one stays billed like a 502 or 503. Script both shapes separately rather than assuming every 500 behaves the same way.
How do I fake just fetch, not my own code?
Replace globalThis.fetch with a function that returns a scripted Response-like object (status, json(), a Headers-like get()), then let client.js's get() and retry.js's withRetry() (both from the TypeScript quickstart) run exactly as they would in production. The fake defaults to rejecting, so a test that forgets to script a response fails loudly instead of letting a real, billed request through.

fakefetch.js - fakes only globalThis.fetch, fails closed
// fakefetch.js - fakes only globalThis.fetch. Fails closed: no request may
// reach the real API unless install(route) was called first.
function _blocked() {
return Promise.reject(new Error("fakefetch: call install(route) first - no real request may leave the test"));
}
globalThis.fetch = _blocked;
export const CALLS = [];
class FakeHeaders {
constructor(obj = {}) {
this._h = Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), String(v)]));
}
get(name) {
return this._h[name.toLowerCase()] ?? null;
}
}
class FakeResponse {
constructor(status, body, headers = {}) {
this.status = status;
this.ok = status >= 200 && status < 300;
this.statusText = `status ${status}`;
this.headers = new FakeHeaders(headers);
this._body = body;
}
async json() {
if (typeof this._body === "string") throw new Error("not json"); // simulate an HTML error page
return this._body;
}
}
export function ok(data) {
return [200, { success: true, data }];
}
export function err(status, message = "request failed", headers = {}) {
return [status, { success: false, error: "error", message }, headers];
}
/**
* route(endpoint, params) -> [status, body] | [status, body, headers] | Error
* Installs a fake fetch for the lifetime of one test.
*/
export function install(route) {
globalThis.fetch = async (url, options = {}) => {
const u = new URL(url);
const endpoint = u.pathname.split("/").filter(Boolean).pop();
const params = Object.fromEntries(u.searchParams.entries());
if (options.headers?.["X-Api-Key"] !== "test-key") {
throw new Error("fakefetch: X-Api-Key header missing or wrong - key must travel as a header");
}
CALLS.push([endpoint, params]);
const result = route(endpoint, params);
if (result instanceof Error) throw result;
const [status, body, headers] = result.length === 2 ? [...result, {}] : result;
return new FakeResponse(status, body, headers);
};
}
export function reset() {
CALLS.length = 0;
globalThis.fetch = _blocked;
}

What does a full Jest scenario suite look like?
Each test calls fakefetch.install(route) to script one response, runs the real lookupProduct (optionally wrapped in withRetry), and asserts both the result and fakefetch.CALLS. An afterEach resets the fake back to its fail-closed default so no scripted response can leak into the next test.
lookup.test.js - the scenario suite, run for real below
process.env.SELLERMAGNET_API_KEY = "test-key";
import * as fakefetch from "../fakefetch.js";
import { lookupProduct } from "../lookup.js";
import { ApiError } from "../client.js";
import { withRetry } from "../retry.js";
const ASIN = "B0CL61F39H";
const MP = "ATVPDKIKX0DER";
function product(price = 444.99) {
return {
productInfo: {
asin: ASIN,
title: "PlayStation5 console (slim)",
link: `https://www.amazon.com/dp/${ASIN}`,
buyBoxInfo: { price, currencyCode: "USD", sellerId: "A2L77EE7U53NWQ" },
},
};
}
afterEach(() => {
fakefetch.reset();
});
test("happy path parses the real response shape", async () => {
fakefetch.install((ep) => {
expect(ep).toBe("amazon-product-lookup");
return fakefetch.ok(product());
});
const out = await lookupProduct(ASIN, MP);
expect(out).toEqual({
asin: ASIN,
title: "PlayStation5 console (slim)",
link: `https://www.amazon.com/dp/${ASIN}`,
buyBoxInfo: { price: 444.99, currencyCode: "USD", sellerId: "A2L77EE7U53NWQ" },
});
expect(fakefetch.CALLS).toEqual([["amazon-product-lookup", { asin: ASIN, marketplaceId: MP }]]);
});
test('no featured offer price "N/A" decodes to null, not 0', async () => {
fakefetch.install(() => fakefetch.ok(product("N/A")));
const out = await lookupProduct(ASIN, MP);
expect(out.buyBoxInfo.price).toBeNull();
});
test("404 is billed and must not be retried", async () => {
fakefetch.install(() => fakefetch.err(404, "Product not found."));
await expect(withRetry(() => lookupProduct(ASIN, MP))).rejects.toMatchObject({ status: 404 });
expect(fakefetch.CALLS).toHaveLength(1);
});
test("free gate 400 (missing marketplaceId) is reported, not retried", async () => {
fakefetch.install(() => fakefetch.err(400, "Missing required parameter(s): marketplaceId"));
const promise = lookupProduct(ASIN, undefined);
await expect(promise).rejects.toBeInstanceOf(ApiError);
await expect(promise).rejects.toMatchObject({ status: 400 });
});
test("401 insufficient credits is not retried", async () => {
fakefetch.install(() => fakefetch.err(401, "Insufficient user credits."));
await expect(withRetry(() => lookupProduct(ASIN, MP))).rejects.toMatchObject({ status: 401 });
expect(fakefetch.CALLS).toHaveLength(1);
});
test("429 concurrency cap is free and retried, honours Retry-After", async () => {
let n = 0;
fakefetch.install(() => {
n += 1;
if (n === 1) return fakefetch.err(429, "Max 40 concurrent requests per API key.", { "Retry-After": "2" });
return fakefetch.ok(product());
});
const out = await withRetry(() => lookupProduct(ASIN, MP));
expect(out.asin).toBe(ASIN);
expect(fakefetch.CALLS).toHaveLength(2);
});
test("500 is refunded and retried exactly once, then stops", async () => {
fakefetch.install(() => fakefetch.err(500, "Internal error."));
await expect(withRetry(() => lookupProduct(ASIN, MP))).rejects.toMatchObject({ status: 500 });
expect(fakefetch.CALLS).toHaveLength(2); // original + exactly one retry
});
test("502/503 are billed and retried up to the attempt cap", async () => {
fakefetch.install(() => fakefetch.err(502, "Upstream error."));
await expect(withRetry(() => lookupProduct(ASIN, MP), 3)).rejects.toMatchObject({ status: 502 });
expect(fakefetch.CALLS).toHaveLength(3); // 502 is billed on every attempt, cap must be respected
});
test("a wrong or missing X-Api-Key header fails the test, not silently", async () => {
fakefetch.install(() => fakefetch.ok(product()));
const wrongKey = process.env.SELLERMAGNET_API_KEY;
process.env.SELLERMAGNET_API_KEY = "wrong-key";
await expect(lookupProduct(ASIN, MP)).rejects.toThrow(/X-Api-Key/);
process.env.SELLERMAGNET_API_KEY = wrongKey;
});
test("fake fetch fails closed without install()", async () => {
await expect(lookupProduct(ASIN, MP)).rejects.toThrow(/install\(route\)/);
});
npm test output (unmodified run)
> apiblog-jest@1.0.0 test
> node --experimental-vm-modules node_modules/.bin/jest --verbose
(node:3494411) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Test Suites: 1 passed, 1 total
Tests: 10 passed, 10 total
Snapshots: 0 total
Time: 3.848 s, estimated 5 s
Ran all test suites.
Never hand-write a mock of client.js itself
A mock that reimplements get() or withRetry() tests the mock's own logic, not the real client. Fake the one call the real code makes to the network (fetch), so a real bug in the parsing or retry logic still shows up as a failing test.
Does the same pattern work for other languages?
Yes, fake the one call the real code makes to the network, whatever that call is. The Python mock testing guide fakes requests.Session.get the same way this guide fakes fetch. The 429/Retry-After guide and the TypeScript quickstart are the production behaviour this test suite checks against; keep both in sync when the retry logic changes. Credit costs and the free trial are on the pricing page, and every endpoint's parameters are in the API documentation and code examples.
Frequently Asked Questions
Do I need TypeScript or a compiler to run these tests?
No. The client, retry and lookup modules are plain JavaScript with JSDoc types, so Jest runs them directly under Node's ESM loader. The same fake-fetch pattern works whether the real client is written as JS, TS compiled to JS, or a .mjs file.
What should I fake: my own client module, or fetch itself?
Fake only the global fetch function, never your own client.js or retry.js. A hand-written mock of your own module tests the mock, not the code that ships; faking fetch one level below your code means a real parsing or retry bug still shows up as a failing test.
How do I simulate a 429 without waiting for Retry-After in every test run?
Keep the real backoff math in retry.js and let the test actually await the short promise (a few hundred milliseconds here, since the backoff is 2 ** attempt * 100 ms); there is no need to fake timers for a wait this short, and faking them risks hiding a real await bug.
Does faking fetch also test the credit math?
Yes, if the fake records every call it receives. Counting fakefetch.CALLS after a 429 or a refunded 500 verifies the exact number of billed attempts a scenario produced, the same number a real burst of requests would be charged for.
Should the fake fetch allow a real request if a test forgets to install a route?
No. Make the default globalThis.fetch reject immediately, and have each test call install(route) before running the code under test. A forgotten setup then fails the test loudly instead of quietly sending a real, billed request to the live API.
Why does the client throw instead of fetch's own AbortError on a wrong key?
It doesn't here: a wrong key still reaches the real client code and is asserted inside the fake fetch itself, so the test fails with a clear message instead of a confusing timeout or a passing test that never checked the header at all.
Bottom line: fake fetch (or your language's equivalent HTTP call), run the real client code against scripted responses for every billed, free and refunded status, and make the fake fail closed so a forgotten setup cannot send a real, billed request. The whole suite above runs in a few seconds and spends zero credits.