Before Testing (Prep):

Separate behavior (hooks) from render to support testing patterns

In order to effectively test a component, it is recommended to separate out business logic from its render behavior/output. In practice, this means that for most components you will wind up with a hooks.js file, separated from the component file.

hooks.js file

index.jsx component file

Note: redux hooks

Testing Strategies

General

Hook Tests hooks.test.js

  1. Test the state connections separate from how they connect to the output. This lets you mock the state values for each test.

  2. Test effects separately from output.

  3. Mock hooks to return output based on their args (recipes below)

General Flow

// Setup code (preparing mockState and test values)

describe('MyComponent hooks', () => {
  // test state hooks

  // test hook methods
    // test hook effects
    // test hook output
      // verify output behaviors
})

Testing state

const testValue = 'test-value';
describe('myComponent hooks', () => {
  describe('state values', () => {
    test('myStateField state getter should return useState passthrough', () => {
      const useState = (val) => ({ useState: val });
      jest.spyOn(react, 'useState').mockImplementationOnce(useState);
      expect(hooks.state(testValue)).toEqual(useState(testValue));
    });
  });
});

Mocking and testing State usage in output/behaviors

let oldState;
const setState = {
  myStateField: jest.fn(),
  otherStateField: jest.fn(),
};
const mockState = () => {
  oldState = hooks.state;
  hooks.state.myStateField = jest.fn(val => [val, setState.myStateField]);
  hooks.state.otherStateField = jest.fn(val => [val, setState.otherStateField]);
};
const restoreState = () => { hooks.state = oldState; };

describe('useMyComponentData hook', () => {
  beforeEach(() => {
    mockState();
  });
  afterAll(() => {
    restoreState();
  });
  // test initialization
  test('myStateValue is initialized with null', () => {
    hooks.useMyComponentData();
    expect(hooks.state.myStateField).toHaveBeenCalledWith(null);
  });
  describe('output', () => {
    // test return based off of state value
    test('returns disabled=true iff myStateField is > 2', () => {
      hooks.state.myStateField.mockReturnValue([2, setState.myStateField]);
      out = hooks.useMyComponentData();
      expect(out.disabled).toEqual(false);
      hooks.state.myStateField.mockReturnValue([3, setState.myStateField]);
      out = hooks.useMyComponentData();
      expect(out.disabled).toEqual(true);
    });
    // test calls to setState
    test('handleSubmit sets myStateField to otherStateField current value', () => {
      hooks.state.otherStateField.mockReturnValue([testValue, setState.otherStateField]);
      out = hooks.useMyComponentData();
      expect(setState.myStateField).not.toHaveBeenCalled();
      out.handleSubmit();
      expect(setState.myStateField).toHaveBeenCalledWith(testValue);
    });
  })
});

Testing events

const useMyEffectHook = ({ update, value }) => {
  React.useEffect(() => {
    update(value); 
  }, [update, value])
};
describe('useMyEffectHook', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  })
  it('calls passed update method when passed value or method change', () => {
    const update = jest.fn();
    hooks.useMyEffectHook({ update, value: testValue });
    expect(React.useEffect.mock.calls.length).toEqual(1);
    const [[cb, prereqs]] = React.useEffect.mock.calls;
    expect(prereqs).toEqual([update, testValue]);
    expect(update).not.toHaveBeenCalled();
    cb();
    expect(update).toHaveBeenCalledWith(testValue);
  });
});

Testing memos and callbacks

const useMyMemoHook = ({ update, value }) => {
  const memoValue = React.useMemo(() => value + 1, [value]);
  const callback = React.useCallback(() => update(value), [update, value]);
  return {
    callback,
    memoValue
  }
};
describe('useMyMemoHook', () => {
  describe('output', () => {
    const update = jest.fn(v => v * 2);
    const value = 'test-value';
    beforeEach(() => {
      out = hooks.useMyMemoHook({ update, value })
    });
    test('callback is a memoized call to update(value)', () => {
      const { cb, prereqs } = out.callback;
      expect(prereqs).toEqual([update, value]);
      cb();
      expect(update).toHaveBeenCalledWith(value);
    });
    test('memoValue returns value + 1, memoized based on value', () => {
      const { cb, prereqs } = out.memoValue;
      expect(prereqs).toEqual([value]);
      expect(cb()).toEqual(value + 1);
    });
  });
});

Component File testing

  1. Mock all incoming hooks and components

    1. jest.mock('./hooks', () => ({
        useMyHookValues: jest.fn(),
      }));
      jest.mock('data/redux/hooks', () => ({
        useMyReduxData: jest.fn(),
      }));
      hooks.useMyHookValues.mockReturnValue(...);
      hooks.useMyHookValues.mockReturnValueOnce(...);
      reduxHooks.useMyReduxData.mockReturnValue(...);
      reduxHooks.useMyReduxData.mockReturnValueOnce(...);
  2. Ensure all logic (aside from conditional rendering) is separated into hooks.

  3. Determine representational states that your component can be in

    1. What are the options for the conditional statements in the render?

  4. Derive a snapshot per state, and inspect each snapshot for relevant callbacks/pieces of data where relevant.

    1. describe('<component state>', () => {
        beforeEach(() => {
          // setup component state
          ...
          el = shallow(<MyComponent {...props} />)
        })
        test('snapshot', () => {
          expect(el).toMatchSnapshot();
        })
        test('<inspection test>', () => {
          expect(el.find(Button).text()).toEqual(testValue);
        })
      });
    2. Ensure all mocked methods have names so they show up appropriately in snapshots

      1. jest.mock().mockName('hooks.useMyHookValues')

  5. If you can separate your messages into the hook file, it becomes extra-easy to inspect the snapshots in the tests for intended messages.

    1. Because we no longer need to rely on mocks of formatted-message (which can get clunky) at that point, we can instead just mock the translatedMessage value from the hook and verify directly against it in the output.

Integration Test Patterns

Helpful Mock Patterns

Internationalization setupTest.js

This mocks formatMessage to return a similar string for snapshot purposes. If any of the passed values are objects, produces a jsx output instead.

// testUtils
export const formatMessage = (msg, values) => {
  let message = msg.defaultMessage;
  if (values === undefined) {
    return message;
  }
  // check if value is not a primitive type.
  if (Object.values(values).filter(value => Object(value) === value).length) {
    // eslint-disable-next-line react/jsx-filename-extension
    return <format-message-function {...{ message: msg, values }} />;
  }
  Object.keys(values).forEach((key) => {
    // eslint-disable-next-line
    message = message.replaceAll(`{${key}}`, values[key]);
  });
  return message;
};
// setupTest
jest.mock('@edx/frontend-platform/i18n', () => {
  const i18n = jest.requireActual('@edx/frontend-platform/i18n');
  const PropTypes = jest.requireActual('prop-types');
  const { formatMessage } = jest.requireActual('./testUtils');
  const formatDate = jest.fn(date => new Date(date).toLocaleDateString()).mockName('useIntl.formatDate');
  return {
    ...i18n,
    intlShape: PropTypes.shape({
      formatMessage: PropTypes.func,
    }),
    useIntl: () => ({
      formatMessage,
      formatDate,
    }),
    IntlProvider: () => 'IntlProvider',
    defineMessages: m => m,
    FormattedMessage: () => 'FormattedMessage',
  };
});

React and react-redux Hooks setupTest.js

// unmock for integration tests
jest.mock('react', () => ({
  ...jest.requireActual('react'),
  useRef: jest.fn((val) => ({ current: val, useRef: true })),
  useCallback: jest.fn((cb, prereqs) => ({ useCallback: { cb, prereqs } })),
  useEffect: jest.fn((cb, prereqs) => ({ useEffect: { cb, prereqs } })),
  useMemo: jest.fn((cb, prereqs) => ({ useMemo: { cb, prereqs } })),
  useContext: jest.fn(context => context),
}));

// unmock for integration tests
jest.mock('react-redux', () => {
  const dispatch = jest.fn((...args) => ({ dispatch: args })).mockName('react-redux.dispatch');
  return {
    useDispatch: jest.fn(() => dispatch),
    useSelector: jest.fn((selector) => ({ useSelector: selector })),
  };
});

// unmock for integration tests
jest.mock('reselect', () => ({
  createSelector: jest.fn((preSelectors, cb) => ({ preSelectors, cb })),
}));

Moment (for easier dates) setupTest.js

jest.mock('moment', () => ({
  __esModule: true,
  default: (date) => ({
    toDate: jest.fn().mockReturnValue(date),
  }),
}));

Mocking localStorage in setupTest.js

https://www.npmjs.com/package/jest-localstorage-mock

Usage:
// setupTest.js
import 'jest-localstorage-mock';

// *.test.jsx
describe('example test suite', () => {
  beforeEach(() => {
    localStorage.clear();
    jest.clearAllMocks();
  });

  it('example test', () => {
    global.localStorage.setItem('hasSeenProductTour', true)
  });
})

Mocking Components (and nested components) setupTest.js

jest.mock('@edx/paragon', () => ({
  Button => 'Button',
}))

for Nested components (Form, Form.Checkbox are both components) testUtils.js and setupTest.js

// testUtils.js
/**
 * Mock a single component, or a nested component so that its children render nicely
 * in snapshots.
 * @param {string} name - parent component name
 * @param {obj} contents - object of child components with intended component
 *   render name.
 * @return {func} - mock component with nested children.
 *
 * usage:
 *   mockNestedComponent('Card', { Body: 'Card.Body', Form: { Control: { Feedback: 'Form.Control.Feedback' }}... });
 *   mockNestedComponent('IconButton', 'IconButton');
 */
export const mockNestedComponent = (name, contents) => {
  if (typeof contents === 'function') {
    return contents();
  }
  if (typeof contents !== 'object') {
    return contents;
  }
  const fn = () => name;
  Object.defineProperty(fn, 'name', { value: name });
  Object.keys(contents).forEach((nestedName) => {
    const value = contents[nestedName];
    fn[nestedName] = typeof value !== 'object'
      ? value
      : mockNestedComponent(`${name}.${nestedName}`, value);
  });
  return fn;
};

/**
 * Mock a module of components.  nested components will be rendered nicely in snapshots.
 * @param {obj} mapping - component module mock config.
 * @return {obj} - module of flat and nested components that will render nicely in snapshots.
 * usage:
 *   mockNestedComponents({
 *     Card: { Body: 'Card.Body' },
 *     IconButton: 'IconButton',
 *   })
 */
export const mockNestedComponents = (mapping) => Object.entries(mapping).reduce(
  (obj, [name, value]) => ({
    ...obj,
    [name]: mockNestedComponent(name, value),
  }),
  {},
);

// setupTest.js
jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({{
  Alert: {
    Heading: 'Alert.Heading',
  },
  Card: {
    Body: 'Card.Body',
    ImageCap: 'Card.ImageCap'
  }
}})

My preferred general-use state-mocking tool (testUtils.js)

/**
 * 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: { <key>: (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('state hooks', () => {
 *     state.testGetter(state.keys.isOpen);
 *     state.testGetter(state.keys.hasDoors);
 *     state.testGetter(state.keys.selected);
 *   });
 *   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.stateVals = {};

    this.mock = this.mock.bind(this);
    this.restore = this.restore.bind(this);
    this.mockVal = this.mockVal.bind(this);
    this.testGetter = this.testGetter.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 => {
        this.stateVals[key] = val;
        return [val, this.setState[key]];
      });
    });
    this.setState = Object.keys(this.keys).reduce(
      (obj, key) => ({
        ...obj,
        [key]: jest.fn(val => {
          this.hooks.state[key] = val;
        }),
      }),
      {},
    );
  }

  expectInitializedWith(key, value) {
    expect(this.hooks.state[key]).toHaveBeenCalledWith(value);
  }

  expectSetStateCalledWith(key, value) {
    expect(this.setState[key]).toHaveBeenCalledWith(value);
  }

  /**
   * 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]]);
  }

  testGetter(key) {
    test(`${key} state getter should return useState passthrough`, () => {
      const testValue = 'some value';
      const useState = (val) => ({ useState: val });
      jest.spyOn(react, 'useState').mockImplementationOnce(useState);
      expect(this.hooks.state[key](testValue)).toEqual(useState(testValue));
    });
  }

  get values() {
    return StrictDict({ ...this.hooks.state });
  }
}