From a79da4cb2d6ed255224b07e43a2db64d02dd7cf8 Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Wed, 16 Mar 2022 11:27:08 -0400 Subject: [PATCH] feat: useState test util (#31) --- .../ImageSettingsModal/hooks.test.js | 98 +++++++---------- src/testUtils.js | 101 ++++++++++++++++++ 2 files changed, 140 insertions(+), 59 deletions(-) diff --git a/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.test.js b/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.test.js index 9bb687348..478258a74 100644 --- a/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.test.js +++ b/src/editors/containers/TextEditor/components/ImageSettingsModal/hooks.test.js @@ -1,4 +1,5 @@ import { StrictDict } from '../../../../utils'; +import { MockUseState } from '../../../../../testUtils'; import * as hooks from './hooks'; jest.mock('react', () => ({ @@ -14,34 +15,13 @@ const multiDims = { height: reducedDims.height * gcd, }; -const stateKeys = StrictDict(Object.keys(hooks.state).reduce( - (obj, key) => ({ ...obj, [key]: key }), - {}, -)); +const state = new MockUseState(hooks); const hookKeys = StrictDict(Object.keys(hooks).reduce( (obj, key) => ({ ...obj, [key]: key }), {}, )); -let oldState; -const setState = {}; -const mockState = () => { - oldState = hooks.state; - const keys = Object.keys(stateKeys); - keys.forEach(key => { - setState[key] = jest.fn(); - hooks.state[key] = jest.fn(val => [val, setState[key]]); - }); -}; -const restoreState = () => { - hooks.state = { ...oldState }; -}; - -const mockStateVal = (key, val) => ( - hooks.state[key].mockReturnValueOnce([val, setState[key]]) -); - let hook; describe('ImageSettingsModal hooks', () => { @@ -119,36 +99,36 @@ describe('ImageSettingsModal hooks', () => { }); describe('lockDimensions', () => { it('does not call setLocked if lockInitialized is false', () => { - setState.locked = jest.fn(); + state.setState.locked = jest.fn(); hooks.lockDimensions({ dimensions: simpleDims, - setLocked: setState.locked, + setLocked: state.setState.locked, lockInitialized: false, }); - expect(setState.locked).not.toHaveBeenCalled(); + expect(state.setState.locked).not.toHaveBeenCalled(); }); it( 'calls setLocked with the given dimensions and minInc, including gcd', () => { - setState.locked = jest.fn(); + state.setState.locked = jest.fn(); hooks.lockDimensions({ dimensions: simpleDims, - setLocked: setState.locked, + setLocked: state.setState.locked, lockInitialized: true, }); - expect(setState.locked).toHaveBeenCalledWith({ + expect(state.setState.locked).toHaveBeenCalledWith({ ...simpleDims, minInc: { gcd: 1, ...simpleDims }, }); - setState.locked.mockClear(); + state.setState.locked.mockClear(); hooks.lockDimensions({ dimensions: multiDims, - setLocked: setState.locked, + setLocked: state.setState.locked, lockInitialized: true, }); expect(hooks.findGcd(multiDims.width, multiDims.height)).toEqual(7); - expect(setState.locked).toHaveBeenCalledWith({ + expect(state.setState.locked).toHaveBeenCalledWith({ ...multiDims, minInc: { gcd, ...reducedDims }, }); @@ -157,11 +137,11 @@ describe('ImageSettingsModal hooks', () => { }); describe('dimensionLockHooks', () => { beforeEach(() => { - mockState(); + state.mock(); hook = hooks.dimensionLockHooks({ dimensions: simpleDims }); }); afterEach(() => { - restoreState(); + state.restore(); }); test('locked is initially null', () => { @@ -169,29 +149,29 @@ describe('ImageSettingsModal hooks', () => { }); test('initializeLock calls setLockInitialized with true', () => { hook.initializeLock(); - expect(setState.lockInitialized).toHaveBeenCalledWith(true); + expect(state.setState.lockInitialized).toHaveBeenCalledWith(true); }); test('lock calls lockDimensions with lockInitialized, dimensions, and setLocked', () => { - mockStateVal(stateKeys.lockInitialized, true, setState.lockInitialized); + state.mockVal(state.keys.lockInitialized, true, state.setState.lockInitialized); hook = hooks.dimensionLockHooks({ dimensions: simpleDims }); const lockDimensionsSpy = jest.spyOn(hooks, hookKeys.lockDimensions); hook.lock(); expect(lockDimensionsSpy).toHaveBeenCalledWith({ dimensions: simpleDims, - setLocked: setState.locked, + setLocked: state.setState.locked, lockInitialized: true, }); }); test('unlock sets locked to null', () => { hook = hooks.dimensionLockHooks({ dimensions: simpleDims }); hook.unlock(); - expect(setState.locked).toHaveBeenCalledWith(null); + expect(state.setState.locked).toHaveBeenCalledWith(null); }); }); describe('dimensionHooks', () => { let lockHooks; beforeEach(() => { - mockState(); + state.mock(); lockHooks = { initializeLock: jest.fn(), lock: jest.fn(), @@ -202,15 +182,15 @@ describe('ImageSettingsModal hooks', () => { hook = hooks.dimensionHooks(); }); afterEach(() => { - restoreState(); + state.restore(); }); it('initializes dimension lock hooks with incoming dimension value', () => { - mockStateVal(stateKeys.dimensions, reducedDims, setState.local); + state.mockVal(state.keys.dimensions, reducedDims, state.setState.local); hook = hooks.dimensionHooks(); expect(hooks.dimensionLockHooks).toHaveBeenCalledWith({ dimensions: reducedDims }); }); test('value is tied to local state', () => { - mockStateVal(stateKeys.local, simpleDims, setState.local); + state.mockVal(state.keys.local, simpleDims, state.setState.local); hook = hooks.dimensionHooks(); expect(hook.value).toEqual(simpleDims); }); @@ -219,14 +199,14 @@ describe('ImageSettingsModal hooks', () => { const evt = { target: img }; it('calls initializeDimensions with selection dimensions if passed', () => { hook.onImgLoad(simpleDims)(evt); - expect(setState.dimensions).toHaveBeenCalledWith(simpleDims); - expect(setState.local).toHaveBeenCalledWith(simpleDims); + expect(state.setState.dimensions).toHaveBeenCalledWith(simpleDims); + expect(state.setState.local).toHaveBeenCalledWith(simpleDims); }); it('calls initializeDimensions with target image dimensions if no selection', () => { hook.onImgLoad({})(evt); const expected = { width: img.naturalWidth, height: img.naturalHeight }; - expect(setState.dimensions).toHaveBeenCalledWith(expected); - expect(setState.local).toHaveBeenCalledWith(expected); + expect(state.setState.dimensions).toHaveBeenCalledWith(expected); + expect(state.setState.local).toHaveBeenCalledWith(expected); }); it('calls initializeLock', () => { const initializeDimensions = jest.fn(); @@ -236,24 +216,24 @@ describe('ImageSettingsModal hooks', () => { }); describe('setHeight', () => { it('sets local height to int value of argument', () => { - mockStateVal(stateKeys.local, simpleDims, setState.local); + state.mockVal(state.keys.local, simpleDims, state.setState.local); hooks.dimensionHooks().setHeight('23.4'); - expect(setState.local).toHaveBeenCalledWith({ ...simpleDims, height: 23 }); + expect(state.setState.local).toHaveBeenCalledWith({ ...simpleDims, height: 23 }); }); }); describe('setWidth', () => { it('sets local width to int value of argument', () => { - mockStateVal(stateKeys.local, simpleDims, setState.local); + state.mockVal(state.keys.local, simpleDims, state.setState.local); hooks.dimensionHooks().setWidth('34.5'); - expect(setState.local).toHaveBeenCalledWith({ ...simpleDims, width: 34 }); + expect(state.setState.local).toHaveBeenCalledWith({ ...simpleDims, width: 34 }); }); }); describe('updateDimensions', () => { it('sets local and stored dimensions to newDimensions output', () => { const newDimensions = (args) => ({ newDimensions: args }); - mockStateVal(stateKeys.dimensions, simpleDims, setState.dimensions); - mockStateVal(stateKeys.locked, reducedDims, setState.locked); - mockStateVal(stateKeys.local, multiDims, setState.local); + state.mockVal(state.keys.dimensions, simpleDims, state.setState.dimensions); + state.mockVal(state.keys.locked, reducedDims, state.setState.locked); + state.mockVal(state.keys.local, multiDims, state.setState.local); jest.spyOn(hooks, hookKeys.newDimensions).mockImplementationOnce(newDimensions); hook = hooks.dimensionHooks(); hook.updateDimensions(); @@ -262,24 +242,24 @@ describe('ImageSettingsModal hooks', () => { locked: reducedDims, local: multiDims, }); - expect(setState.local).toHaveBeenCalledWith(expected); - expect(setState.dimensions).toHaveBeenCalledWith(expected); + expect(state.setState.local).toHaveBeenCalledWith(expected); + expect(state.setState.dimensions).toHaveBeenCalledWith(expected); }); }); }); }); describe('altTextHooks', () => { it('returns value and isDecorative, along with associated setters', () => { - mockState(); + state.mock(); const value = 'myVAL'; const isDecorative = 'IS WE Decorating?'; - mockStateVal(stateKeys.altText, value, setState.altText); - mockStateVal(stateKeys.isDecorative, isDecorative, setState.isDecorative); + state.mockVal(state.keys.altText, value, state.setState.altText); + state.mockVal(state.keys.isDecorative, isDecorative, state.setState.isDecorative); hook = hooks.altTextHooks(); expect(hook.value).toEqual(value); - expect(hook.setValue).toEqual(setState.altText); + expect(hook.setValue).toEqual(state.setState.altText); expect(hook.isDecorative).toEqual(isDecorative); - expect(hook.setIsDecorative).toEqual(setState.isDecorative); + expect(hook.setIsDecorative).toEqual(state.setState.isDecorative); }); }); describe('onInputChange', () => { diff --git a/src/testUtils.js b/src/testUtils.js index b1f346b36..fd3468294 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -1,3 +1,4 @@ +import { StrictDict } from './editors/utils'; /** * Mocked formatMessage provided by react-intl */ @@ -57,3 +58,103 @@ export const mockNestedComponents = (mapping) => Object.entries(mapping).reduce( }), {}, ); + +/** + * Mock utility for working with useState in a hooks module. + * Expects/requires an object containing the state object in order to ensure + * the mock behavior works appropriately. + * + * Expected format: + * hooks = { state: { : (val) => React.createRef(val), ... } } + * + * Returns a utility for mocking useState and providing access to specific state values + * and setState methods, as well as allowing per-test configuration of useState value returns. + * + * Example usage: + * // hooks.js + * import * as module from './hooks'; + * const state = { + * isOpen: (val) => React.useState(val), + * hasDoors: (val) => React.useState(val), + * selected: (val) => React.useState(val), + * }; + * ... + * export const exampleHook = () => { + * const [isOpen, setIsOpen] = module.state.isOpen(false); + * if (!isOpen) { return null; } + * return { isOpen, setIsOpen }; + * } + * ... + * + * // hooks.test.js + * import * as hooks from './hooks'; + * const state = new MockUseState(hooks) + * ... + * describe('exampleHook', () => { + * beforeEach(() => { state.mock(); }); + * it('returns null if isOpen is default value', () => { + * expect(hooks.exampleHook()).toEqual(null); + * }); + * it('returns isOpen and setIsOpen if isOpen is not null', () => { + * state.mockVal(state.keys.isOpen, true); + * expect(hooks.exampleHook()).toEqual({ + * isOpen: true, + * setIsOpen: state.setState[state.keys.isOpen], + * }); + * }); + * afterEach(() => { state.restore(); }); + * }); + * + * @param {obj} hooks - hooks module containing a 'state' object + */ +export class MockUseState { + constructor(hooks) { + this.hooks = hooks; + this.oldState = null; + this.setState = {}; + + this.mock = this.mock.bind(this); + this.restore = this.restore.bind(this); + this.mockVal = this.mockVal.bind(this); + } + + /** + * @return {object} - StrictDict of state object keys + */ + get keys() { + return StrictDict(Object.keys(this.hooks.state).reduce( + (obj, key) => ({ ...obj, [key]: key }), + {}, + )); + } + + /** + * Replace the hook module's state object with a mocked version, initialized to default values. + */ + mock() { + this.oldState = this.hooks.state; + Object.keys(this.keys).forEach(key => { + this.hooks.state[key] = jest.fn(val => [val, this.setState[key]]); + }); + this.setState = Object.keys(this.keys).reduce( + (obj, key) => ({ ...obj, [key]: jest.fn() }), + {}, + ); + } + + /** + * Restore the hook module's state object to the actual code. + */ + restore() { + this.hooks.state = this.oldState; + } + + /** + * Mock the state getter associated with a single key to return a specific value one time. + * @param {string} key - state key (from this.keys) + * @param {any} val - new value to be returned by the useState call. + */ + mockVal(key, val) { + this.hooks.state[key].mockReturnValueOnce([val, this.setState[key]]); + } +}