Pinning a spec down with a targeted unit test — reaching into library internals, patching jsdom, and typing mocks precisely
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.
localStorage (token, userId, email, name) and redirect to /login/loginA 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 line | Test |
|---|---|
| clears storage + redirects | Test 1 — clears auth data and redirects to /login on 401 |
| loop prevention | Test 2 — clears storage but does not redirect when already on /login |
| non-401 bubbles | Test 3 — bubbles non-401 errors without touching storage or location |
| network error bubbles | Test 4 — bubbles network errors without touching storage or location |
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:
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.
localStorage.clear() and hrefSetter.mockClear() so each test starts from a known state.
localStorage, set the path via window.history.pushState(...), build a fake AxiosErrorawait responseHandler.rejected(error) and assert it re-rejects the same errorlocalStorage was / wasn't cleared, and that hrefSetter was / wasn't called with /loginResponseHandler Type
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[]:
AxiosError and can do anything. Our code returns Promise.reject(error), hence unknown.
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.
as unknown as Double Cast
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.
as unknownas NewType
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.
Symbol("impl") Trick
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.
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":
When user code does window.location.href = "/login", the facade's setter internally does this[implSymbol].href = value — delegating to the impl.
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.
jest.fn<void, [string]>()
So jest.fn<void, [string]>() means: returns nothing (mimics a setter), takes one argument of type string.
The [string] wrapping is important — it means a fixed tuple of exactly one string, not an array of strings. TypeScript distinguishes:
| Syntax | Meaning |
|---|---|
| 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 |
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.
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.
Object.getPrototypeOf(rex) returns Dog.prototype — the object one step up the chain.
set href(v) {}) are defined on the prototype by class syntax, not on each instancehref property, which is locked per WHATWG spec
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.
| Concept | What it is |
|---|---|
| ResponseHandler | Hand-written type describing axios's internal {fulfilled, rejected} entry |
| as unknown as | Two-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 |
| prototype | Object one step up the chain where class methods/accessors actually live |
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.