Skip to end of metadata
Go to start of metadata

You are viewing an old version of this page. View the current version.

Compare with Current View Page History

Version 1 Current »

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

  • self-import to allow mocking state (and extra hook methods if you have multiple) when testing hook behaviors

    • // allows mocking state modules from 
      import * as module from './hooks';
  • Separate state accessors so that in tests you can individually control/verify the state of different values

    • const state = {
        // Separating state values into separately mock-able methods that forward 
        // useState method allows much more effective mocking for testing.
        // The eslint-disable-line call is just to prevent needing to name
        // all of these `useMyStateField`.
        myStateField: (val) => React.useState(val), // eslint-disable-line
        myOtherStateField: (val) => React.useState(val), //eslint-disable-line
      }
      
      const useMyComponentData = (args) => {
        const [myStateField, setMyStateField] = module.state.myStateField(null);
        ...
      }
  • return callbacks for all behaviors in the component

    • only use useCallback if you have specific optimization needs

    • const useMyComponentData = (args) => {
        const [myStateField, setMyStateField] = module.state.myStateField(null);
        const clearMyStateField = () => setMyStateField(null);
        const setMyStateFieldCallback = (val) => () => setMyStateField(val);
        const performanceOptimizedCallback = useCallback(() => {
          doPerformanceIntensiveBehavior(myStateField);
        }, [myStateField]);
        
        return {
          // <Button onClick={clearMyStateField}>Clear</Button>
          clearMyStateField,
          // <Button onClick={setMyStateFieldCallback('orange')}>Orange</Button>
          setMyStateFieldCallback,
          // <Button onClick={performanceOptimizedCallback()}>Do The Thing!</Button>
          performanceOptimizedCallback,
        };
      }
  • useEffect calls for behaviors on initialization or on state change

    • const useMyComponentData = () => {
        const [myStateField, setMyStateField] = module.state.myStateField(null);
        useEffect(() => {
          doThingOnInitialization();
        }, []);
        useEffect(() => {
          doThingWhenStateFieldChanges(myStateField);
        }, [myStateField]);
      }

index.jsx component file

  • Derive values directly from external hook components. This limits the number of sources that need to be mocked to test the component in isolation.

    • const MyComponent = (args) => {
        // any state or callback fields directly associated with the component can
        // and should be wrapped in its hooks
        const { myDataField } = hooks.useMyComponentData();
        // Hooks defined at the redux level can simplify access of that data without
        // needing to be separated from the component.  
        const { myReduxField } = reduxHooks.useMyReduxData();
        return (...);
      }
  • Derive all callbacks from hooks. This means that they can be mocked in tests, with names so that they show up appropriately in snapshots.

    • const MyComponent = (args) => {
        // any state or callback fields directly associated with the component can
        // and should be wrapped in its hooks
        const { handleCancel, handleSubmit, handleInputBlur } = useMyComponentData();
        // Hooks defined at the redux level can simplify access to actions without
        // needing to be separated from the component.  
        const { setMyReduxField, clearMyReduxField } = useMyReduxActions();
        return (...);
      }
  • Internationalize all strings so that you can easily verify against them in render tests.

    • import { useIntl } from '@edx/frontend-platform/i18n';
      const MyComponent = (args) => {
        const { formatMessage } = useIntl();
        return (
          <div>
            <h1>{formatMessage(messages.heading)}</h1>
          </div>
        );
      }
    • (even easier if you are passing them already translated from the component’s hook file)

      • const MyComponent = (args) => {
          const { titleMessage } = hooks.useMyComponentData();
          return (
            <div><h1>{titleMessage}</h1></div>
          );
        }
  • Return simple JSX with minimal conditional renders. This reduces render tests to a set of conditional states that are validated with snapshots and targeted checks.

    • Good

      • {args.showError && (...)}
        {args.isInitialized
          ? (
            ...
          ) : (
            ...
          )
        }
      • Render values and callbacks from hooks or args

        • <Button isDisabled={args.isDisabled} onClick={hooks.handleSubmit}>
            ...
          </Button>
    • Bad

      • Complex value derivation (should be separated into hook code)

        • <div>
            {myField ? doThingWithMyValue(aValue) : otherValue + (thirdValue * 3)}
          </div>
      • In-render callbacks (hard to test and snapshot. should be separated into hook code).

        • <Button onClick={() => doThing(myValue)}>
            ...
          </Button>
  • Perform maps where reasonable, but put overly complex maps into their own components.

    • If a map is over 5-10 lines of code, it could probably be broken into its own component.

    • Repeated patterns that should be broken into a map (make testing harder than it needs to be)

      • <Button onClick={setMyStateFieldCallback(1)>
          {formatMessage(message.loadOption, { value: 1 })}
        </Button>
        <Button onClick={setMyStateFieldCallback(2)>
          {formatMessage(message.loadOption, { value: 2 })}
        </Button>
        <Button onClick={setMyStateFieldCallback(3)>
          {formatMessage(message.loadOption, { value: 3 })}
        </Button>
        
        {[1, 2, 3].map(value => (
          <Button onClick={setMyStateFieldCallback(value)} key={`button-${value}`}>
            {formatMessage(messages.loadOption, { value })}
          </Button>
        ))}
    • When mapping from static fields, separate for validation

      • export const options = {
          orange: 'red',
          red: 'green',
          purple: 'blue',
        };
        const MyComponent = (args) => {
          ...
          return (
            <div>
              {[options.red, options.green, options.blue].map((color) => (
                <Button onClick={setMyStateFieldCallback(color) key={color}>
                  {formatMessage(messages.color)}
                </Button>
              ))}
            </div>
          );
        };

Note: redux hooks

  • redux hooks can be defined at the app-level to further separate component behavior from data logic.

    • // can provide direct access to selectors
      const useMyModelData = () => useSelector(selectors.myReduxSelector)
      // can provide direct access to actions
      const useMyReduxActionMethod = (args) => {
        const dispatch = useDispatch();
        dispatch(actions.myReduxAction(args));
      }
      // can replace thunk actions in many cases
      const useMyComplexReduxAction = (args) => {
        const dispatch = useDispatch();
        dispatch(actions.myImmediateReduxAction());
        fetch(myUrl).then(data => {
          dispatch(actions.myFetchSuccessReduxAction(args));
        });
      }

Testing Strategies

General

  • Mock Everything

  • Leverage setupTest for repeated mocks (react hooks, paragon, etc)

  • Be intentional about your test structure

    • use describe blocks to break up feature sets and conditions. Much easier to have a describe block for “on” state and one for “off” than to include that in all nested tests. Even easier when there are more than 3 states to check against.

    • use test for statements and it for behavioral assertions

      • it('calls initialize on load', () => {});

      • test('snapshot', () => {});

      • test('output includes most recent date', () => {});

    • describe(<moduleName>, () => {
        describe(<featureSet>, () => {
          describe(<specificFeature>, () => {
            describe(<state1>, () => {
              it('does thing', () => {
              });
              test('statement', () => {
              });
            });
          });
        });
      });

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

  • Mock useEffect and just check against calls to it.

  • Be sure to clear all mocks between tests or these calls will just add up

  • check prerequisites and then run the callback and verify behavior

  • Sometimes it is helpful to add the test to verify the behavior doesn’t run without calling the callback

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

  • Mock useMemo and useCallback to return their passed callback and prerequisites for easy testing.

    • (cb, prereqs) => ({ cb, prereqs });
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

  • Integration tests work well at the top level of the app to test a number of work paths in sequence with a minimal amount of mocking.

    • Make sure you un-mock everything listed in your setupTest file

    • generally you mainly want to mock api methods.

    • A simple pattern for mocking api events so that you can control when/if they return/fail

      • import api from 'api';
        jest.mock('api', () => ({
          myApiMethod: jest.fn(),
        }));
        const resolveFns = {};
        const rejectFns = {};
        api.myApiMethod.mockReturnValue(new Promise((resolve, reject) => {
          resolveFns.myApiMethod = resolve;
          rejectFns.myApiMethod = reject;
        }));
        
        const promise = api.myApiMethod();
        // resolveFns.myApiMethod(response) will force the method to succed
        // rejectFns.myApiMethod(error) will force the method to fail
  • Generally for integration tests, react-testing-library’s renderer is more interrogate-able than enzyme’s and thus is preferred.

  • Where possible, find as few paths to flow through as you can, and verify as much as possible per path.

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 });
  }
}
  • No labels