Тестирование компонентов с RTK Query

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

Базовая точка тестирования компонентов с RTK Query — корректно сконфигурированный store. В него обязательно подключается reducer API-сервиса и middleware, иначе хуки useQuery и useMutation не смогут функционировать корректно.

import { configureStore } from '@reduxjs/toolkit';
import { api } from '../services/api';

export function setupStore(preloadedState) {
  return configureStore({
    reducer: {
      [api.reducerPath]: api.reducer,
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(api.middleware),
    preloadedState,
  });
}

Ключевой момент — использование api.reducerPath как динамического ключа. Это обеспечивает совместимость с любым API slice.

Оборачивание компонентов в Provider

Для тестирования React-компонентов необходимо обернуть их в Provider с тестовым store.

import React from 'react';
import { Provider } from 'react-redux';
import { setupStore } from './setupStore';

export function renderWithStore(ui, preloadedState) {
  const store = setupStore(preloadedState);

  return {
    store,
    ...render(<Provider store={store}>{ui}</Provider>),
  };
}

Такой подход позволяет контролировать состояние Redux внутри каждого теста и при необходимости сбрасывать его между сценариями.

Тестирование query-запросов через MSW

Наиболее устойчивый способ тестирования RTK Query — использование Mock Service Worker. Он перехватывает реальные HTTP-запросы и возвращает заранее определенные ответы.

import { rest } from 'msw';
import { setupServer } from 'msw/node';

export const handlers = [
  rest.get('/users', (req, res, ctx) => {
    return res(ctx.json([{ id: 1, name: 'Alex' }]));
  }),
];

export const server = setupServer(...handlers);

В тестовой среде сервер запускается и останавливается:

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

MSW позволяет тестировать RTK Query как в реальном приложении, сохраняя поведение сети.

Проверка состояния загрузки

Компоненты с useQuery обычно имеют три ключевых состояния: загрузка, успех и ошибка. Состояние загрузки можно тестировать через ожидание появления индикатора.

test('отображает loader во время загрузки', async () => {
  renderWithStore(<UsersList />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();
});

Важно учитывать, что RTK Query может кешировать данные, и повторный рендер может не вызывать загрузку. Для изоляции теста иногда используется сброс API состояния:

store.dispatch(api.util.resetApiState());

Тестирование успешного ответа

После завершения запроса компонент должен отрендерить данные из кэша RTK Query.

test('рендерит список пользователей', async () => {
  renderWithStore(<UsersList />);

  const item = await screen.findByText('Alex');

  expect(item).toBeInTheDocument();
});

findByText используется, так как RTK Query выполняет асинхронный запрос, и UI обновляется после завершения промиса.

Обработка ошибок

Ошибки сети можно симулировать через MSW, переопределяя handler.

server.use(
  rest.get('/users', (req, res, ctx) => {
    return res(ctx.status(500));
  })
);

Тест проверяет корректный UI-рендер ошибки:

test('показывает ошибку при неудачном запросе', async () => {
  renderWithStore(<UsersList />);

  const error = await screen.findByText(/error/i);

  expect(error).toBeInTheDocument();
});

Тестирование useMutation

Mutations требуют отдельного подхода, так как они инициируются вручную.

const [createUser] = useCreateUserMutation();

В тесте важно вызвать mutation и дождаться обновления состояния.

test('создает пользователя', async () => {
  renderWithStore(<CreateUserForm />);

  fireEvent.change(screen.getByPlaceholderText('name'), {
    target: { value: 'John' },
  });

  fireEvent.click(screen.getByText('submit'));

  expect(await screen.findByText('success')).toBeInTheDocument();
});

MSW используется для симуляции POST-запроса:

rest.post('/users', (req, res, ctx) => {
  return res(ctx.json({ id: 2, name: 'John' }));
});

Тестирование RTK Query hooks отдельно

Иногда требуется тестировать хуки без UI. Для этого используется renderHook из React Testing Library.

import { renderHook } from '@testing-library/react';
import { Provider } from 'react-redux';

test('useGetUsersQuery возвращает данные', async () => {
  const store = setupStore();

  const wrapper = ({ children }) => (
    <Provider store={store}>{children}</Provider>
  );

  const { result } = renderHook(() => useGetUsersQuery(), { wrapper });

  expect(result.current.isLoading).toBe(true);

  await waitFor(() => {
    expect(result.current.isSuccess).toBe(true);
  });
});

Работа с кэшированием RTK Query в тестах

RTK Query активно использует кэш, поэтому один и тот же запрос может не повторяться между тестами. Это часто приводит к «ложно успешным» результатам.

Для полной изоляции используется:

afterEach(() => {
  store.dispatch(api.util.resetApiState());
});

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

Тестирование selectFromResult

selectFromResult позволяет оптимизировать рендер, но усложняет тестирование, так как возвращаемое значение отличается от стандартного ответа.

const { data, isLoading } = useGetUsersQuery(undefined, {
  selectFromResult: ({ data, isLoading }) => ({
    data: data?.slice(0, 5),
    isLoading,
  }),
});

Тесты должны учитывать трансформацию данных:

expect(screen.getAllByRole('listitem')).toHaveLength(5);

Изоляция middleware и повторяемость тестов

Middleware RTK Query может влиять на глобальное состояние store. Для стабильных тестов важно избегать шаринга store между тестами.

let store;

beforeEach(() => {
  store = setupStore();
});

Каждый тест получает новый экземпляр store, что исключает утечки состояния.

Альтернативный подход: мокирование API слоя

Помимо MSW, можно мокировать сам API slice:

jest.mock('../services/api', () => ({
  useGetUsersQuery: () => ({
    data: [{ id: 1, name: 'Mock' }],
    isLoading: false,
    isSuccess: true,
  }),
}));

Такой подход ускоряет тесты, но снижает их реалистичность, поэтому применяется только для изолированных unit-тестов компонентов UI.

Проверка повторных запросов и дедупликации

RTK Query автоматически дедуплицирует запросы с одинаковыми аргументами. Это можно проверять через spy на fetch:

const fetchSpy = jest.spyOn(global, 'fetch');

renderWithStore(<UsersList />);
renderWithStore(<UsersList />);

expect(fetchSpy).toHaveBeenCalledTimes(1);

Тестирование состояния skipToken

skipToken используется для условного отключения запроса.

useGetUserQuery(userId ?? skipToken);

Тест проверяет, что запрос не выполняется:

renderWithStore(<UserProfile userId={null} />);

expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();

Интеграционное тестирование компонентов с несколькими endpoint’ами

Компоненты, использующие несколько RTK Query хуков, требуют проверки согласованности состояний.

const user = useGetUserQuery(id);
const posts = useGetPostsQuery(id);

Тест должен учитывать, что данные приходят независимо:

await screen.findByText('User loaded');
await screen.findByText('Posts loaded');

Сценарии ошибок также могут быть раздельными, что важно проверять отдельно.

Контроль обновления кэша после мутаций

RTK Query позволяет инвалидировать теги после mutation, и это поведение должно проверяться в тестах.

server.use(
  rest.post('/users', (req, res, ctx) => {
    return res(ctx.json({ id: 3, name: 'Kate' }));
  })
);

После выполнения mutation список пользователей должен обновиться автоматически:

await screen.findByText('Kate');

Это подтверждает корректную работу invalidation и refetch механизма.