Бывает ситуация, когда нам необходимо протестировать middleware, либо асинхронное событие, которые возникают в хранилище redux.

Цель этой статьи в том, чтобы показать как тестировать action в redux store.

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

Я предлагаю не создавать fake store, а просто добавить свой middleware, который будет сохранять action, которые были вызваны. В последствии вы сможете проверять, был ли вызван он или нет. А также аргументы, с которыми он был вызван.

Скачать сам проект можно здесь, чтобы не писать самому.

Если в кратце, мы протестируем вызов ф-ции задания имени животному.

src/store/animal.ts - то, что будем тестировать.

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

const initialState = {
    name: ''
}

const animalSlice = createSlice({
  name: 'animal',
  initialState,
  reducers: {
    setName: (state, action: PayloadAction<string>) => {
      state.name = action.payload;
    },
  },
})

export const { setName } = animalSlice.actions
export default animalSlice.reducer

src/tests/actionsMiddleware.ts - то, что нам позволяет хранить вызванные действия.

import { Middleware, Action, isAction } from "redux";

export interface ActionsMiddleware extends Middleware {
  getActions: () => Promise<Action[]>;
  clearActions: () => Promise<void>;
}

export const actionsMiddleware = () => {
  const actions: Action[] = [];

  const middleware: ActionsMiddleware = () => (next) => (action) => {
    if (isAction(action)) {
      actions.push(action);
    }
    return next(action);
  };

  middleware.getActions = async () => {
    return actions;
  };

  middleware.clearActions = async () => {
    actions.length = 0;
  };

  return middleware;
};

getActions сделан асинхронным, т.к. при работе с middleware нам нужно удостоверится что асинхронные действия поставленные в очередь redux, выполнились, т.е. по сути мы ждем следующую очередь, чтобы извлечь текущие actions.

src/tests/animal.test.ts - сам тест, в котором используем написанный middleware

import { configureStore } from "@reduxjs/toolkit";
import { ActionsMiddleware, actionsMiddleware } from "./actionsMiddleware";
import animalReducer, { setName } from "../store/animal";

function createStore(middleware: ActionsMiddleware) {
  return configureStore({
    reducer: {
      animal: animalReducer,
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(middleware),
  });
}

describe("animal test", () => {
  let store: ReturnType<typeof createStore>;
  let middleware: ActionsMiddleware;

  beforeEach(() => {
    middleware = actionsMiddleware();
    store = createStore(middleware);
  });

  afterEach(async () => {
    await middleware.clearActions();
  });

  it("saves only when conditions are met", async () => {
    store.dispatch(setName("cat"));

    let actions = await middleware.getActions();
    expect(actions).toHaveElementInArray(setName("cat"));
    expect(store.getState().animal.name).toBe("cat");

    await middleware.clearActions();

    actions = await middleware.getActions();
    expect(actions).not.toHaveElementInArray(setName("cat"));
    expect(store.getState().animal.name).toBe("cat");
  });
});

Как можно увидеть, для создания store в отдельную ф-цию вынес, чтобы можно было получить тип и им оперировать на 33 строке, иначе typescript тип неизвестен. Ну и такое решение гибкое, т.к. в зависимости от reducer у нас разные данные хранятся в хранилище.

У нас есть два действия:

getActions - это получить действия, которые были вызваны с момента создания store.

clearActions - утилитарный ф-ция, которая позволяет очистить стек вызовов действий.

Это демонстрация того, что redux можно и нужно тестировать.

И на последок ф-ция, которая используется для проверки toHaveElementInArray, есть ли объект внутри массива. Т.к. у нас стек, нужно убедиться, что было вызвано действие.

Я добавил это в файл setupTests.ts

expect.extend({
  toHaveElementInArray(received: any[], element: any) {
    const pass = received.some(
      (item) => JSON.stringify(item) === JSON.stringify(element)
    );

    if (pass) {
      return {
        message: () =>
          `expected ${JSON.stringify(received)} not to contain ${JSON.stringify(element)}`,
        pass: true,
      };
    } else {
      return {
        message: () =>
          `expected ${JSON.stringify(received)} to contain ${JSON.stringify(element)}`,
        pass: false,
      };
    }
  },
});

Для типизации создал файл src/test.d.ts:

declare global {
  namespace jest {
    interface Matchers<R> {
      toHaveElementInArray(element: any): R;
    }
  }
}

Делитесь своими советами, как вы тестируете redux.

Комментарии (2)


  1. Vitaly_js
    18.10.2024 08:22

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


    1. ko22012 Автор
      18.10.2024 08:22

      Вы предлагаете тестирование приложения с точки зрения пользователя, я не против. Но когда хотят функциональное тестирование, то приходят на выручку данные решения. react testing library это больше про интеграционные тесты.