RTK Query строит кэш как нормализованное хранилище результатов
запросов, привязанных к endpoint, аргументам запроса и
конфигурации сериализации ключей. Каждый запрос формирует стабильный
cache key, который используется для повторного использования данных без
повторного сетевого обращения.
Кэширование в RTK Query опирается на несколько ключевых механизмов:
queryKey), формируемый из
endpoint name + serialized argskeepUnusedDataFor)refetchOnMountOrArgChange,
refetchOnFocus, refetchOnReconnect)providesTags /
invalidatesTags)Тестирование кэширования требует проверки не только факта сохранения данных, но и поведения системы при повторных подписках, изменении аргументов и инвалидировании.
Для тестов важно фиксировать 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);
});
Ключевая проверка здесь — отсутствие повторного запроса при наличии валидного кэша.
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 управляет временем хранения
неиспользуемых данных.
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);
});
Переопределение сериализации может сломать или изменить стратегию кэширования.
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);
});
Этот флаг управляет повторной загрузкой при повторной подписке.
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);
});
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');
});
При параллельных запросах 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();
});
Сброс состояния 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();
});