251 words, 2 min read

When writing JavaScript code that manipulates the browser URL, you might use history.pushState to update the query parameters or path without reloading the page. Verifying this in tests is not as straightforward as it seems, especially when using JSDOM in Vitest or Jest.

In a real browser, calling:

history.pushState({}, null, "/new-path?foo=bar");

will update window.location.pathname and window.location.search. However, JSDOM does not fully simulate this behaviour. While pushState is called, window.location remains unchanged. This means a test like the following will fail:

it("updates the query params", () => {
history.pushState({}, null, "/new-path?foo=bar");
expect(window.location.search).toBe("?foo=bar"); // fails in JSDOM
});

JSDOM updates its internal history stack but does not re-parse window.location.

A more reliable way is to test whether your code called history.pushState with the correct arguments. In Vitest you can use vi.spyOn:

it("removes filters from the URL", async () => {
const pushSpy = vi.spyOn(history, "pushState");
await useFiltersToParams([], MOCK_ROUTER);
expect(pushSpy).toHaveBeenCalledWith(
{},
null,
expect.not.stringContaining("filters")
);
pushSpy.mockRestore();
});

Here we are not asserting against window.location.search, but against the URL passed into pushState. This ensures the function under test is producing the expected side effect.

When to use spies vs window.location:

  • Unit tests: prefer spying on pushState, because it isolates your code’s behavior from JSDOM quirks.
  • Integration or end-to-end tests: use window.location assertions in Cypress, Playwright, or a real browser environment, where the URL actually changes.

TLDR

When testing URL updates in Vitest, don’t rely on window.location. Instead, spy on history.pushState to verify the correct URL was passed. This keeps your tests reliable while avoiding JSDOM limitations.