React
vitest-auto-spy/react превращает ваш класс (сервис, стор, API-клиент) в типизированный спай. Спай — объект-заглушка: каждый его метод запоминает вызовы и возвращает то, что вы задали. Вы передаёте спай туда, откуда компонент берёт настоящий объект, чаще всего в Context-провайдер.
vitest-auto-spy/react — точка входа, то есть путь импорта для React-спек. Она экспортирует createSpyFromClass, createAutoMock, autoMocked, тип Spy<T>, asInstance, resetAutoSpy и хелперы для моков модулей, которые встретятся ниже. Полный список — в справочнике API.
Через Context-провайдер
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { type Spy, createSpyFromClass } from 'vitest-auto-spy/react';
import { Cart, CartContext } from './cart';
import { CartStore, type Order } from './cart-store';
describe('<Cart />', () => {
let cart: Spy<CartStore>;
beforeEach(() => {
cart = createSpyFromClass(CartStore);
});
it('renders the total the store reports', () => {
cart.total.mockReturnValue(42); // total(): number
render(
<CartContext.Provider value={cart}>
<Cart />
</CartContext.Provider>,
);
expect(screen.getByText('$42')).toBeInTheDocument();
});
it('shows the order number after checkout', async () => {
const order: Order = { id: 'ord_42', total: 42 };
cart.checkout.resolveWith(order); // checkout(token): Promise<Order>
render(
<CartContext.Provider value={cart}>
<Cart paymentToken="tok_abc" />
</CartContext.Provider>,
);
await userEvent.click(screen.getByRole('button', { name: 'Check out' }));
expect(await screen.findByText('Order ord_42 placed')).toBeInTheDocument();
expect(cart.checkout).toHaveBeenCalledWith('tok_abc');
});
});Как задать ответ методу спая:
mockReturnValue(value)— для метода, который возвращает обычное значение.resolveWith(value)— для метода, который возвращаетPromise. Передайте значение, которое вернёт промис (здесьOrder). Неполный объект приведите к типу:resolveWith({ id: 'ord_42' } as Order).calledWith(args)перед любым из них — ответ сработает только на вызов с этими аргументами.
Ответ resolveWith приходит асинхронно, поэтому результат ждите через findByText, а не getByText. toBeInTheDocument приходит из @testing-library/jest-dom: добавьте import '@testing-library/jest-dom/vitest' в setup-файл Vitest.
Частая ошибка: asInstance нужен, только если у класса есть #private- или private-члены. Тогда TypeScript сообщает об ошибке типа на value={cart}: тип спая эти члены отбрасывает. Передайте asInstance(cart) вместо приведения через as:
import { asInstance } from 'vitest-auto-spy/react';
<CartContext.Provider value={asInstance(cart)}>
<Cart />
</CartContext.Provider>;Подробнее — в разделе Типизация спаев.
В React нет контейнера зависимостей, поэтому аналога Angular-хелпера provideAutoSpy здесь нет: спай кладётся в провайдер руками, как в примере выше. react и @testing-library/react остаются вашими dev-зависимостями любой версии.
Какой раннер
На Vitest больше ничего не нужно. Если setup-файл уже импортирует другую точку входа раннера, например vitest-auto-spy/bun, спаи продолжает создавать тот раннер. Эта точка входа импортирует vitest, поэтому под bun test и node --test не загрузится: там импортируйте те же функции из vitest-auto-spy/bun или vitest-auto-spy/node.
Как зависимость хука
Если хук принимает зависимость аргументом, передайте спай напрямую. Обёртка не нужна:
import { renderHook, waitFor } from '@testing-library/react';
import { expect, it } from 'vitest';
import { createSpyFromClass } from 'vitest-auto-spy/react';
import { useUser } from './use-user';
import { UserApi } from './user-api';
it('exposes the loaded user', async () => {
const api = createSpyFromClass(UserApi);
api.fetchUser.calledWith(1).resolveWith({ id: 1, name: 'Ada' });
const { result } = renderHook(() => useUser(1, api));
await waitFor(() => expect(result.current.user?.name).toBe('Ada'));
expect(api.fetchUser).toHaveBeenCalledTimes(1);
});Мок кастомного хука
Лучше подходят два способа выше. Но бывает, что компонент вызывает хук сам, например useMovies() из модуля с хуками, и снаружи ничего не получает. Тогда модуль подменяют через vi.mock. Сложнее всего с результатом хука: написанный руками, это большой объект, который собирается заново в каждом тесте и не сверяется с типом. autoMocked собирает его по типу.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { assertMocked, autoMocked, moduleNamespace } from 'vitest-auto-spy/react';
import * as hooks from './hooks';
import { MovieSearch } from './movie-search';
vi.mock('./hooks', () => moduleNamespace({ useMovies: vi.fn(), useFetch: vi.fn() }));
beforeEach(() => {
assertMocked(hooks, { specifier: './hooks', exports: ['useMovies', 'useFetch'] });
});
describe('<MovieSearch />', () => {
it('renders what the hook filtered', () => {
const movies = autoMocked<hooks.UseMoviesResult>({
searchTerm: 'star',
filteredItems: [{ id: 1, title: 'Star Wars' }],
});
vi.mocked(hooks.useMovies).mockReturnValue(movies);
render(<MovieSearch movies={[]} />);
expect(screen.getByRole('listitem').textContent).toBe('Star Wars');
});
it('hands every keystroke to the setter', async () => {
const movies = autoMocked<hooks.UseMoviesResult>({ searchTerm: '', filteredItems: [] });
vi.mocked(hooks.useMovies).mockReturnValue(movies);
render(<MovieSearch movies={[]} />);
await userEvent.type(screen.getByLabelText('Search'), 'x');
expect(movies.setSearchTerm).toHaveBeenCalledWith('x');
});
});Что делает каждая часть:
autoMocked<UseMoviesResult>(values)собирает результат хука по его типу. Поля, которые вы передали, — обычные значения. Все остальные члены (setSearchTerm,reload) — спаи, они создаются при первом чтении. Переданные значения проверяются по типу, так что переименованное в хуке поле даст ошибку компиляции. Результат подходит и какUseMoviesResult, и как спай: один и тот же объект можно передать вmockReturnValueи проверить вexpect. Если нужен только тип спая, беритеcreateAutoMock<T>().moduleNamespaceдобавляет к результату фабрики ключиdefaultи__esModule. Без них модуль хуков, прочитанный через слой совместимости с CommonJS, падает сNo "default" export is defined on the mock.assertMockedпадает на этой строке, если мок не применился. Так бывает, когда бандлер уже встроил модуль в код; тогда компонент вызвал бы настоящий хук. Вexportsперечислены хуки, которыми управляет этот файл.
Сам хук остаётся обычным vi.fn(). vi.mocked даёт ему тип настоящего хука, поэтому mockReturnValue принимает только UseMoviesResult.
Два варианта:
- Оставить настоящие хуки и заменить один. С
vi.mock('./hooks', async (importOriginal) => moduleNamespace(await importOriginal(), { passthrough: true }))каждая экспортируемая функция становится спаем, который вызывает настоящий хук, пока вы его не настроите. - Дать
vi.fn()из фабрики методыcalledWithиresolveWith.adoptMock(hooks.useMovies)добавляет их на месте.
Оба описаны на странице о моках модулей.
Хук, который возвращает кортеж
Хук в стиле useState возвращает кортеж вроде [value, loading, error]. Кортеж — это данные, пишите его руками. Тип результата хука проверит каждую позицию:
// тот же файл, что выше
import { UserCard } from './user-card';
it('shows the loading state', () => {
vi.mocked(hooks.useFetch).mockReturnValue([undefined, true, null]);
render(<UserCard id={1} />);
expect(screen.getByText('Loading…')).toBeTruthy();
});
it('shows the error the hook reported', () => {
vi.mocked(hooks.useFetch).mockReturnValue([undefined, false, new Error('Not found')]);
render(<UserCard id={1} />);
expect(screen.getByRole('alert').textContent).toBe('Not found');
});
it('goes from loading to loaded across renders', () => {
vi.mocked(hooks.useFetch)
.mockReturnValueOnce([undefined, true, null])
.mockReturnValue([{ name: 'Ada' }, false, null]);
const { rerender } = render(<UserCard id={1} />);
expect(screen.getByText('Loading…')).toBeTruthy();
rerender(<UserCard id={1} />);
expect(screen.getByRole('heading').textContent).toBe('Ada');
expect(hooks.useFetch).toHaveBeenCalledWith('/api/users/1');
});Частая ошибка: createAutoMock<[T, boolean]>() для кортежа. Строка const [data, loading] = hook() бросает TypeError: … is not iterable: автомок не итерируемый. mockDeep падает так же. Автомок — для объектов с функциями, не для кортежей.
Значение, которое протекает в следующий тест
vi.fn() из фабрики vi.mock живёт весь файл. mockReturnValue, заданный в одном тесте, отвечает и в следующем. Признак: тест проходит один и падает, когда запускается весь файл.
С clearMocks: true (в Vitest 5 это значение по умолчанию) любой Vitest перед каждым тестом очищает записанные вызовы, но заданный ответ при этом остаётся. Исправить можно одним из двух способов:
- включите
mockReset: trueв конфиге Vitest; - настраивайте хук в каждом тесте, который рендерит компонент, как в примерах выше.
Значения из autoMocked или createAutoMock, созданные внутри теста, каждый раз новые и не протекают. Для спая, созданного один раз на файл, см. setupAutoSpy() и resetAutoSpy.
Что спаить не надо
Спайте классы, которыми владеете. Мокайте хук, только когда он — зависимость компонента. Заспаенный компонент ничего не говорит о рендеринге. Тест хука, в котором замокан сам хук, проверяет только свой мок: тестируйте хук через renderHook и спай зависимости, как в разделе Как зависимость хука.