Learning Session Summary

Jest & Unit Testing

Pinning a spec down with a targeted unit test — reaching into library internals, patching jsdom, and typing mocks precisely

The Big Picture

What the test pins down

The error-handling spec defines what the axios response interceptor must do. axios.spec.ts is a unit test that pins exactly those rules — nothing more, nothing less.

  • On 401 — clear localStorage (token, userId, email, name) and redirect to /login
  • Loop prevention — skip the redirect if already on /login
  • Non-401 errors — bubble up to the caller untouched
  • Network errors — also bubble up, without touching storage or location
Mental model

A unit test isolates just the one function the spec describes. Rather than firing a real HTTP request and mocking the transport, we grab the registered rejected handler directly and invoke it with a fabricated error.

Spec lineTest
clears storage + redirectsTest 1 — clears auth data and redirects to /login on 401
loop preventionTest 2 — clears storage but does not redirect when already on /login
non-401 bubblesTest 3 — bubbles non-401 errors without touching storage or location
network error bubblesTest 4 — bubbles network errors without touching storage or location

File Structure, Top to Bottom

① Reach into the registered handler

When axios.ts is imported, it calls api.interceptors.response.use(success, error). Axios stores those callbacks in an internal handlers[] array. We grab the rejected function directly:

const responseHandler = (
  api.interceptors.response as unknown as { handlers: ResponseHandler[] }
).handlers[0];
② Patch jsdom's location.href setter

jsdom treats window.location and location.href as non-configurable — you can't jest.spyOn them. We reach through the hidden Symbol("impl") key, grab its prototype, and swap href's setter for jest.fn(). Restored in afterAll.

③ Reset between tests

localStorage.clear() and hrefSetter.mockClear() so each test starts from a known state.

④ Each test follows the same three-beat shape
  • Arrange — seed localStorage, set the path via window.history.pushState(...), build a fake AxiosError
  • Actawait responseHandler.rejected(error) and assert it re-rejects the same error
  • Assert — check localStorage was / wasn't cleared, and that hrefSetter was / wasn't called with /login

The ResponseHandler Type

A hand-written description of axios internals

ResponseHandler isn't imported from axios — axios doesn't export it. It's a local TypeScript type describing what we expect to find in handlers[]:

type ResponseHandler = {
  fulfilled: (value: AxiosResponse) => AxiosResponse | Promise<AxiosResponse>;
  rejected: (error: AxiosError) => unknown;
};
fulfilled
The success callback — takes a response, returns it (possibly transformed) or a promise of it.
rejected
The error callback — takes an AxiosError and can do anything. Our code returns Promise.reject(error), hence unknown.
Why write it

Think of it as: "Here's my understanding of axios's internal data structure; please check my code against this." Without it, every access to handlers[0] would be any and you'd lose all type safety inside the tests.

The as unknown as Double Cast

TypeScript's escape hatch for incompatible casts

In TypeScript you can cast A as B directly only if the two types overlap. api.interceptors.response is typed as AxiosInterceptorManager<AxiosResponse>, which publicly exposes only use, eject, and clear. There's no handlers in the public type.

// ERROR — types don't overlap
api.interceptors.response as { handlers: ResponseHandler[] }

// Works — unknown is the reset button
api.interceptors.response as unknown as { handlers: ResponseHandler[] }
Original type
public API only
as unknown
forget everything
as NewType
re-assert shape
In plain English

as unknown as X means: "I know better than you — this value really is shaped like X, trust me." A two-step force-cast, needed here because handlers is private axios internals that the public type intentionally hides.

The Symbol("impl") Trick

Symbols, briefly

A Symbol is a unique, unforgeable property key. Regular property keys are strings (obj.foo); a symbol-keyed property becomes invisible to normal iteration — it won't show up in Object.keys() or for...in.

const hidden = Symbol("secret");
const obj = { visible: 1, [hidden]: 2 };

Object.keys(obj); // ["visible"]
Object.getOwnPropertySymbols(obj); // [Symbol(secret)]
How jsdom uses it

jsdom's window.location is the WHATWG-spec facade. The actual implementation (URL parsing, navigation, inner state) lives on a separate LocationImpl object, attached to the facade under a hidden symbol key whose description is "impl":

// jsdom does (conceptually):
const implSymbol = Symbol("impl");
window.location[implSymbol] = new LocationImpl(...);

When user code does window.location.href = "/login", the facade's setter internally does this[implSymbol].href = value — delegating to the impl.

Why the test needs it

The facade's href setter is locked non-configurable by WHATWG spec. But the impl's href setter on its prototype is configurable. So we dig through the symbol to find the impl, get its prototype, and swap in our jest mock. The facade keeps delegating to the impl, which now hits our mock.

const implSymbol = Object.getOwnPropertySymbols(window.location).find(
  (s) => s.description === "impl",
) as symbol;

Typing the Mock: jest.fn<void, [string]>()

Two generic parameters describe the signature
jest.fn<TReturn, TArgs extends any[]>()
// ^^^^^^^ ^^^^^^^^^^^^^^^^^^^
// what it the types of its
// returns arguments, as a tuple

So jest.fn<void, [string]>() means: returns nothing (mimics a setter), takes one argument of type string.

Square brackets = tuple, not array

The [string] wrapping is important — it means a fixed tuple of exactly one string, not an array of strings. TypeScript distinguishes:

SyntaxMeaning
string[]Array of any number of strings
[string]Exactly one argument, a string
[string, number]Exactly two args: a string, then a number
[string, ...number[]]First arg string, then any number of numbers
Why tuples

Function argument lists are ordered and fixed-arity, not arrays. Tuples describe that precisely. TypeScript will now warn you if you call hrefSetter() with no args or with a number.

The Prototype Chain

Every object has a hidden link to another

When you access a property on an object and it doesn't have that property directly, JS walks up the prototype chain looking for it.

class Dog {
  bark() { console.log("woof"); }
}

const rex = new Dog();
rex.bark(); // works — bark lives on Dog.prototype, not on rex

Object.getPrototypeOf(rex) returns Dog.prototype — the object one step up the chain.

Why target the prototype, not the instance?
  • The setter lives there — class accessors (set href(v) {}) are defined on the prototype by class syntax, not on each instance
  • It's configurable there — unlike the facade's own href property, which is locked per WHATWG spec
The chain visually
window.location ← facade (own href — locked)
  │
  │ [Symbol("impl")] → LocationImpl instance
  │                  │
  │                  │ [[Prototype]] → LocationImpl.prototype ← OUR TARGET
  │                           │
  │                           │ set href(v) {...} ← we replace this
Flow when href is set

window.location.href = "/login" → facade's setter runs → delegates to impl.href = "/login" → impl has no own href, so JS walks up → finds our jest.fn on LocationImpl.prototype → mock records the call. Non-configurable property intercepted, spec compliance intact.

TL;DR — Five Tricky Pieces

ConceptWhat it is
ResponseHandlerHand-written type describing axios's internal {fulfilled, rejected} entry
as unknown asTwo-step force-cast to bypass TS's "types don't overlap" check
Symbol("impl")Hidden key jsdom uses to attach the real Location implementation to the facade
jest.fn<void, [string]>Mock typed as (arg: string) => void — the [string] is a tuple, not an array
prototypeObject one step up the chain where class methods/accessors actually live

Key Takeaway

A good unit test isolates exactly one thing the spec describes — nothing more. When the target hides behind library internals or browser specs, you reach through the layers (private fields, hidden symbols, prototypes) rather than mocking at a higher level. The tests stay narrow and honest about what they're actually pinning down.