Mocking localStorage in Vitest
stubWebStorage replaces localStorage or sessionStorage with a fresh in-memory storage for one test. You can fill it in the same call and check what ended up stored with one toEqual.
import { beforeEach, describe, expect, it } from 'vitest';
import { type WebStorageStub, stubWebStorage } from 'vitest-auto-spy/dom-stubs';
import { forgetUser, loadTheme, saveTheme } from './preferences';
describe('preferences', () => {
let local: WebStorageStub;
beforeEach(() => {
local = stubWebStorage('localStorage', { items: { theme: 'dark', token: 'abc' } });
});
it('reads the saved theme', () => {
expect(loadTheme()).toBe('dark');
});
it('writes the theme back', () => {
saveTheme('light');
expect(local.snapshot()).toEqual({ theme: 'light', token: 'abc' });
});
it('forgets the user in both storages', () => {
const session = stubWebStorage('sessionStorage', { items: { draft: '{}' } });
forgetUser();
expect(local.snapshot()).toEqual({ theme: 'dark' });
expect(session.snapshot()).toEqual({});
});
});Restore after each test. Call setupAutoSpy() once in your setup file (the file listed in setupFiles of your Vitest config). It puts back the original storage after every test, so you write no afterEach:
// vitest.setup.ts
import { setupAutoSpy } from 'vitest-auto-spy/setup';
setupAutoSpy();Without setupAutoSpy(), restore it yourself:
import { afterEach } from 'vitest';
import { restoreMockedProps } from 'vitest-auto-spy';
afterEach(() => restoreMockedProps());Options and return value
stubWebStorage(key?, options?)
| Argument | Type | Default | Meaning |
|---|---|---|---|
key | 'localStorage' | 'sessionStorage' | 'localStorage' | which global to replace |
options.items | Record<string, string> | empty | what the storage holds at the start; stored through setItem |
options.view | object | null | document.defaultView | another window object to install on too; null installs on globalThis only |
You rarely need view. By default the stub goes on globalThis and also on document.defaultView (the window of jsdom or happy-dom) when that is a different object. view replaces that second target:
- pass another window object when your code reads storage from it, for example a fake
windowyou inject; - pass
nullto install onglobalThisonly;window.localStoragethen keeps the environment's storage.
Preload values with items. getItem is a real method, not a spy. To force one answer anyway, spy on it: vi.spyOn(local.storage, 'getItem').mockReturnValue('dark').
It returns a WebStorageStub:
| Member | What it is |
|---|---|
storage | the installed Storage object, the one the global returns until the test ends |
snapshot() | a plain-object copy of what the storage holds right now |
What the stub behaves like
- A real
Storage.getItem,setItem,removeItem,clear,keyandlengthwork like the browser's. A number written withsetItemreads back as a string. A missing key readsnull, notundefined. A hand-written fake often gets these wrong and passes tests the browser would fail. - Filled like real code fills it.
itemsgo in throughsetItem, so values are converted the same way. - Checked as data.
snapshot()returns a copy, so onetoEqualchecks the whole storage. - Both names. In
jsdomandhappy-dom,windowcan be a different object fromglobalThis. The stub installs on both, solocalStorageandwindow.localStoragereturn the same stub. - Any environment. It also works in the
nodetest environment, which has no storage of its own.
Check that a specific call happened
Usually a test should check what was stored, not how. Sometimes the call itself is the behaviour: a cache that must not write twice, or a key that must be removed rather than overwritten. Then spy on the installed storage, not on Storage.prototype:
it('writes the theme once', () => {
const setItem = vi.spyOn(local.storage, 'setItem');
saveTheme('dark');
expect(setItem).toHaveBeenCalledTimes(1);
expect(setItem).toHaveBeenCalledWith('theme', 'dark');
});The spy sees every call your code makes, and it leaves every other Storage object alone.
Test a "storage is full" error
The stub has no size limit, so setItem never throws on its own. Make it throw:
vi.spyOn(local.storage, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError');
});Compared with other approaches
| Approach | Downside | Use it when |
|---|---|---|
A hand-written Map fake + vi.stubGlobal | About twenty lines per project; you must remember the restore and copy the conversion rules | Rarely: stubWebStorage already is this fake, with the restore built in |
A …-localstorage-mock package in setupFiles | One storage for the whole file: what one test writes, the next one reads, unless you clear it | Your tests only need storage to exist and never check its contents |
vi.spyOn(Storage.prototype, 'setItem') | Patches every Storage object; depends on the environment's storage (from jsdom or happy-dom) working at all | You must check calls on the environment's own storage |
stubWebStorage | One import from vitest-auto-spy/dom-stubs | A spec fills storage, reads it back, or must start empty |
Troubleshooting: localStorage is broken on Node 25 and later
Under Vitest's jsdom or happy-dom environment on Node 25+, localStorage can be broken before any spec runs:
- Node 25:
setItem is not a function; - Node 26:
localStorageisundefined.
The cause: Node 25 added its own global localStorage. Vitest copies the DOM environment's globals onto the test's global object, but it skips names that already exist. So the Node version stays, and the working one from jsdom or happy-dom never arrives. setupAutoSpy() fixes this by default. The details are in Web Storage the runner never handed over. stubWebStorage works either way, because it replaces whatever is there.
Limits
- No property access to items.
localStorage.tokenandObject.keys(localStorage)do not see the items. UsegetItemandsnapshot(). - No
storageevent. Nothing is sent to other windows; a test has none. - No size limit. See Test a "storage is full" error.
Related
stubWebStoragein the setup reference: the API next to the Node 25 fix.- Observer stubs: the same install-and-restore pattern for
IntersectionObserver,ResizeObserverandMutationObserver.