Чистий тест володіє трьома фундаментальними характеристиками: він детермінований, ізольований від побічних ефектів і перевіряє спостережувану поведінку, а не деталі внутрішньої реалізації. Організаційним каркасом будь-якого надійного автоматизованого тесту є патерн 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) |
+---------+ +---------+ +---------+ +---------+ +---------+crypto.randomUUID, performance.now, Date.now) через шпигуни vi.spyOn().vi.hoisted() та vi.mock() для модулів із побічними ефектами під час імпорту.vi.fn() без надсилання реальних мережевих пакетів.Розглянемо сервіс переказу коштів 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 аналізуються та компілюються до виконання будь-якого синхронного коду модуля. Компілятор Vitest автоматично піднімає (hoisting) виклики vi.mock() у самий початок файлу. Якщо всередині фабрики vi.mock() звертатися до звичайних локальних змінних (const mockFn = vi.fn()), виникне ReferenceError. Для розв'язання цієї проблеми створено утиліту 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 автоматично піднімається вище за імпорт бібліотеки axios нижче
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());
});
});Коли тест вимагає 15 рядків налаштування моків для супутніх сервісів і контекстів, він тестує не поведінку застосунку, а власні припущення автора. При зміні сигнатури реального API такий тест залишиться зеленим, пропустивши критичний баг (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() або ланцюжок .then() впаде з помилкою або розсинхронізується з 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 }));Якщо об'єкт передається суто для задоволення сигнатури інтерфейсу і не викликається — створіть Dummy.
Якщо тестований код тільки читає дані (конфігурацію, прапорці) без змін стану — створіть Stub (vi.fn().mockReturnValue).
Якщо необхідно підтвердити надсилання метрики чи виклик логера без зміни його функціонування — застосуйте Spy (vi.spyOn).
Якщо залежність виконує CRUD-операції над сутностями — напишіть швидкий InMemory Fake клас.