Skip to content

Prevent key conflict and keep iterable behavior #104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,7 @@ By including this in your Jest setup you'll allow tests that expect a
also allow you to use the mocks provided to check that your localStorage is
being used as expected.

The `__STORE__` attribute of `localStorage.__STORE__` or
`sessionStorage.__STORE__` is made available for you to directly access the
storage object if needed.
You can directly access the storage object with `localStorage` or `sessionStorage` if needed.

### Test Examples

Expand All @@ -115,8 +113,8 @@ test('should save to localStorage', () => {
VALUE = 'bar';
dispatch(action.update(KEY, VALUE));
expect(localStorage.setItem).toHaveBeenLastCalledWith(KEY, VALUE);
expect(localStorage.__STORE__[KEY]).toBe(VALUE);
expect(Object.keys(localStorage.__STORE__).length).toBe(1);
expect(localStorage[KEY]).toBe(VALUE);
expect(localStorage.length).toBe(1);
});
```

Expand All @@ -127,7 +125,7 @@ Check that your `sessionStorage` is empty, examples work with either
test('should have cleared the sessionStorage', () => {
dispatch(action.reset());
expect(sessionStorage.clear).toHaveBeenCalledTimes(1);
expect(sessionStorage.__STORE__).toEqual({}); // check store values
expect(sessionStorage).toEqual({}); // check store values
expect(sessionStorage.length).toBe(0); // or check length
});
```
Expand All @@ -140,7 +138,7 @@ test('should not have saved to localStorage', () => {
VALUE = 'bar';
dispatch(action.notIdempotent(KEY, VALUE));
expect(localStorage.setItem).not.toHaveBeenLastCalledWith(KEY, VALUE);
expect(Object.keys(localStorage.__STORE__).length).toBe(0);
expect(localStorage.length).toBe(0);
});
```

Expand All @@ -151,7 +149,7 @@ beforeEach(() => {
// values stored in tests will also be available in other tests unless you run
localStorage.clear();
// or directly reset the storage
localStorage.__STORE__ = {};
localStorage = {};
// you could also reset all mocks, but this could impact your other mocks
jest.resetAllMocks();
// or individually reset a mock used
Expand All @@ -163,17 +161,17 @@ test('should not impact the next test', () => {
VALUE = 'bar';
dispatch(action.update(KEY, VALUE));
expect(localStorage.setItem).toHaveBeenLastCalledWith(KEY, VALUE);
expect(localStorage.__STORE__[KEY]).toBe(VALUE);
expect(Object.keys(localStorage.__STORE__).length).toBe(1);
expect(localStorage[KEY]).toBe(VALUE);
expect(localStorage.length).toBe(1);
});

test('should not be impacted by the previous test', () => {
const KEY = 'baz',
VALUE = 'zab';
dispatch(action.update(KEY, VALUE));
expect(localStorage.setItem).toHaveBeenLastCalledWith(KEY, VALUE);
expect(localStorage.__STORE__[KEY]).toBe(VALUE);
expect(Object.keys(localStorage.__STORE__).length).toBe(1);
expect(localStorage[KEY]).toBe(VALUE);
expect(localStorage.length).toBe(1);
});
```

Expand Down
52 changes: 37 additions & 15 deletions __tests__/index.js → __tests__/localstorage.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
describe('storage', () =>
describe('storage', () => {
[localStorage, sessionStorage].map(storage => {
// https://html.spec.whatwg.org/multipage/webstorage.html#storage
beforeEach(() => {
Expand All @@ -13,18 +13,18 @@ describe('storage', () =>
VALUE = 'bar';
storage.setItem(KEY, VALUE);
expect(storage.setItem).toHaveBeenLastCalledWith(KEY, VALUE);
expect(storage.__STORE__[KEY]).toBe(VALUE);
expect(Object.keys(storage.__STORE__).length).toBe(1);
expect(storage[KEY]).toBe(VALUE);
expect(Object.keys(storage).length).toBe(1);
storage.clear();
expect(storage.clear).toHaveBeenCalledTimes(1);
expect(Object.keys(storage.__STORE__).length).toBe(0);
expect(storage.__STORE__[KEY]).toBeUndefined();
expect(Object.keys(storage).length).toBe(0);
expect(storage[KEY]).toBeUndefined();
storage.setItem(KEY, VALUE);
expect(storage.setItem).toHaveBeenLastCalledWith(KEY, VALUE);
storage.clear();
expect(storage.clear).toHaveBeenCalledTimes(2);
expect(Object.keys(storage.__STORE__).length).toBe(0);
expect(storage.__STORE__[KEY]).toBeUndefined();
expect(Object.keys(storage).length).toBe(0);
expect(storage[KEY]).toBeUndefined();
});

// setItem
Expand All @@ -36,18 +36,18 @@ describe('storage', () =>
VALUE3 = 42;
storage.setItem(KEY, VALUE1);
expect(storage.setItem).toHaveBeenLastCalledWith(KEY, VALUE1);
expect(storage.__STORE__[KEY]).toBe(VALUE1);
expect(storage[KEY]).toBe(VALUE1);
storage.setItem(KEY, VALUE2);
expect(storage.setItem).toHaveBeenLastCalledWith(KEY, VALUE2);
expect(storage.__STORE__[KEY]).toBe(VALUE2);
expect(storage[KEY]).toBe(VALUE2);
storage.setItem(KEY, VALUE3);
expect(storage.__STORE__[KEY]).toBe(VALUE3.toString());
expect(storage[KEY]).toBe(VALUE3.toString());
storage.setItem(KEY, null);
expect(storage.__STORE__[KEY]).toBe('null');
expect(storage[KEY]).toBe('null');
storage.setItem(KEY, undefined);
expect(storage.__STORE__[KEY]).toBe('');
expect(storage[KEY]).toBe('');
storage.setItem(KEY, {});
expect(storage.__STORE__[KEY]).toBe('[object Object]');
expect(storage[KEY]).toBe('[object Object]');
});

// getItem
Expand All @@ -56,7 +56,8 @@ describe('storage', () =>
const KEY = 'foo',
VALUE1 = 'bar',
VALUE2 = 'baz',
DOES_NOT_EXIST = 'does not exist';
DOES_NOT_EXIST = 'does not exist',
LOCAL_STORAGE_RESERVED_KEY = 'key';

storage.setItem(KEY, VALUE1);
expect(storage.getItem(KEY)).toBe(VALUE1);
Expand All @@ -68,6 +69,14 @@ describe('storage', () =>

expect(storage.getItem(DOES_NOT_EXIST)).toBeNull();
expect(storage.getItem).toHaveBeenLastCalledWith(DOES_NOT_EXIST);

expect(() =>
storage.setItem(LOCAL_STORAGE_RESERVED_KEY, VALUE1)
).not.toThrow();
expect(storage.getItem(LOCAL_STORAGE_RESERVED_KEY)).toBe(VALUE1);
expect(storage.getItem).toHaveBeenLastCalledWith(
LOCAL_STORAGE_RESERVED_KEY
);
});

// removeItem
Expand Down Expand Up @@ -132,4 +141,17 @@ describe('storage', () =>
expect(storage.toString()).toEqual('[object Storage]');
expect(storage.toString).toHaveBeenCalledTimes(1);
});
}));

test('iterations', () => {
storage.setItem('key1', 'value1');
storage.setItem('key2', 'value2');
storage.setItem('key3', 'value3');

expect(Object.entries(storage)).toEqual([
['key1', 'value1'],
['key2', 'value2'],
['key3', 'value3'],
]);
});
});
});
20 changes: 9 additions & 11 deletions __tests__/setup.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { LocalStorage } from '../src/localstorage';

describe('setup', () => {
const orignalImpGlobsl = {};

const setupGloabls = (restore = false) => {
const setupGlobals = (restore = false) => {
[
'_localStorage',
'localStorage',
Expand All @@ -20,31 +18,31 @@ describe('setup', () => {
});
};

const restoreGlobals = () => setupGloabls(true);
const restoreGlobals = () => setupGlobals(true);

beforeEach(() => {
setupGloabls();
setupGlobals();
jest.resetModuleRegistry();
});

afterEach(() => {
restoreGlobals();
});

['_localStorage', '_sessionStorage'].forEach(gKey => {
it(`[${gKey}] should define a property on the global object with writable false`, () => {
['_localStorage', '_sessionStorage'].forEach(globalKey => {
it(`[${globalKey}] should define a property on the global object with writable false`, () => {
require('../src/setup');
expect(global[gKey.replace('_', '')].constructor.name).toBe(
expect(global[globalKey.replace('_', '')].constructor.name).toBe(
'LocalStorage'
);
});

it(`[${gKey}] should define a property on the global object with writable false`, () => {
global[gKey] = true;
it(`[${globalKey}] should define a property on the global object with writable false`, () => {
global[globalKey] = true;
require('../src/setup');
let e;
try {
global[`_${gKey.replace('_', '')}`] = 'blah';
global[`_${globalKey.replace('_', '')}`] = 'blah';
} catch (_e) {
e = _e;
}
Expand Down
98 changes: 53 additions & 45 deletions src/localstorage.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,53 @@
export class LocalStorage {
constructor(jest) {
Object.defineProperty(this, 'getItem', {
enumerable: false,
value: jest.fn(key => this[key] || null),
});
Object.defineProperty(this, 'setItem', {
enumerable: false,
// not mentioned in the spec, but we must always coerce to a string
value: jest.fn((key, val = '') => {
this[key] = val + '';
}),
});
Object.defineProperty(this, 'removeItem', {
enumerable: false,
value: jest.fn(key => {
delete this[key];
}),
});
Object.defineProperty(this, 'clear', {
enumerable: false,
value: jest.fn(() => {
Object.keys(this).map(key => delete this[key]);
}),
});
Object.defineProperty(this, 'toString', {
enumerable: false,
value: jest.fn(() => {
return '[object Storage]';
}),
});
Object.defineProperty(this, 'key', {
enumerable: false,
value: jest.fn(idx => Object.keys(this)[idx] || null),
});
} // end constructor

get length() {
return Object.keys(this).length;
}
// for backwards compatibility
get __STORE__() {
return this;
}
}
export const createStorage = jest => {
const storage = Object.create(null);

const methods = {
getItem: jest.fn(key => {
if (key in storage) {
return storage[key];
}

return null;
}),

setItem: jest.fn((key, value = '') => {
storage[key] = value + '';
}),

removeItem: jest.fn(key => {
delete storage[key];
}),

clear: jest.fn(() => {
Object.keys(storage).forEach(key => delete storage[key]);
}),

toString: jest.fn(() => '[object Storage]'),

key: jest.fn(idx => Object.keys(storage)[idx] || null),

get length() {
return Object.keys(storage).length;
},

get constructor() {
return {
name: 'LocalStorage',
};
},

get __STORE__() {
return { ...storage };
},
};

return new Proxy(storage, {
get(_, prop) {
if (prop in methods) {
return methods[prop];
}

return Reflect.get(...arguments);
},
});
};
10 changes: 5 additions & 5 deletions src/setup.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { LocalStorage } from './localstorage';
import { createStorage } from './localstorage';

if (typeof global._localStorage !== 'undefined') {
Object.defineProperty(global, '_localStorage', {
value: new LocalStorage(jest),
value: createStorage(jest),
writable: false,
});
} else {
global.localStorage = new LocalStorage(jest);
global.localStorage = createStorage(jest);
}

if (typeof global._sessionStorage !== 'undefined') {
Object.defineProperty(global, '_sessionStorage', {
value: new LocalStorage(jest),
value: createStorage(jest),
writable: false,
});
} else {
global.sessionStorage = new LocalStorage(jest);
global.sessionStorage = createStorage(jest);
}