Мокирование
При написании тестов рано или поздно возникает необходимость создать "фиктивную" версию внутренней или внешней службы. Это обычно называется мокированием. Vitest предоставляет вспомогательные функции для этого через свой помощник vi. Вы можете import { vi } from 'vitest'
или получить к нему доступ глобально (когда глобальная конфигурация активирована).
WARNING
Всегда не забывайте очищать или восстанавливать моки до или после каждого запуска теста, чтобы отменить изменения их состояния между запусками! Дополнительную информацию см. в документации mockReset
.
Если вы хотите сразу погрузиться в детали, ознакомьтесь с разделом API, в противном случае продолжайте читать, чтобы глубже погрузиться в мир мокирования.
Даты
Иногда вам нужно управлять датой, чтобы обеспечить согласованность при тестировании. Vitest использует пакет @sinonjs/fake-timers
для манипулирования таймерами, а также системной датой. Подробнее о конкретном API вы можете найти здесь.
Пример
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const businessHours = [9, 17];
function purchase() {
const currentHour = new Date().getHours();
const [open, close] = businessHours;
if (currentHour > open && currentHour < close) {
return { message: 'Success' };
}
return { message: 'Error' };
}
describe('purchasing flow', () => {
beforeEach(() => {
// сообщаем vitest, что мы используем поддельное время
vi.useFakeTimers();
});
afterEach(() => {
// восстанавливаем время после каждого теста
vi.useRealTimers();
});
it('allows purchases within business hours', () => {
// устанавливаем час в рабочие часы
const date = new Date(2000, 1, 1, 13);
vi.setSystemTime(date);
// доступ к Date.now() приведет к дате, установленной выше
expect(purchase()).toEqual({ message: 'Success' });
});
it('disallows purchases outside of business hours', () => {
// устанавливаем час за пределами рабочего времени
const date = new Date(2000, 1, 1, 19);
vi.setSystemTime(date);
// доступ к Date.now() приведет к дате, установленной выше
expect(purchase()).toEqual({ message: 'Error' });
});
});
Функции
Мокирование функций можно разделить на две разные категории: шпионирование и мокирование.
Иногда все, что вам нужно, это проверить, была ли вызвана определенная функция (и, возможно, какие аргументы были использованы). В этих случаях нам понадобится только шпион, который вы можете использовать с vi.spyOn()
(подробнее здесь).
Однако шпионы могут только отслеживать функции, они не способны изменять реализацию этих функций. Если нам нужно создать мокированную версию функции, мы можем использовать vi.fn()
(подробнее здесь).
Мы используем Tinyspy в качестве основы для создания моков функций, но у нас есть собственная обертка, чтобы сделать его совместимым с jest
. И vi.fn()
, и vi.spyOn()
обладают одними и теми же методами, однако только результат, возвращаемый vi.fn()
, может быть вызван.
Пример
import { afterEach, describe, expect, it, vi } from 'vitest';
const messages = {
items: [
{ message: 'Simple test message', from: 'Testman' },
// ...
],
getLatest, // также может быть `getter` или `setter`, если поддерживается
};
function getLatest(index = messages.items.length - 1) {
return messages.items[index];
}
describe('reading messages', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should get the latest message with a spy', () => {
const spy = vi.spyOn(messages, 'getLatest');
expect(spy.getMockName()).toEqual('getLatest');
expect(messages.getLatest()).toEqual(
messages.items[messages.items.length - 1]
);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockImplementationOnce(() => 'access-restricted');
expect(messages.getLatest()).toEqual('access-restricted');
expect(spy).toHaveBeenCalledTimes(2);
});
it('should get with a mock', () => {
const mock = vi.fn().mockImplementation(getLatest);
expect(mock()).toEqual(messages.items[messages.items.length - 1]);
expect(mock).toHaveBeenCalledTimes(1);
mock.mockImplementationOnce(() => 'access-restricted');
expect(mock()).toEqual('access-restricted');
expect(mock).toHaveBeenCalledTimes(2);
expect(mock()).toEqual(messages.items[messages.items.length - 1]);
expect(mock).toHaveBeenCalledTimes(3);
});
});
Дополнительно
Глобальные переменные
Вы можете мокировать глобальные переменные, которые недоступны в jsdom
или node
, используя помощник vi.stubGlobal
. Он поместит значение глобальной переменной в объект globalThis
.
import { vi } from 'vitest';
const IntersectionObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
}));
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
// теперь вы можете получить к нему доступ как `IntersectionObserver` или `window.IntersectionObserver`
Модули
Мокирование модулей позволяет имитировать сторонние библиотеки, которые вызываются в другом коде, что дает возможность тестировать аргументы, вывод или даже переопределять их реализацию.
Подробнее см. в разделе API vi.mock()
.
Алгоритм автоматического мокирования
Если ваш код импортирует мокированный модуль без связанного файла __mocks__
или factory
для этого модуля, Vitest будет мокировать сам модуль, вызывая его и мокируя все экспорты.
Применяются следующие принципы:
- Все массивы будут очищены
- Все примитивы и коллекции останутся прежними
- Все объекты будут глубоко клонированы
- Все экземпляры классов и их прототипы будут глубоко клонированы
Виртуальные модули
Vitest поддерживает мокирование виртуальных модулей Vite. Это работает иначе, чем обработка виртуальных модулей в Jest. Вместо передачи virtual: true
в функцию vi.mock
, вам нужно сообщить Vite, что модуль существует, иначе он выдаст ошибку при парсинге. Вы можете сделать это несколькими способами:
- Предоставить псевдоним
// vitest.config.js
export default {
test: {
alias: {
'$app/forms': resolve('./mocks/forms.js'),
},
},
};
- Предоставить плагин, который разрешает виртуальный модуль
// vitest.config.js
export default {
plugins: [
{
name: 'virtual-modules',
resolveId(id) {
if (id === '$app/forms') {
return 'virtual:$app/forms';
}
},
},
],
};
Преимущество второго подхода заключается в том, что вы можете динамически создавать различные виртуальные входные точки. Если вы перенаправляете несколько виртуальных модулей в один файл, то все они будут затронуты vi.mock
, поэтому убедитесь, что используете уникальные идентификаторы.
Подводные камни мокирования
Учтите, что невозможно мокировать вызовы методов, которые вызываются внутри других методов того же файла. Например, в данном коде:
export function foo() {
return 'foo';
}
export function foobar() {
return `${foo()}bar`;
}
Невозможно мокировать метод foo
извне, потому что он ссылается напрямую. Таким образом, этот код не окажет влияния на вызов foo
внутри foobar
(но повлияет на вызов foo
в других модулях):
import { vi } from 'vitest';
import * as mod from './foobar.js';
// это повлияет только на "foo" за пределами исходного модуля
vi.spyOn(mod, 'foo');
vi.mock('./foobar.js', async importOriginal => {
return {
...(await importOriginal<typeof import('./foobar.js')>()),
// это повлияет только на "foo" за пределами исходного модуля
foo: () => 'mocked',
};
});
Вы можете подтвердить это поведение, предоставив реализацию методу foobar
напрямую:
// foobar.test.js
import * as mod from './foobar.js';
vi.spyOn(mod, 'foo');
// экспортированный foo ссылается на мокированный метод
mod.foobar(mod.foo);
// foobar.js
export function foo() {
return 'foo';
}
export function foobar(injectedFoo) {
return injectedFoo === foo; // false
}
Такое поведение ожидаемо. Обычно это признак плохого кода, когда мокирование применяется таким образом. Рассмотрите возможность рефакторинга вашего кода в несколько файлов или улучшения архитектуры вашего приложения с использованием таких методов, как внедрение зависимостей.
Пример
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Client } from 'pg';
import { failure, success } from './handlers.js';
// получить задачи
export async function getTodos(event, context) {
const client = new Client({
// ...clientOptions
});
await client.connect();
try {
const result = await client.query('SELECT * FROM todos;');
client.end();
return success({
message: `${result.rowCount} item(s) returned`,
data: result.rows,
status: true,
});
} catch (e) {
console.error(e.stack);
client.end();
return failure({ message: e, status: false });
}
}
vi.mock('pg', () => {
const Client = vi.fn();
Client.prototype.connect = vi.fn();
Client.prototype.query = vi.fn();
Client.prototype.end = vi.fn();
return { Client };
});
vi.mock('./handlers.js', () => {
return {
success: vi.fn(),
failure: vi.fn(),
};
});
describe('get a list of todo items', () => {
let client;
beforeEach(() => {
client = new Client();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should return items successfully', async () => {
client.query.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await getTodos();
expect(client.connect).toBeCalledTimes(1);
expect(client.query).toBeCalledWith('SELECT * FROM todos;');
expect(client.end).toBeCalledTimes(1);
expect(success).toBeCalledWith({
message: '0 item(s) returned',
data: [],
status: true,
});
});
it('should throw an error', async () => {
const mError = new Error('Unable to retrieve rows');
client.query.mockRejectedValueOnce(mError);
await getTodos();
expect(client.connect).toBeCalledTimes(1);
expect(client.query).toBeCalledWith('SELECT * FROM todos;');
expect(client.end).toBeCalledTimes(1);
expect(failure).toBeCalledWith({ message: mError, status: false });
});
});
Файловая система
Мокирование файловой системы гарантирует, что тесты не зависят от реальной файловой системы, делая их более надежными и предсказуемыми. Эта изоляция помогает избежать побочных эффектов от предыдущих тестов. Она позволяет тестировать ошибочные условия и граничные случаи, которые может быть трудно или невозможно воспроизвести с реальной файловой системой, такие как проблемы с разрешениями, сценарии переполнения диска или ошибки чтения/записи.
Vitest не предоставляет встроенного API для мокирования файловой системы. Вы можете использовать vi.mock
для ручного мокирования модуля fs
, но это сложно поддерживать. Вместо этого мы рекомендуем использовать memfs
для этого. memfs
создает файловую систему в памяти, которая имитирует операции файловой системы, не затрагивая реальный диск. Этот подход быстр и безопасен, позволяя избежать любых потенциальных побочных эффектов в реальной файловой системе.
Пример
Чтобы автоматически перенаправлять каждый вызов fs
в memfs
, вы можете создать файлы __mocks__/fs.cjs
и __mocks__/fs/promises.cjs
в корне вашего проекта:
// мы также можем использовать `import`, но тогда
// каждый экспорт должен быть явно определен
const { fs } = require('memfs');
module.exports = fs;
// мы также можем использовать `import`, но тогда
// каждый экспорт должен быть явно определен
const { fs } = require('memfs');
module.exports = fs.promises;
// read-hello-world.js
import { readFileSync } from 'node:fs';
export function readHelloWorld(path) {
return readFileSync(path);
}
// hello-world.test.js
import { beforeEach, expect, it, vi } from 'vitest';
import { fs, vol } from 'memfs';
import { readHelloWorld } from './read-hello-world.js';
// сообщаем vitest использовать мок fs из папки __mocks__
// это можно сделать в файле настройки, если fs всегда должен быть мокирован
vi.mock('node:fs');
vi.mock('node:fs/promises');
beforeEach(() => {
// сброс состояния файловой системы в памяти
vol.reset();
});
it('should return correct text', () => {
const path = '/hello-world.txt';
fs.writeFileSync(path, 'hello world');
const text = readHelloWorld(path);
expect(text).toBe('hello world');
});
it('can return a value multiple times', () => {
// вы можете использовать vol.fromJSON для определения нескольких файлов
vol.fromJSON(
{
'./dir1/hw.txt': 'hello dir1',
'./dir2/hw.txt': 'hello dir2',
},
// cwd по умолчанию
'/tmp'
);
expect(readHelloWorld('/tmp/dir1/hw.txt')).toBe('hello dir1');
expect(readHelloWorld('/tmp/dir2/hw.txt')).toBe('hello dir2');
});
Запросы
Поскольку Vitest работает в Node, мокирование сетевых запросов представляет собой сложную задачу; веб-API недоступны, поэтому нам нужно что-то, что сможет имитировать сетевое поведение. Мы рекомендуем Mock Service Worker для достижения этой цели. Он позволит вам мокировать как REST
, так и GraphQL
сетевые запросы и не зависит от фреймворка.
Mock Service Worker (MSW) работает путем перехвата запросов, которые выполняют ваши тесты, что позволяет использовать его без изменения кода вашего приложения. В браузере для этого используется Service Worker API. В Node.js и для Vitest он использует библиотеку @mswjs/interceptors
. Чтобы узнать больше о MSW, прочитайте их введение
Конфигурация
Вы можете использовать его, как показано ниже, в вашем файле настройки
import { afterAll, afterEach, beforeAll } from 'vitest';
import { setupServer } from 'msw/node';
import { graphql, http, HttpResponse } from 'msw';
const posts = [
{
userId: 1,
id: 1,
title: 'first post title',
body: 'first post body',
},
// ...
];
export const restHandlers = [
http.get('https://rest-endpoint.example/path/to/posts', () => {
return HttpResponse.json(posts);
}),
];
const graphqlHandlers = [
graphql.query('ListPosts', () => {
return HttpResponse.json({
data: { posts },
});
}),
];
const server = setupServer(...restHandlers, ...graphqlHandlers);
// Запустить сервер перед всеми тестами
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Закрыть сервер после всех тестов
afterAll(() => server.close());
// Сбросить обработчики после каждого теста `важно для изоляции тестов`
afterEach(() => server.resetHandlers());
Настройка сервера с
onUnhandleRequest: 'error'
гарантирует, что ошибка будет сгенерирована всякий раз, когда есть запрос, у которого нет соответствующего обработчика запросов.
Дополнительно
В MSW есть гораздо больше. Вы можете получать доступ к файлам cookie и параметрам запроса, определять мокированные ответы об ошибках и многое другое! Чтобы увидеть все, что вы можете сделать с MSW, прочитайте их документацию.
Таймеры
Когда мы тестируем код, который использует тайм-ауты или интервалы, вместо того, чтобы наши тесты ждали завершения или истечения тайм-аута, мы можем ускорить их, используя "фиктивные" таймеры, которые мокируют вызовы setTimeout
и setInterval
.
Подробнее см. в разделе API vi.useFakeTimers
.
Пример
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
function executeAfterTwoHours(func) {
setTimeout(func, 1000 * 60 * 60 * 2); // 2 часа
}
function executeEveryMinute(func) {
setInterval(func, 1000 * 60); // 1 минута
}
const mock = vi.fn(() => console.log('executed'));
describe('delayed execution', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should execute the function', () => {
executeAfterTwoHours(mock);
vi.runAllTimers();
expect(mock).toHaveBeenCalledTimes(1);
});
it('should not execute the function', () => {
executeAfterTwoHours(mock);
// продвижение на 2 мс не вызовет функцию
vi.advanceTimersByTime(2);
expect(mock).not.toHaveBeenCalled();
});
it('should execute every minute', () => {
executeEveryMinute(mock);
vi.advanceTimersToNextTimer();
expect(mock).toHaveBeenCalledTimes(1);
vi.advanceTimersToNextTimer();
expect(mock).toHaveBeenCalledTimes(2);
});
});
Классы
Вы можете мокировать весь класс одним вызовом vi.fn
— поскольку все классы также являются функциями, это работает по умолчанию. Имейте в виду, что в настоящее время Vitest не обрабатывает ключевое слово new
, поэтому new.target
всегда undefined
в теле функции.
class Dog {
name: string;
constructor(name: string) {
this.name = name;
}
static getType(): string {
return 'animal';
}
speak(): string {
return 'bark!';
}
isHungry() {}
feed() {}
}
Мы можем пересоздать этот класс с помощью функций ES5:
const Dog = vi.fn(function (name) {
this.name = name;
});
// обратите внимание, что статические методы мокируются непосредственно на функции,
// а не на экземпляре класса
Dog.getType = vi.fn(() => 'mocked animal');
// мокируем методы "speak" и "feed" на каждом экземпляре класса
// все экземпляры `new Dog()` будут использовать эти шпионы
Dog.prototype.speak = vi.fn(() => 'loud bark!');
Dog.prototype.feed = vi.fn();
КОГДА ИСПОЛЬЗОВАТЬ?
Как правило, вы бы пересоздали класс таким образом внутри фабрики модуля, если класс реэкспортируется из другого модуля:
import { Dog } from './dog.js';
vi.mock(import('./dog.js'), () => {
const Dog = vi.fn();
Dog.prototype.feed = vi.fn();
// ... другие моки
return { Dog };
});
Этот метод также может быть использован для передачи экземпляра класса функции, которая принимает тот же интерфейс:
// ./src/feed.ts
function feed(dog: Dog) {
// ...
}
// ./tests/dog.test.ts
import { expect, test, vi } from 'vitest';
import { feed } from '../src/feed.js';
const Dog = vi.fn();
Dog.prototype.feed = vi.fn();
test('can feed dogs', () => {
const dogMax = new Dog('Max');
feed(dogMax);
expect(dogMax.feed).toHaveBeenCalled();
expect(dogMax.isHungry()).toBe(false);
});
Теперь, когда мы создаем новый экземпляр класса Dog
, его метод speak
(наряду с feed
) уже мокирован:
const dog = new Dog('Cooper');
dog.speak(); // громкий лай!
// вы можете использовать встроенные утверждения для проверки валидности вызова
expect(dog.speak).toHaveBeenCalled();
Мы можем переназначить возвращаемое значение для конкретного экземпляра:
const dog = new Dog('Cooper');
// "vi.mocked" - это вспомогательная функция для типизации, поскольку
// TypeScript не распознает, что Dog - это мокированный класс,
// он приводит любую функцию к типу MockInstance<T>
// без проверки, является ли функция моком
vi.mocked(dog.speak).mockReturnValue('woof woof');
dog.speak(); // гав-гав
Чтобы мокировать свойство, мы можем использовать метод vi.spyOn(dog, 'name', 'get')
. Это позволяет использовать утверждения шпиона на мокированном свойстве:
const dog = new Dog('Cooper');
const nameSpy = vi.spyOn(dog, 'name', 'get').mockReturnValue('Max');
expect(dog.name).toBe('Max');
expect(nameSpy).toHaveBeenCalledTimes(1);
TIP
Вы также можете шпионить за геттерами и сеттерами, используя тот же метод.
Шпаргалка
INFO
vi
в примерах ниже импортируется непосредственно из vitest
. Вы также можете использовать его глобально, если установите для globals
значение true
в вашей конфигурации.
Если нужно…
Мокировать экспортированные переменные
// файл: some-path.js
export const getter = 'variable';
// some-path.test.ts
import * as exports from './some-path.js';
vi.spyOn(exports, 'getter', 'get').mockReturnValue('mocked');
Мокировать экспортированную функцию
- Пример с
vi.mock
:
WARNING
Не забывайте, что вызов vi.mock
перемещается в начало файла. Он всегда будет выполняться перед всеми импортами.
// ./some-path.js
export function method() {}
import { method } from './some-path.js';
vi.mock('./some-path.js', () => ({
method: vi.fn(),
}));
- Пример с
vi.spyOn
:
import * as exports from './some-path.js';
vi.spyOn(exports, 'method').mockImplementation(() => {});
Мокировать реализацию экспортированного класса
- Пример с
vi.mock
и.prototype
:
// ./some-path.ts
export class SomeClass {}
import { SomeClass } from './some-path.js';
vi.mock(import('./some-path.js'), () => {
const SomeClass = vi.fn();
SomeClass.prototype.someMethod = vi.fn();
return { SomeClass };
});
// SomeClass.mock.instances будет содержать SomeClass
- Пример с
vi.spyOn
:
import * as mod from './some-path.js';
const SomeClass = vi.fn();
SomeClass.prototype.someMethod = vi.fn();
vi.spyOn(mod, 'SomeClass').mockImplementation(SomeClass);
Шпионить за объектом, возвращаемым функцией
- Пример использования кеша:
// some-path.ts
export function useObject() {
return { method: () => true };
}
// useObject.js
import { useObject } from './some-path.js';
const obj = useObject();
obj.method();
// useObject.test.js
import { useObject } from './some-path.js';
vi.mock(import('./some-path.js'), () => {
let _cache;
const useObject = () => {
if (!_cache) {
_cache = {
method: vi.fn(),
};
}
// теперь каждый раз, когда вызывается useObject(), будет
// возвращать ту же ссылку на объект
return _cache;
};
return { useObject };
});
const obj = useObject();
// obj.method был вызван внутри some-path
expect(obj.method).toHaveBeenCalled();
Мокировать часть модуля
import { mocked, original } from './some-path.js';
vi.mock(import('./some-path.js'), async importOriginal => {
const mod = await importOriginal();
return {
...mod,
mocked: vi.fn(),
};
});
original(); // имеет оригинальное поведение
mocked(); // является функцией-шпионом
WARNING
Не забывайте, что это мокирует только внешний доступ. В этом примере, если original
вызывает mocked
внутренне, он всегда будет вызывать функцию, определенную в модуле, а не в фабрике моков.
Мокировать текущую дату
Чтобы мокировать время Date
, вы можете использовать вспомогательную функцию vi.setSystemTime
. Это значение не будет автоматически сбрасываться между запусками различных тестов.
Имейте в виду, что использование vi.useFakeTimers
также изменяет время Date
.
const mockDate = new Date(2022, 0, 1);
vi.setSystemTime(mockDate);
const now = new Date();
expect(now.valueOf()).toBe(mockDate.valueOf());
// сброс мокированного времени
vi.useRealTimers();
Мокировать глобальную переменную
Вы можете установить глобальную переменную, присвоив ей значение globalThis
или используя вспомогательную функцию vi.stubGlobal
. При использовании vi.stubGlobal
значение не будет автоматически сбрасываться между различными тестами, если вы не включите опцию конфигурации unstubGlobals
или не вызовете vi.unstubAllGlobals
.
vi.stubGlobal('__VERSION__', '1.0.0');
expect(__VERSION__).toBe('1.0.0');
Мокировать import.meta.env
- Чтобы изменить переменную окружения, вы можете просто присвоить ей новое значение.
WARNING
Значение переменной окружения не будет автоматически сбрасываться между запусками различных тестов.
import { beforeEach, expect, it } from 'vitest';
// вы можете сбросить значение в хуке beforeEach вручную
const originalViteEnv = import.meta.env.VITE_ENV;
beforeEach(() => {
import.meta.env.VITE_ENV = originalViteEnv;
});
it('changes value', () => {
import.meta.env.VITE_ENV = 'staging';
expect(import.meta.env.VITE_ENV).toBe('staging');
});
- Если вы хотите автоматически сбрасывать значения, вы можете использовать вспомогательную функцию
vi.stubEnv
с включенной опцией конфигурацииunstubEnvs
(или вручную вызватьvi.unstubAllEnvs
в хукеbeforeEach
):
import { expect, it, vi } from 'vitest';
// перед запуском тестов "VITE_ENV" имеет значение "test"
import.meta.env.VITE_ENV === 'test';
it('changes value', () => {
vi.stubEnv('VITE_ENV', 'staging');
expect(import.meta.env.VITE_ENV).toBe('staging');
});
it('значение восстанавливается перед запуском другого теста', () => {
expect(import.meta.env.VITE_ENV).toBe('test');
});
// vitest.config.ts
export default defineConfig({
test: {
unstubEnvs: true,
},
});