Тестирование кэширования и инвалидации

RTK Query строит кэш как нормализованное хранилище результатов запросов, привязанных к endpoint, аргументам запроса и конфигурации сериализации ключей. Каждый запрос формирует стабильный cache key, который используется для повторного использования данных без повторного сетевого обращения.

Кэширование в RTK Query опирается на несколько ключевых механизмов:

  • ключ запроса (queryKey), формируемый из endpoint name + serialized args
  • политика времени жизни данных (keepUnusedDataFor)
  • флаги повторной загрузки (refetchOnMountOrArgChange, refetchOnFocus, refetchOnReconnect)
  • система тегов (providesTags / invalidatesTags)

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


Базовая структура тестируемого API slice

Для тестов важно фиксировать API слой как единую точку контроля:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
      providesTags: (result) =>
        result
          ? [
              ...result.map(({ id }) => ({ type: 'Post', id })),
              { type: 'Post', id: 'LIST' },
            ]
          : [{ type: 'Post', id: 'LIST' }],
    }),

    addPost: builder.mutation({
      query: (body) => ({
        url: '/posts',
        method: 'POST',
        body,
      }),
      invalidatesTags: [{ type: 'Post', id: 'LIST' }],
    }),
  }),
});

export const { useGetPostsQuery, useAddPostMutation } = api;

Тестирование кэширования: базовый сценарий повторного запроса

Основная цель — убедиться, что второй вызов useGetPostsQuery не инициирует повторный network request при совпадающем cache key.

import { setupApiStore } from './testUtils';
import { api } from './api';

test('должен использовать кэш при повторном запросе с теми же аргументами', async () => {
  const store = setupApiStore(api);

  const firstResult = store.dispatch(api.endpoints.getPosts.initiate());
  await firstResult;

  const stateAfterFirst = store.getState();

  const secondResult = store.dispatch(api.endpoints.getPosts.initiate());

  expect(secondResult.requestId).not.toBe(firstResult.requestId);

  const stateAfterSecond = store.getState();

  expect(stateAfterSecond.api.queries).toBe(stateAfterFirst.api.queries);
});

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


Проверка cache hit через селекторы

RTK Query предоставляет селекторы для проверки состояния кэша напрямую:

test('селектор возвращает данные из кэша без нового запроса', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPosts.initiate());

  const selector = api.endpoints.getPosts.select();

  const cachedState = selector(store.getState());

  expect(cachedState.data).toBeDefined();
  expect(cachedState.status).toBe('fulfilled');
});

Селекторный уровень тестирования важен для отделения UI-логики от сетевого слоя.


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

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

test('инвалидация LIST тега вызывает повторный fetch', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPosts.initiate());

  await store.dispatch(
    api.endpoints.addPost.initiate({ title: 'new post' })
  );

  const result = await store.dispatch(api.endpoints.getPosts.initiate());

  expect(result.meta.requestStatus).toBe('fulfilled');
});

Здесь важно проверять не только факт повторного запроса, но и то, что он был вызван именно после mutation.


Контроль поведения кэша через keepUnusedDataFor

Параметр keepUnusedDataFor управляет временем хранения неиспользуемых данных.

builder.query({
  query: () => '/posts',
  keepUnusedDataFor: 0,
});

Тестирование этого поведения требует симуляции размонтирования подписчиков:

test('данные удаляются из кэша после завершения keepUnusedDataFor', async () => {
  jest.useFakeTimers();

  const store = setupApiStore(api);

  const sub = store.dispatch(api.endpoints.getPosts.initiate());
  await sub;

  sub.unsubscribe();

  jest.advanceTimersByTime(1000);

  const state = store.getState();

  expect(state.api.queries['getPosts(undefined)']).toBeUndefined();
});

Тестирование повторной загрузки при смене аргументов

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

test('разные аргументы создают разные cache entries', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPostsByUser.initiate(1));
  await store.dispatch(api.endpoints.getPostsByUser.initiate(2));

  const state = store.getState();

  const keys = Object.keys(state.api.queries);

  expect(keys.length).toBe(2);
});

serializeQueryArgs и влияние на кэш

Переопределение сериализации может сломать или изменить стратегию кэширования.

getPosts: builder.query({
  query: (filters) => `/posts?sort=${filters.sort}`,
  serializeQueryArgs: ({ endpointName }) => endpointName,
});

Теперь все вызовы используют один cache key независимо от аргументов.

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

test('serializeQueryArgs объединяет кэш по endpoint', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPosts.initiate({ sort: 'asc' }));
  await store.dispatch(api.endpoints.getPosts.initiate({ sort: 'desc' }));

  const state = store.getState();

  const entries = Object.values(state.api.queries).filter(Boolean);

  expect(entries.length).toBe(1);
});

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

Этот флаг управляет повторной загрузкой при повторной подписке.

getPosts: builder.query({
  query: () => '/posts',
  refetchOnMountOrArgChange: true,
});

Тест:

test('refetchOnMountOrArgChange вызывает новый запрос при новом mount', async () => {
  const store = setupApiStore(api);

  const first = store.dispatch(api.endpoints.getPosts.initiate());
  await first;

  first.unsubscribe();

  const second = store.dispatch(api.endpoints.getPosts.initiate());

  await second;

  expect(second.requestId).not.toBe(first.requestId);
});

Интеграционное тестирование с MSW

Mock Service Worker позволяет тестировать реальное поведение кэша без моков Redux слоя.

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

const server = setupServer(
  rest.get('/api/posts', (req, res, ctx) => {
    return res(ctx.json([{ id: 1, title: 'post' }]));
  })
);

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

Тест кэширования:

test('MSW: второй запрос не вызывает новый handler при наличии кэша', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPosts.initiate());
  await store.dispatch(api.endpoints.getPosts.initiate());

  expect(server.events).toBeDefined();
});

В реальной проверке чаще отслеживается количество вызовов handler через счетчик:

let callCount = 0;

rest.get('/api/posts', (req, res, ctx) => {
  callCount++;
  return res(ctx.json([]));
});

Тестирование инвалидации конкретных сущностей

Инвалидация по ID позволяет проверять точечное обновление кэша.

invalidatesTags: (result, error, arg) => [
  { type: 'Post', id: arg.id },
];

Тест:

test('инвалидация конкретного id обновляет соответствующий кэш', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPost.initiate(1));

  await store.dispatch(
    api.endpoints.updatePost.initiate({ id: 1, title: 'updated' })
  );

  const state = store.getState();

  const entry = api.endpoints.getPost.select(1)(state);

  expect(entry.data.title).toBe('updated');
});

Проверка race conditions в кэше

При параллельных запросах RTK Query должен дедуплицировать одинаковые вызовы.

test('параллельные одинаковые запросы используют один network call', async () => {
  const store = setupApiStore(api);

  const p1 = store.dispatch(api.endpoints.getPosts.initiate());
  const p2 = store.dispatch(api.endpoints.getPosts.initiate());

  await Promise.all([p1, p2]);

  const state = store.getState();

  const entry = state.api.queries['getPosts(undefined)'];

  expect(entry.requestId).toBeDefined();
});

Проверка очистки кэша при resetApiState

Сброс состояния API должен полностью очищать queries и mutations.

test('resetApiState очищает кэш', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPosts.initiate());

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

  const state = store.getState();

  expect(state.api.queries).toEqual({});
});

Особенности тестирования мутаций и последовательности инвалидирования

Мутации часто инициируют цепочку: mutation → invalidatesTags → refetch queries.

Важно проверять порядок:

test('mutation запускает refetch после invalidation', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPosts.initiate());

  const mutation = store.dispatch(api.endpoints.addPost.initiate({ title: 'x' }));

  await mutation;

  const state = store.getState();

  const queries = state.api.queries;

  expect(Object.keys(queries).length).toBeGreaterThan(0);
});

Проверка устойчивости к повторным инвалидациям

Повторные invalidateTags не должны приводить к бесконечным refetch циклам.

test('повторная инвалидация не вызывает бесконечный refetch', async () => {
  const store = setupApiStore(api);

  await store.dispatch(api.endpoints.getPosts.initiate());

  for (let i = 0; i < 3; i++) {
    store.dispatch(api.util.invalidateTags(['Post']));
  }

  const state = store.getState();

  expect(state.api.queries['getPosts(undefined)']).toBeDefined();
});