Лучший подход к тестированию вашего кода Redux

Приведенная ниже статья была ранее опубликована в моем блоге:



В конечном итоге я перенесу весь свой контент сюда, на Medium, так что если вам нравится то, что вы здесь читаете, просто следуйте за мной и туда. Я, вероятно, буду публиковать сообщения более регулярно.

TL;DR

При тестировании Redux, вот несколько рекомендаций:

Ванильный Редукс

  • Самая маленькая автономная единица в Redux - это весь срез состояния. Модульные тесты должны взаимодействовать с ним как единое целое.
  • Нет смысла тестировать редукторы, создатели действий и селекторы изолированно. Поскольку они тесно связаны друг с другом, изоляция практически не имеет для нас значения.
  • Тесты должны взаимодействовать с вашим Redux-фрагментом так же, как и ваше приложение. создателей и селекторов, без необходимости писать тесты, нацеленные на них по отдельности.
  • Избегайте утверждений типа _1 _ / _ 2_ против объекта состояния, поскольку они создают связь между вашими тестами и структурой состояния.
  • Использование селекторов дает вам степень детализации, необходимую для выполнения простых утверждений.
  • Селекторы и создатели действий должны быть утомительными, чтобы не требовать тестирования.
  • Ваш фрагмент в некоторой степени эквивалентен чистой функции, а это означает, что вам не нужны имитирующие средства для его тестирования.

Redux + redux-thunk

  • Отправка thunks не имеет прямого эффекта. Только после вызова преобразователя у нас появятся побочные эффекты, необходимые для работы нашего приложения.
  • Здесь можно использовать заглушки, шпионы, а иногда и моки (но не злоупотребляйте моками).
  • Из-за того, как преобразователи структурированы, единственный способ их протестировать - это проверить детали их реализации.
  • Стратегия при тестировании преобразователей состоит в том, чтобы настроить магазин, отправить преобразователь и затем подтвердить, отправил ли он ожидаемые вами действия в ожидаемом вами порядке или нет.

Я создал репо, воплощая в жизнь изложенные выше идеи.

вступление

Как инженер-программист, я всегда нахожу способы улучшить свое мастерство. Это не просто. Нисколько. Кодировать достаточно сложно. Написать хороший код еще сложнее.

Потом есть тесты. Я думаю, что каждый раз, когда я начинаю новый проект - профессионально или просто для развлечения, - мои идеи о том, как мне проверить изменение кода. Каждый. Одинокий. Время. Это не обязательно плохо, так как разные проблемы требуют разных решений, но это все же меня немного заинтриговывает.

Проблема с тестами

Как ~ большую часть времени ~ практикующий TDD, я понял, что основная причина, по которой мы пишем тесты, заключается в том, чтобы не утверждать правильность нашего кода - это просто классный побочный эффект. Самый большой выигрыш при написании тестов в первую очередь заключается в том, что они помогут вам разработать код, который вы напишете следующим. Если что-то сложно проверить, есть вероятно лучший способ реализовать это.

Однако по прошествии некоторого времени вы поймете, что писать хорошие тесты так же сложно, как писать производственный код. Иногда даже тяжелее. Написание тестов требует времени. А дополнительное время - это то, что ваши клиенты или деловые люди в вашей компании не дадут вам так легко.

И становится еще хуже. Даже если вы умеете писать правильные тесты, на протяжении всего жизненного цикла продукта / проекта, над которым вы работаете, требования будут меняться, будут появляться новые сценарии. Напишите слишком много тестов, сделайте их очень запутанными, и любое незначительное изменение в вашем приложении потребует больших усилий, чтобы все тесты прошли снова. Еще одна проблема - нестабильные тесты. Когда он выходит из строя, вы даже не догадывались, что нужно было начать его исправлять. Вы, вероятно, просто повторно запустите набор тестов, и если он пройдет, все готово.

Но как узнать, пишете ли вы хорошие тесты? Что, черт возьми, вообще такое хорошее испытание?

Школы тестирования

Существует долгая дискуссия между двумя разными течениями мысли, известными как Лондонская школа и Детройтская школа тестирования.

Обобщая их различия, в то время как Детройт защищает, что программное обеспечение должно создаваться снизу вверх, с упором на шаблоны проектирования и тесты должны иметь как можно меньше знаний о реализации и практически не содержать заглушек / насмешек вообще, Лондон выступает за то, что дизайн должен быть нисходящим, с использованием внешних ограничений в качестве отправной точки, обеспечивая максимальную изоляцию между тестовыми наборами за счет широкого использования заглушек / имитаций, что имеет побочный эффект необходимость знать, как реализуется тестируемый объект.

Это очень краткое изложение - даже если вы рискуете ошибиться из-за краткости - но вы можете найти больше хороших отзывов об этой головоломке двух десятилетий давности здесь, здесь и здесь.

Тестирование в реальном мире

Так кто из них прав, лондонцы или детротийцы? Они оба и ни один одновременно. Как я узнал за почти пять лет, что я был профессиональным инженером-программистом, догматизм не уведет вас очень далеко в реальном мире, где проекты должны выполняться, ожидания продукта должны соответствовать ожиданиям и у вас есть счета, которые нужно оплачивать.

Что вам действительно нужно, так это уметь взять лучшее из обоих миров и использовать его в свою пользу. Использовать его мудро.

Мы живем в мире, где все, кажется, одержимы ~ почти ~ идеальным покрытием кода, в то время как проблема избыточного покрытия упоминается редко - нелегко найти в Интернете ссылки, обсуждающие это. Если вы злоупотребляете тестами, у вас могут возникнуть проблемы, когда ваши требования внезапно изменятся.

В конце концов, нам платят не за написание тестов, а за решение чужих проблем с помощью кода. Написание тестов стоит дорого и не добавляет ощутимой ценности для клиентов / пользователей. Можно утверждать, что тесты приносят добавленную стоимость, но по моему личному опыту, очень трудно заставить людей, не обладающих техническими знаниями, это купить.

Мы, инженеры-программисты, должны стремиться к написанию минимального количества тестов, обеспечивающих достаточную уверенность в качестве и правильности кода, а достаточно сильно зависит от контекста.

Тестирование Redux согласно документации

Redux известен отличной документацией. На самом деле это правда. Это не только документация по API и несколько быстрых примеров, но и несколько ценных советов по передовому опыту и даже ссылки на более подробные обсуждения Redux и его экосистемы.

Однако я считаю, что раздел Написание тестов оставляет желать лучшего.

Тестирование создателей действий

Этот раздел в документации начинается с создателей действий.

export function addTodo(text) {
  return {
    type: 'ADD_TODO',
    text
  }
}

Затем мы можем протестировать это так:

import * as actions from '../../actions/TodoActions'
import * as types from '../../constants/ActionTypes'
describe('actions', () => {
  it('should create an action to add a todo', () => {
    const text = 'Finish docs'
    const expectedAction = {
      type: types.ADD_TODO,
      text
    }
    expect(actions.addTodo(text)).toEqual(expectedAction)
  })
})

Хотя тест правильный и проходит отлично, основная проблема здесь в том, что он не приносит большой пользы. Создатели ваших обычных действий должны быть очень скучными, почти декларативным кодом. Для этого не нужны тесты.

Более того, если вы используете вспомогательные библиотеки, такие как redux-act или собственный @reduxjs/toolkit Redux - что вам следует - тогда нет абсолютно никаких причин писать для них тесты, так как ваши тесты будут тестировать вспомогательные библиотеки. сами, которые уже протестированы и, что более важно, даже не принадлежат вам.

А поскольку создатели действий могут быть очень плодовитыми в реальном приложении, количество тестов, которые им потребуются, огромно.

Но как мы можем убедиться, что наши старые добрые создатели экшенов не содержат таких глупых ошибок, как опечатки?

Потерпите меня. Подробнее об этом позже.

Тестирование редукторов

В Redux редукторы - это функция, которая, учитывая состояние и действие, должна создавать совершенно новое состояние, не изменяя исходное. Редукторы - это чистые функции. Чистые функции - это рай для тестировщиков. Это должно быть довольно просто, правда?

Документы дают нам следующий пример:

import { ADD_TODO } from '../constants/ActionTypes'
const initialState = [
  {
    text: 'Use Redux',
    completed: false,
    id: 0
  }
]
export default function todos(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        {
          id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
          completed: false,
          text: action.text
        },
        ...state
      ]
    default:
      return state
  }
}

Затем тест:

describe('todos reducer', () => {
  it('should return the initial state', () => {
    expect(reducer(undefined, {})).toEqual([
      {
        text: 'Use Redux',
        completed: false,
        id: 0
      }
    ])
  })
  it('should handle ADD_TODO', () => {
    expect(
      reducer([], {
        type: types.ADD_TODO,
        text: 'Run the tests'
      })
    ).toEqual([
      {
        text: 'Run the tests',
        completed: false,
        id: 0
      }
    ])
    expect(
      reducer(
        [
          {
            text: 'Use Redux',
            completed: false,
            id: 0
          }
        ],
        {
          type: types.ADD_TODO,
          text: 'Run the tests'
        }
      )
    ).toEqual([
      {
        text: 'Run the tests',
        completed: false,
        id: 1
      },
      {
        text: 'Use Redux',
        completed: false,
        id: 0
      }
    ])
  })
})

Давайте просто проигнорируем тот факт, что предлагаемый тестовый пример «должен обрабатывать ADD_TODO» на самом деле представляет собой два теста, объединенных вместе, что может напугать некоторых фанатов тестирования. Несмотря на то, что в этом случае я считаю, что было бы лучше иметь разные тестовые примеры - один для пустого списка, а другой для списка с некоторыми начальными значениями, иногда это просто нормально.

Настоящая проблема с этими тестами заключается в том, что они тесно связаны с внутренней структурой редуктора. Точнее, приведенные выше тесты связаны со структурой объекта состояния через эти .toEqual() утверждения.

Хотя этот пример довольно прост, состояние данного слайса в Redux очень часто меняется с течением времени по мере поступления новых требований и возникновения некоторых непредвиденных взаимодействий. Если мы напишем тесты, подобные приведенным выше, они скоро превратятся в кошмар обслуживания. Любое минимальное изменение структуры состояний потребует обновления нескольких тестовых примеров.

Так как именно мы должны писать эти тесты?

Правильное тестирование Redux

Отказ от ответственности: я не говорю, что это лучший или единственный способ тестирования вашего приложения Redux, однако недавно я пришел к выводу, что выполнение этого способа, который я предлагаю ниже, дает наилучшую экономическую выгоду, которую я знать о. Если вы знаете способ получше, свяжитесь со мной через комментарии, Twitter, электронную почту или дымовые знаки.

Вот популярная структура папок для приложений Redux, которая очень похожа на те, которые можно найти во многих руководствах и даже в официальных документах:

src
└── store
    ├── auth
    │   ├── actions.js
    │   ├── actionTypes.js
    │   └── reducer.js
    └── documents
        ├── actions.js
        ├── actionTypes.js
        └── reducer.js

Если вы похожи на меня и хотите, чтобы тестовые файлы размещались вместе с исходным кодом, эта структура поощряет вас иметь следующее:

src
└── store
    ├── auth
    │   ├── actions.js
    │   ├── actions.test.js
    │   ├── actionTypes.js
    │   ├── reducer.js
    │   └── reducer.test.js
    └── documents
        ├── actions.js
        ├── actions.test.js
        ├── actionTypes.js
        ├── reducer.js
        └── reducer.test.js

Я уже исключил actionTypes тестов, поскольку эти файлы являются чисто декларативными. Однако я уже объяснил, почему создатели действий должны быть чисто декларативными и, следовательно, также не должны тестироваться. Остается протестировать единственный редуктор, но это не совсем так.

Проблема здесь в том, что мы понимаем под «единицей» в Redux. Большинство людей склонны рассматривать каждый из указанных выше файлов как единое целое. Я считаю, что это заблуждение. Действия, типы действий и редукторы должны быть тесно связаны друг с другом для правильной работы. Для меня нет смысла тестировать эти «компоненты» изолированно. Все они должны собраться вместе, чтобы сформировать срез (например: auth и documents выше), который я считаю наименьшим автономным элементом в архитектуре Redux.

По этой причине меня считают узором Утки, хотя с ним есть некоторые оговорки. Авторы Ducks выступают за то, чтобы все, что касается одного фрагмента (который они называют уткой), должно быть помещено в один файл и следовать четко определенной структуре экспорта.

У меня обычно есть структура, которая выглядит примерно так:

src
└── modules
    ├── auth
    │   ├── authSlice.js
    │   └── authSlice.test.js
    └── documents
        ├── documentsSlice.js
        └── documentsSlice.test.js

Теперь идея состоит в том, чтобы написать как можно меньше тестов, имея при этом хорошую степень уверенности в том, что конкретный фрагмент работает, как ожидалось. Причина, по которой Redux существует в первую очередь, заключается в том, чтобы помочь нам управлять состоянием, предоставляя единое место для состояния нашего приложения.

Другими словами, ценность, которую предоставляет нам Redux, - это возможность записывать и читать состояние из централизованного места, называемого хранилищем. Поскольку Redux основан на Flux Architecture, его обычный поток примерно такой:

Стратегия тестирования Redux

В конце концов, мы хотим проверить, правильно ли мы пишем - через отправку действий - и читаем из магазина. Мы делаем это, задавая начальное состояние, мы отправляем какое-то действие в хранилище, позволяем редуктору работать, а затем после этого мы проверяем состояние, чтобы увидеть, были ли внесены ожидаемые изменения.

Однако как мы можем это сделать, избегая ловушки, связанной с объединением тестов со структурой объекта состояния? Простой. Всегда используйте селекторы. Даже те, которые показались бы тупыми.

Селекторы - это общедоступный API для чтения данных. Они могут инкапсулировать внутреннюю структуру вашего состояния и предоставлять только те данные, которые необходимы вашему приложению, с необходимой степенью детализации. Вы также можете рассчитывать данные и оптимизировать их с помощью мемоизации. Точно так же создатели действий - это его общедоступный API для записи данных.

Все еще не понимаете? Давайте попробуем ввести код, используя @reduxjs/toolkit.

Вот мой фрагмент авторизации:

import { createSlice, createSelector } from '@reduxjs/toolkit';
export const initialState = {
  userName: '',
  token: '',
};
const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    signIn(state, action) {
      const { token, userName } = action.payload;
      state.token = token;
      state.userName = userName;
    },
  },
});
export const { signIn } = authSlice.actions;
export default authSlice.reducer;
export const selectToken = state => state.auth.token;
export const selectUserName = state => state.auth.userName;
export const selectIsAuthenticated = createSelector([selectToken], token => token !== '');

В этом файле нет ничего особенного. Я использую помощник createSlice, который избавляет меня от большого количества шаблонного кода. Структура экспорта более или менее соответствует шаблону Ducks, основное отличие состоит в том, что я не экспортирую явно типы действий, поскольку они определены в свойстве type создателей действий (например: signIn.type возвращает 'auth/signIn').

Теперь набор тестов реализован с использованием jest:

import reducer, { initialState, signIn, selectToken, selectName, selectIsAuthenticated } from './authSlice';
describe('auth slice', () => {
  describe('reducer, actions and selectors', () => {
    it('should return the initial state on first run', () => {
      // Arrange
      const nextState = initialState;
      // Act
      const result = reducer(undefined, {});
      // Assert
      expect(result).toEqual(nextState);
    });
    it('should properly set the state when sign in is made', () => {
      // Arrange
      const data = {
        userName: 'John Doe',
        token: 'This is a valid token. Trust me!',
      };
      // Act
      const nextState = reducer(initialState, signIn(data));
      // Assert
      const rootState = { auth: nextState };
      expect(selectIsAuthenticated(rootState)).toEqual(true);
      expect(selectUserName(rootState)).toEqual(data.userName);
      expect(selectToken(rootState)).toEqual(data.token);
    });
  });
});

Первый тестовый пример ('should return the initial state on first run') нужен только для того, чтобы убедиться в отсутствии проблем с определением файла среза. Обратите внимание, что я использую утверждение .toEqual(), которое я сказал вам не следует. Однако в этом случае, поскольку утверждение противоречит константе initialState и нет никаких мутаций, всякий раз, когда изменяется форма состояния, initialState изменяется вместе, поэтому этот тест автоматически будет «исправлен».

Второй тестовый пример - это то, что нас здесь интересует. Из начального состояния мы «отправляем» signIn действие с ожидаемой полезной нагрузкой. Затем мы проверяем, соответствует ли полученное состояние ожидаемому. Однако мы делаем это исключительно с помощью селекторов. Таким образом, наш тест более отделен от реализации.

Если ваш фрагмент становится больше, используя селекторы при тестировании переходов между состояниями, вы получаете еще одно преимущество: вы можете использовать только те селекторы, на которые влияет отправленное вами действие, и можете игнорировать все остальное. Если бы вы утверждали против полного дерева состояний среза, вам все равно нужно было бы объявить эти несвязанные свойства состояния в утверждении.

Внимательный читатель мог бы заметить, что этот стиль тестирования больше напоминает стиль Детройтской школы. Нет никаких издевательств, заглушек, шпионов и т. Д. Поскольку редукторы - это просто чистые функции, их нет смысла использовать.

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

Тестирование более реалистичного среза

Первый шаг - разделить наше signIn действие на три новых: signInStart, signInSuccess и signInFailure. Имена не требуют пояснений. После этого наше состояние должно обработать состояние загрузки и возможную ошибку.

Вот код с этими изменениями:

import { createSlice, createSelector } from '@reduxjs/toolkit';
export const initialState = {
  isLoading: false,
  user: {
    userName: '',
    token: '',
  },
  error: null,
};
const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    signInStart(state, action) {
      state.isLoading = true;
      state.error = null;
    },
    signInSuccess(state, action) {
      const { token, userName } = action.payload;
      state.user = { token, userName };
      state.isLoading = false;
      state.error = null;
    },
    signInFailure(state, action) {
      const { error } = action.payload;
      state.error = error;
      state.user = {
        userName: '',
        token: '',
      };
      state.isLoading = false;
    },
  },
});
export const { signInStart, signInSuccess, signInFailure } = authSlice.actions;
export default authSlice.reducer;
export const selectToken = state => state.auth.user.token;
export const selectUserName = state => state.auth.user.userName;
export const selectError = state => state.auth.error;
export const selectIsLoading = state => state.auth.isLoading;
export const selectIsAuthenticated = createSelector([selectToken], token => token !== '');

Первое, что вы можете заметить, это то, что наша форма состояния изменилась. Мы вложили userName и token в свойство user. Если бы мы не создали селекторы, это нарушило бы все тесты и код, который зависит от этого фрагмента. Однако, поскольку у нас есть селекторы, единственные изменения, которые нам нужно сделать, - это selectToken и selectUserName.

Обратите внимание, что наш набор тестов теперь полностью сломан, но это потому, что мы коренным образом изменили срез. Хотя исправить это несложно:

describe('auth slice', () => {
  describe('reducer, actions and selectors', () => {
    it('should return the initial state on first run', () => {
      // Arrange
      const nextState = initialState;
      // Act
      const result = reducer(undefined, {});
      // Assert
      expect(result).toEqual(nextState);
    });
    it('should properly set loading and error state when a sign in request is made', () => {
      // Arrange
      // Act
      const nextState = reducer(initialState, signInStart());
      // Assert
      const rootState = { auth: nextState };
      expect(selectIsAuthenticated(rootState)).toEqual(false);
      expect(selectIsLoading(rootState)).toEqual(true);
      expect(selectError(rootState)).toEqual(null);
    });
    it('should properly set loading, error and user information when a sign in request succeeds', () => {
      // Arrange
      const payload = { token: 'this is a token', userName: 'John Doe' };
      // Act
      const nextState = reducer(initialState, signInSuccess(payload));
      // Assert
      const rootState = { auth: nextState };
      expect(selectIsAuthenticated(rootState)).toEqual(true);
      expect(selectToken(rootState)).toEqual(payload.token);
      expect(selectUserName(rootState)).toEqual(payload.userName);
      expect(selectIsLoading(rootState)).toEqual(false);
      expect(selectError(rootState)).toEqual(null);
    });
    it('should properly set loading, error and remove user information when sign in request fails', () => {
      // Arrange
      const error = new Error('Incorrect password');
      // Act
      const nextState = reducer(initialState, signInFailure({ error: error.message }));
      // Assert
      const rootState = { auth: nextState };
      expect(selectIsAuthenticated(rootState)).toEqual(false);
      expect(selectToken(rootState)).toEqual('');
      expect(selectUserName(rootState)).toEqual('');
      expect(selectIsLoading(rootState)).toEqual(false);
      expect(selectError(rootState)).toEqual(error.message);
    });
  });
});

Обратите внимание, что signInStart имеет меньше утверждений относительно нового состояния, потому что текущие userName и token не имеют для него значения. Все остальное во многом соответствует тому, что мы обсуждали до сих пор.

Есть еще одна тонкость, которая может остаться незамеченной. Несмотря на то, что основное внимание в тестах уделяется редуктору, в конечном итоге они также тестируют создателей действий. Здесь будут обнаружены такие глупые ошибки, как опечатки, поэтому нам не нужно писать отдельный набор тестов, чтобы предотвратить их появление.

То же самое и с селекторами. Простые селекторы - это чисто декларативный код. Мемоизированные селекторы для производных данных, созданные с помощью createSelector из повторного выбора, также не должны тестироваться. Ошибки будут обнаружены в тесте редуктора.

Например, если мы забыли изменить selectUserName и selectToken после рефакторинга формы состояния и оставили их так:

// should be state.auth.user.token
export const selectToken = state => state.auth.token;
// should be state.auth.user.userName
export const selectUserName = state => state.auth.userName;

В этом случае все вышеперечисленные тестовые примеры не пройдут.

Тестирование побочных эффектов

Мы приближаемся к цели, но наш фрагмент еще не готов. В нем отсутствует часть, которая управляет потоком входа и взаимодействует с API серверной службы.

Сам Redux намеренно не обрабатывает побочные эффекты. Для этого вам понадобится промежуточное ПО Redux, которое сделает это за вас. Хотя вы можете выбрать свой собственный яд, @reduxjs/toolkit уже поставляется с redux-thunk, так что это то, что мы собираемся использовать.

В этом случае в документации Redux действительно есть действительно хороший пример, поэтому я в основном взял его и адаптировал к нашему варианту использования.

В нашем authSlice.js мы просто добавляем:

// ...
import api from '../../api';
// ...
export const signIn = ({ email, password }) => async dispatch => {
  try {
    dispatch(signInStart());
    const { token, userName } = await api.signIn({
      email,
      password,
    });
    dispatch(signInSuccess({ token, userName }));
  } catch (error) {
    dispatch(signInFailure({ error }));
  }
};

Обратите внимание, что функция signIn почти похожа на создателя действия, однако вместо того, чтобы возвращать объект действия, она возвращает функцию, которая получает функцию диспетчеризации в качестве параметра. Это «действие», которое запускается, когда пользователь нажимает кнопку «Войти» в нашем приложении.

Это означает, что такие функции, как signIn, очень важны для приложения, поэтому их следует тестировать. Однако как мы можем протестировать это отдельно от модуля api? Введите макеты и заглушки.

Поскольку это в основном компонент оркестровки, нас не интересуют его видимые эффекты. Вместо этого нас интересуют действия, которые были отправлены из преобразователя в соответствии с ответом от API.

Итак, мы можем изменить тестовый файл следующим образом:

import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
// ...
import api from '../../api';
jest.mock('../../api');
const mockStore = configureMockStore([thunk]);
describe('thunks', () => {
    it('creates both signInStart and signInSuccess when sign in succeeds', async () => {
      // Arrange
      const requestPayload = {
        email: '[email protected]',
        password: 'very secret',
      };
      const responsePayload = {
        token: 'this is a token',
        userName: 'John Doe',
      };
      const store = mockStore(initialState);
      api.signIn.mockResolvedValueOnce(responsePayload);
      // Act
      await store.dispatch(signIn(requestPayload));
      // Assert
      const expectedActions = [signInStart(), signInSuccess(responsePayload)];
      expect(store.getActions()).toEqual(expectedActions);
    });
    it('creates both signInStart and signInFailure when sign in fails', async () => {
      // Arrange
      const requestPayload = {
        email: '[email protected]',
        password: 'wrong passoword',
      };
      const responseError = new Error('Invalid credentials');
      const store = mockStore(initialState);
      api.signIn.mockRejectedValueOnce(responseError);
      // Act
      await store.dispatch(signIn(requestPayload));
      // Assert
      const expectedActions = [signInStart(), signInFailure({ error: responseError })];
      expect(store.getActions()).toEqual(expectedActions);
    });
  });

Итак, в отличие от редукторов, которые легче тестировать с помощью методологии Детройтской школы, мы используем стиль Лондонской школы для тестирования наших преобразователей, потому что это то, что имеет смысл.

Поскольку мы тестируем детали реализации, всякий раз, когда изменяется код, наши тесты должны это отражать. В реальном приложении после успешного входа вы, вероятно, захотите куда-нибудь перенаправить пользователя. Если бы мы использовали что-то вроде connected-response-router, мы бы получили такой код:

+import { push } from 'connected-react-router';
 // ...
 import api from '../../api';
 // ...
     const { token, userName } = await api.signIn({
       email,
       password,
     });
     dispatch(signInSuccess({ token, userName }));
+    dispatch(push('/'));
   } catch (error) {
     dispatch(signInFailure({ error }));
   }
 // ...

Затем мы обновляем часть утверждения нашего тестового примера:

+import { push } from 'connected-react-router';
 // ...
 // Assert
 const expectedActions = [
   signInStart(),
   signInSuccess(responsePayload),
+  push('/')
 ];
 expect(store.getActions()).toEqual(expectedActions);
 // ...

Это часто является критикой redux-thunk, но если вы даже так решили использовать его, вам придется иметь дело с этим компромиссом.

Заключение

Что касается реального мира, не существует единственного наилучшего подхода к написанию тестов. Мы можем и должны использовать стили Детройта и Лондона для эффективного тестирования ваших приложений.

Для компонентов, которые ведут себя как чистые функции, т. Е. При заданном вводе, производят некоторый детерминированный вывод, блестит Детройтский стиль. Наши тесты могут быть немного более грубыми, поскольку идеальная изоляция не добавляет им особой ценности. Где именно мы должны провести черту? Как и на большинство хороших вопросов, ответ - «зависит от обстоятельств».

В Redux я пришел к выводу, что срез - это наименьшая существующая автономная единица. Нет смысла писать изолированные тесты для их подкомпонентов, таких как редюсеры, создатели действий и селекторы. Тестируем их вместе. Если какой-то из них сломан, тесты покажут нам и выяснить, какой именно.

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

При использовании redux-thunk мы должны проверить, что наш преобразователь отправляет соответствующие действия в той же последовательности, которую мы ожидаем. Такие помощники, как redux-mock-store, облегчают нам задачу, поскольку раскрывают больше внутреннего состояния хранилища, чем собственное хранилище Redux.

Э-э-та-это все е-ф-фо-ребята!