Home / Blog / Mock the SellerMagnet API in Jest Tests Without Spending Credits

Mock the SellerMagnet API in Jest Tests Without Spending Credits

Test the Node.js code that calls the SellerMagnet API without hitting it: fake only the global `fetch`, run the real `client.js` / `retry.js` / `lookup.js` helpers from the TypeScript quickstart unchanged, and cover every billed, free and refunded status code with a Jest suite that fails closed.

November 13, 2026
5 min read
SellerMagnet Team
Share & Bookmark
Terminal window showing a Jest run that fakes the SellerMagnet API's fetch call while the real Node.js client code runs unchanged

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.fetch only; 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.js uses 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.

Scenarios worth testing, and what the fake needs to script for each.
ScenarioChargedRetry?Test should assert
happy path with real data shapesbillednoparse and return
404 product not foundbillednoreport it; do not retry
free gate 400 (missing marketplaceId)freenofix the call, no retry
401 insufficient creditsfreenostop; top up credits
429 concurrency capfreeyeshonour Retry-After, then retry
500 internal errorrefundedyesretry once, then stop
502 / 503 upstreambilledyesretry up to the attempt cap
wrong or missing X-Api-Key headern/anofail 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.

Blueprint sequence diagram of a Jest test faking the SellerMagnet API's fetch call while the real client code runs unchanged
Only fetch is faked; get() and withRetry() run unmodified.

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;
}
Blueprint table of Jest test scenarios against the SellerMagnet API's status codes, billing and the correct client reaction
Each row needs its own scripted response; a happy-path test alone misses most of this.

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.

Related Articles

Continue exploring topics related to this article.

Terminal window running an n8n workflow that reports an Amazon Buy Box price drop
Amazon API No Code Automation

Amazon Product Data in n8n: HTTP Request Node, No Code

Pull Amazon product data into n8n with the HTTP Request node: a Schedule trigger, one call to /amazon-product-offers per ASIN, a Data table that holds the last known price, and an alert branch on a Buy Box price drop. Built and run twice in a real n8n instance, exported as an importable workflow JSON, not just unit-run JavaScript.

Nov 13, 2026 Read
Terminal showing a Supabase Edge Function invoked by pg_cron calling the SellerMagnet product lookup API
Amazon API Supabase Typescript

Amazon Product API on Supabase Edge Functions: Two Limits, Not One

Run an Amazon Buy Box price-watch on Supabase Edge Functions, invoked on a schedule by pg_cron. retry.js needs no change at all; client.js needs exactly one line, reading the API key with Deno.env.get instead of process.env. The real traps: a scheduled call gets a silent 401 unless verify_jwt is explicitly off, and Supabase meters wall-clock duration and CPU time as two separate limits, so a CPU-cheap batch can still run out of real seconds.

Nov 13, 2026 Read
Terminal showing a Vercel Function invoked by a Vercel Cron Job calling the SellerMagnet product lookup API
Amazon API Javascript Vercel

Amazon Product API on Vercel Functions: the Cron Job Is a Public URL

Run an Amazon Buy Box price-watch on a Vercel Function triggered by a Vercel Cron Job. client.js and retry.js need zero changes from the TypeScript quickstart. The real traps: a Cron Job path is a public URL unless the handler itself checks the CRON_SECRET bearer token Vercel sends, Hobby-plan cron jobs fire at most once a day with imprecise timing, and Vercel can miss or duplicate a cron delivery, so the handler must be safe to repeat.

Nov 13, 2026 Read
Back to Blog

Ready to Extract Amazon Data at Scale?

Start building with SellerMagnet API today. Real-time product data, competitive pricing, and review analytics at your fingertips.

150 free API credits • No credit card required • Cancel anytime