Чистый тест обладает тремя фундаментальными характеристиками: он детерминирован, изолирован от побочных эффектов и проверяет наблюдаемое поведение, а не детали внутренней реализации. Организующей структурой любого надежного теста является паттерн AAA (Arrange, Act, Assert).
При тестировании модулей, зависящих от внешних систем (базы данных, сетевые сокеты, аппаратные API, сервисы авторизации), возникает необходимость изоляции. Для этого используется обобщенная концепция Test Double (Тестовый дублер).
Классическая таксономия Джерарда Месароша (xUnit Test Patterns) выделяет пять четко разграниченных типов тестовых дублеров:
InMemoryRepository на базе Map вместо реальной базы данных PostgreSQL).+----------------------------------------+
| Test Double (Месарош) |
+----------------------------------------+
|
+-------------+-----------+-----------+-------------+
| | | | |
v v v v v
+---------+ +---------+ +---------+ +---------+ +---------+
| Dummy | | Stub | | Spy | | Mock | | Fake |
| (Типы) | | (Ответы)| |(Аудит) | |(Ожидания| |(In-Mem) |
+---------+ +---------+ +---------+ +---------+ +---------+Если объект передается только для удовлетворения интерфейса и не используется в коде — создать Dummy.
Если тестируемый код только читает данные (конфигурацию, флаги фичей) — создать Stub (vi.fn().mockReturnValue).
Если необходимо подтвердить отправку метрики или вызов внешнего логгера без изменения его работы — применить Spy (vi.spyOn).
Если зависимость выполняет CRUD-операции над состоянием — написать легковесный InMemory Fake класс.
В чем фундаментальное различие между Fake и Mock согласно классификации Джерарда Месароша?
crypto.randomUUID, performance.now) через шпионы vi.spyOn().vi.hoisted() и vi.mock() для модулей с побочными эффектами при импорте.vi.fn() без отправки реальных HTTP-пакетов.Рассмотрим сервис перевода денежных средств TransferService. Мы применим все пять типов дублеров без применения небезопасного приведения типов any.
import { describe, it, expect, vi } from 'vitest';
export interface UserAccount {
id: string;
email: string;
balanceCents: number;
}
export interface AccountRepository {
findById(id: string): Promise<UserAccount | null>;
save(account: UserAccount): Promise<void>;
}
export interface AuditLogger {
logTransfer(fromId: string, toId: string, amount: number): void;
}
export interface NotificationService {
notifyUser(email: string, message: string): Promise<void>;
}
// Бизнес-логика сервиса
export class TransferService {
constructor(
private readonly accountRepo: AccountRepository,
private readonly logger: AuditLogger,
private readonly notifier: NotificationService
) {}
async execute(fromId: string, toId: string, amountCents: number): Promise<void> {
if (amountCents <= 0) {
throw new Error('Сумма перевода должна быть положительной');
}
const sender = await this.accountRepo.findById(fromId);
const recipient = await this.accountRepo.findById(toId);
if (!sender || !recipient) {
throw new Error('Один из счетов не найден');
}
if (sender.balanceCents < amountCents) {
throw new Error('Недостаточно средств на счете отправителя');
}
sender.balanceCents -= amountCents;
recipient.balanceCents += amountCents;
await this.accountRepo.save(sender);
await this.accountRepo.save(recipient);
this.logger.logTransfer(fromId, toId, amountCents);
}
}
// 1. FAKE: Полноценная in-memory имплементация репозитория
class InMemoryAccountRepository implements AccountRepository {
private readonly store = new Map<string, UserAccount>();
seed(account: UserAccount): void {
this.store.set(account.id, { ...account });
}
async findById(id: string): Promise<UserAccount | null> {
const acc = this.store.get(id);
return acc ? { ...acc } : null;
}
async save(account: UserAccount): Promise<void> {
this.store.set(account.id, { ...account });
}
}
describe('TransferService (Анатомия чистых тестов с Test Doubles)', () => {
it('успешно переводит средства между счетами по паттерну AAA', async () => {
// ---------------- Arrange ----------------
const fakeRepo = new InMemoryAccountRepository();
fakeRepo.seed({ id: 'acc-1', email: '[email protected]', balanceCents: 10_000 });
fakeRepo.seed({ id: 'acc-2', email: '[email protected]', balanceCents: 2_000 });
// 2. SPY: Пассивный сборщик вызовов для аудита
const spyLogger: AuditLogger = {
logTransfer: vi.fn(),
};
// 3. DUMMY: Объект, необходимый сигнатуре конструктора, но не участвующий в логике
const dummyNotifier: NotificationService = {
notifyUser: vi.fn(),
};
const service = new TransferService(fakeRepo, spyLogger, dummyNotifier);
// ---------------- Act ----------------
await service.execute('acc-1', 'acc-2', 3_000);
// ---------------- Assert ----------------
// 1. State Verification (проверяем конечные данные в репозитории)
const updatedSender = await fakeRepo.findById('acc-1');
const updatedRecipient = await fakeRepo.findById('acc-2');
expect(updatedSender?.balanceCents).toBe(7_000);
expect(updatedRecipient?.balanceCents).toBe(5_000);
// 2. Behavior Verification (проверяем факт аудита через Spy)
expect(spyLogger.logTransfer).toHaveBeenCalledTimes(1);
expect(spyLogger.logTransfer).toHaveBeenCalledWith('acc-1', 'acc-2', 3_000);
// 3. Проверяем, что Dummy действительно не был вызван
expect(dummyNotifier.notifyUser).not.toHaveBeenCalled();
});
it('выбрасывает ошибку при нехватке баланса (Arrange-Act-Assert)', async () => {
// Arrange
const fakeRepo = new InMemoryAccountRepository();
fakeRepo.seed({ id: 'acc-1', email: '[email protected]', balanceCents: 1_000 });
fakeRepo.seed({ id: 'acc-2', email: '[email protected]', balanceCents: 500 });
const dummyLogger: AuditLogger = { logTransfer: vi.fn() };
const dummyNotifier: NotificationService = { notifyUser: vi.fn() };
const service = new TransferService(fakeRepo, dummyLogger, dummyNotifier);
// Act & Assert
await expect(service.execute('acc-1', 'acc-2', 5_000)).rejects.toThrow(
'Недостаточно средств на счете отправителя'
);
// Баланс не должен измениться
const sender = await fakeRepo.findById('acc-1');
expect(sender?.balanceCents).toBe(1_000);
});
});vi.hoisted()#В спецификации ECMAScript статические import выражения компилируются до выполнения любого синхронного кода файла. Метод vi.mock() автоматически поднимается (hoisted) в начало файла компилятором Vitest. Если внутри фабрики vi.mock() необходимо использовать внешние переменные, обычные объявления (const mockFn = vi.fn()) вызовут ReferenceError. Для этого Vitest предоставляет метод vi.hoisted().
import { describe, it, expect, vi } from 'vitest';
// Инициализация переменных, доступных внутри фабрики vi.mock()
const { mockedPost, fakeAuthHeader } = vi.hoisted(() => {
return {
mockedPost: vi.fn(),
fakeAuthHeader: 'Bearer hoisted-token-xyz',
};
});
// vi.mock автоматически поднимается выше импорта модуля ниже
vi.mock('axios', () => {
return {
default: {
post: mockedPost,
defaults: {
headers: {
common: { Authorization: fakeAuthHeader },
},
},
},
};
});
import axios from 'axios';
import { dispatchSecurityAlert } from './alertService';
describe('dispatchSecurityAlert с hoisted-моками', () => {
it('отправляет инцидент через замоканный транспорт axios', async () => {
mockedPost.mockResolvedValueOnce({ status: 200, data: { status: 'acknowledged' } });
const result = await dispatchSecurityAlert('UNAUTHORIZED_ACCESS_ATTEMPT');
expect(mockedPost).toHaveBeenCalledWith(
'/api/v1/security/incidents',
expect.objectContaining({ incidentType: 'UNAUTHORIZED_ACCESS_ATTEMPT' })
);
expect(result).toBe(true);
});
});vi.spyOn() и безопасный сброс#Шпионы (vi.spyOn) позволяют наблюдать за вызовами уже существующих методов объектов без полной замены самого объекта, с возможностью отслеживания нативного поведения (mockImplementation) и последующего восстановления исходного метода (mockRestore).
import { describe, it, expect, vi, afterEach } from 'vitest';
function getSessionExpiryIsoString(durationMinutes: number): string {
const expiryTime = Date.now() + durationMinutes * 60 * 1000;
return new Date(expiryTime).toISOString();
}
describe('getSessionExpiryIsoString', () => {
afterEach(() => {
// Восстанавливаем оригинальные методы после каждого теста
vi.restoreAllMocks();
});
it('рассчитывает время истечения относительно фиксированной временной метки', () => {
// Замораживаем Date.now с помощью spyOn
const fixedNow = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z
vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
const result = getSessionExpiryIsoString(30);
// 1_700_000_000_000 + 30 * 60 * 1000 = 1_700_001_800_000
expect(result).toBe(new Date(1_700_001_800_000).toISOString());
});
});Почему попытка использовать внешнюю переменную напрямую внутри vi.mock('./module', () => ({ ... })) приводит к ReferenceError в нативной среде ESM?
Когда тест содержит 15 строк мокирования вспомогательных сервисов, баз данных и контекстов, он тестирует не систему, а фантазию разработчика о системе. При изменении сигнатуры реального сервиса тест продолжает радостно проходить зеленым (False Negative).
// ❌ Антипаттерн: тест проверяет только то, что моки вызывают другие моки
it('обрабатывает пользователя', async () => {
const db = { query: vi.fn().mockReturnValue([{ id: 1 }]) };
const validator = { validate: vi.fn().mockReturnValue(true) };
const logger = { log: vi.fn() };
// Код оторван от реального исполнения
});
// ✅ Правильное решение: использование Fake-реализаций домена и интеграционные тесты
const fakeDb = new InMemoryDatabase();
const service = new UserService(fakeDb); // Проверяем реальную бизнес-логикуЕсли асинхронный метод замокан синхронным mockReturnValue(data), код, ожидающий промис (await service.getData()), упадет с труднодиагностируемой ошибкой либо поведение промисов рассинхронизируется с Event Loop.
// ❌ Ошибка: асинхронный метод возвращает обычное значение вместо Promise
const fetchUser = vi.fn().mockReturnValue({ id: 'u1' });
// В рантайме: await fetchUser() сработает, но fetchUser().then(...) выбросит TypeError!
// ✅ Правильное решение: явное указание возврата Promise
const fetchUser = vi.fn().mockResolvedValue({ id: 'u1' });Если экземпляр vi.fn() объявлен в корневом скоупе файла и не очищается перед каждым it(...), счетчик toHaveBeenCalledTimes будет накапливаться от теста к тесту.
// ❌ Ошибка: счетчики вызовов накапливаются
const globalReporter = vi.fn();
it('первый запуск', () => {
globalReporter();
expect(globalReporter).toHaveBeenCalledTimes(1); // OK
});
it('второй запуск', () => {
globalReporter();
expect(globalReporter).toHaveBeenCalledTimes(1); // Упадет! Вызовов уже 2
});
// ✅ Правильное решение: автоматическая очистка в afterEach
afterEach(() => {
vi.clearAllMocks(); // Очищает историю вызовов
});Передача локальной переменной в фабрику vi.mock приводит к ошибке ReferenceError: Cannot access 'mockService' before initialization, так как вызов vi.mock физически поднимается компилятором выше этой переменной.
// ❌ Ошибка компиляции: mockService еще не инициализирован в момент работы vi.mock
const mockService = vi.fn();
vi.mock('./myModule', () => ({ service: mockService }));
// ✅ Правильное решение: объявление через vi.hoisted()
const { mockService } = vi.hoisted(() => ({ mockService: vi.fn() }));
vi.mock('./myModule', () => ({ service: mockService }));