Как упростить тестирование Редакс-редьюсеров

Если вы работаете с Редаксом, вы знаете, что редьюсеры меняют состояние приложения в соответствии с произведёнными действиями. Если после какого-то действия состояние изменится неправильным образом, приложение может сломаться. Чтобы этого не допустить, нужно проверять, правильное ли состояние редьюсер генерирует в ответ на обрабатываемые им действия.

В документации Редакса есть пример тестирования редьюсеров. Суть очень проста: берём редьюсер, вызываем его с каким-либо исходным состоянием и нужным экшеном и сравниваем полученный результат с ожидаемым. Если ожидаемое и полученное состояния совпали, всё супер, если нет — вы поймали баг.

Официальный пример тестирования очень многословен, такое тестирование отнимает силы, время и любовь к жизни. Расскажу, как его упростить благодаря магии снепшотов jest.

Упрощать будем на примере простого редьюсера:

export const initialState = {
  counter: 0,
  disabled: false,
};

export default function counterReducer(state = initialState, action = {}) {
  switch (action.type) {
    case 'INCREMENT': {
      if (state.disabled) {
        return state;
      }

      return {
        ...state,
        counter: state.counter + 1,
      };
    }

    case 'DISABLE': {
      return {
        ...state,
        disabled: true,
      };
    }

    case 'ENABLE': {
      return {
        ...state,
        disabled: false,
      };
    }

    default: {
      return state;
    }
  }
}

Если следовать официальной документации, тесты будут выглядеть так:

import reducer, { initialState } from './';

describe('counter reducer', () => {
  it('should return the initial state', () => {
    expect(reducer(initialState)).toEqual(initialState);
  });

  it('should increment if not disabled', () => {
    const modifiedState = {
      ...initialState,
      counter: 0,
      disabled: false,
    };
    const action = {
      type: 'INCREMENT'
    };
    const expectedState = {
      ...modifiedState,
      counter: 1,
    };

    expect(reducer(modifiedState, action)).toEqual(expectedState);
  });

  it('should not increment if disabled', () => {
    const modifiedState = {
      ...initialState,
      disabled: true,
    };
    const action = {
      type: 'INCREMENT'
    };

    expect(reducer(modifiedState, action)).toEqual(modifiedState);
  });

  it('should disable if enabled', () => {
    const modifiedState = {
      ...initialState,
      disabled: false,
    };
    const action = {
      type: 'DISABLE'
    };
    const expectedState = {
      ...modifiedState,
      disabled: true,
    };

    expect(reducer(modifiedState, action)).toEqual(expectedState);
  });

  it('should enable if disabled', () => {
    const modifiedState = {
      ...initialState,
      disabled: true,
    };
    const action = {
      type: 'ENABLE'
    };
    const expectedState = {
      ...modifiedState,
      disabled: false,
    };

    expect(reducer(modifiedState, action)).toEqual(expectedState);
  });
});

Получилось многословно и императивно. В каждом тесткейсе для нас имеют значение только его название, исходное состояние, действие и ожидаемое состояние. Всё остальное — шум. Чтобы избавиться от него, напишем небольшую функцию-обёртку, скрывающую в себе весь бойлерплейт:

function createStateTransitionTester({ reducer, initialState }) { // сразу запоминаем редьюсер и его исходное состояние, чтобы не повторять их в каждом тесткейсе
  return (testCaseName, params) => {
    const {
      modifiedState = initialState, // по умолчанию считаем, что переход совершается из начального состояния
      action,
    } = params;
    /**
     * Ожидаемое состояние может зависеть от исходного состояния,
     * поэтому даём возможность генерации ожидаемого состояния на основе исходного
    */
    const expectedState = typeof params.expectedState === 'function' ? params.expectedState(modifiedState) : params.expectedState;

    test(testCaseName, () => {
      expect(reducer(modifiedState, action)).toEqual(expectedState);
    });
  };
}

С ней тесты становятся более декларативными и лаконичными:

import { createStateTransitionTester } from '~/utils';
import reducer, { initialState } from './';

describe('counter reducer', () => {
  const testStateTransition = createStateTransitionTester({ reducer, initialState });

  testStateTransition('should return the initial state', {
    expectedState: initialState,
  });

  testStateTransition('should increment if not disabled', {
    action: {
      type: 'INCREMENT',
    },
    modifiedState: {
      ...initialState,
      counter: 0,
      disabled: false,
    },
    expectedState: (modifiedState) => ({
      ...modifiedState,
      counter: 1,
    }),
  });

  testStateTransition('should not increment if disabled', {
    action: {
      type: 'INCREMENT',
    },
    modifiedState: {
      ...initialState,
      disabled: true,
    },
    expectedState: (modifiedState) => modifiedState,
  });

  testStateTransition('should disable if enabled', {
    action: {
      type: 'DISABLE',
    },
    modifiedState: {
      ...initialState,
      disabled: false,
    },
    expectedState: (modifiedState) => ({
      ...modifiedState,
      disabled: true,
    }),
  });

  testStateTransition('should enable if disabled', {
    action: {
      type: 'ENABLE',
    },
    modifiedState: {
      ...initialState,
      disabled: true,
    },
    expectedState: (modifiedState) => ({
      ...modifiedState,
      disabled: false,
    }),
  });
});

Стало лучше, но описывать каждый раз ожидаемое состояние довольно утомительно, особенно если в нём много полей. Я долгое время смиренно жил с этим решением, пока мой коллега Денис не предложил использовать jest-снепшоты для автоматической генерации снимка ожидаемого состояния. При таком подходе вручную нужно описывать только экшен и исходное состояние. При первом запуске ожидаемое состояние для всех тесткейсов запишется в снепшоты, и результаты дальнейших запусков тестов будут сравниваться с сохранёнными снепшотами. Здесь сделано допущение, что при первом запуске редьюсер работает правильно и без багов, иначе первые снимки ожидаемого состояния получатся некорректными.

Для использования такого подхода нужно самую малость доработать функцию createStateTransitionTester:

function createStateTransitionTester({ reducer, initialState }) {
  return (testCaseName, { modifiedState = initialState, action, /* expectedState больше не нужен */ }) => {
    test(testCaseName, () => {
      expect(reducer(modifiedState, action)).toMatchSnapshot(); // .toEqual(expectedState) → .toMatchSnapshot()
    })
  };
}

Благодаря этому тесты стали ещё лаконичнее, а писать их теперь не так больно:

import { createStateTransitionTester } from '~/utils';
import reducer, { initialState } from './';

describe('counter reducer', () => {
  const testStateTransition = createStateTransitionTester({ reducer, initialState });

  testStateTransition('should return the initial state');

  testStateTransition('should increment if not disabled', {
    action: {
      type: 'INCREMENT',
    },
    modifiedState: {
      ...initialState,
      counter: 0,
      disabled: false,
    },
  });

  testStateTransition('should not increment if disabled', {
    action: {
      type: 'INCREMENT',
    },
    modifiedState: {
      ...initialState,
      disabled: true,
    },
  });

  testStateTransition('should disable if enabled', {
    action: {
      type: 'DISABLE',
    },
    modifiedState: {
      ...initialState,
      disabled: false,
    },
  });

  testStateTransition('should enable if disabled', {
    action: {
      type: 'ENABLE',
    },
    modifiedState: {
      ...initialState,
      disabled: true,
    },
  });
});