Фундаментом надежной архитектуры любого масштабируемого приложения является отделение чистой вычислительной бизнес-логики от механизмов ввода-вывода (I/O), сетевых протоколов и состояния пользовательского интерфейса. В функциональном программировании и чистой архитектуре (Clean Architecture) ядро системы проектируется на базе чистых функций (Pure Functions).
Свойства чистых функций делают их идеальным объектом для автоматизированного тестирования:
Date.now(), генераторов псевдослучайных чисел Math.random(), глобальных синглтонов).Главный вызов при тестировании бизнес-логики — проверка множества краевых условий (Edge Cases). Написание отдельного блока it(...) для каждого из 20 вариантов входных данных приводит к разрастанию бойлерплейта и снижению читаемости тестов. Для решения этой проблемы в Vitest применяется механизм параметризованного тестирования — test.each.
Параметризованное тестирование трансформирует проверку алгоритма из набора повторяющихся императивных тестов в декларативную матрицу соответствия «Входные данные → Ожидаемый результат»:
┌────────────────────────────────────────────────────────┐
│ Матрица тестовых сценариев (Data Table) │
├─────────────────────────┬──────────────────────────────┤
│ Входные аргументы │ Ожидаемый результат / Ошибка │
├─────────────────────────┼──────────────────────────────┤
│ ("[email protected]") │ true │
│ ("invalid-email") │ false │
│ ("") │ false │
│ (" ") │ false │
│ (null as unknown) │ throws TypeError │
└─────────────────────────┴──────────────────────────────┘
│
▼ Автоматическая итерация test.each
┌────────────────────────────────────────────────────────┐
│ Vitest Runner: N изолированных тестов с выводом аргументов│
│ ✓ validateEmail("[email protected]") -> true │
│ ✓ validateEmail("invalid-email") -> false │
│ ✓ validateEmail("") -> false │
│ ✓ validateEmail(" ") -> false │
│ ✓ validateEmail(null) -> throws TypeError │
└────────────────────────────────────────────────────────┘Каждая строка таблицы данных выполняется тестовым раннером как отдельный, независимый тест. Если одно из граничных значений приводит к ошибке, падает ровно один кейс с подробной информацией о несовпавших параметрах, не блокируя остальные проверки.
Разбиение входного пространства аргументов на классы эквивалентности (корректные данные, граничные значения, некорректные типы).
Оформление матрицы сценариев в виде массива кортежей или шаблонной строки (Tagged Template) с именованными колонками.
Использование спецификаторов подстановки (%s, %i, %o, $name) в заголовке теста для наглядного отображения аргументов в логах CI/CD.
Вызов тестируемой чистой функции и проверка результата через expect(actual).toBe(expected) в единой строке.
Какое главное архитектурное преимущество дает использование test.each по сравнению с запуском цикла for внутри одного теста it()?
0.1 + 0.2 !== 0.3).В Vitest доступно два основных синтаксиса для параметризации:
Удобен для сложных объектов, функций и регулярных выражений:
test.each([
['[email protected]', true],
['invalid-mail', false],
['@domain.com', false],
])('validateEmail("%s") возвращает %s', (input, expected) => {
expect(validateEmail(input)).toBe(expected);
});Спецификаторы форматирования в строке имени теста:
%s — строка;%i или %d — целое число;%f — число с плавающей точкой;%j или %o — JSON/объект;%# — порядковый индекс кейса (начиная с 0).Идеально читается человеком, так как структурирована в виде таблицы с заголовками колонок:
test.each`
email | expected
${'[email protected]'} | ${true}
${'[email protected]'} | ${true}
${'no-at-sign'} | ${false}
${''} | ${false}
`('email "$email" -> $expected', ({ email, expected }) => {
expect(validateEmail(email)).toBe(expected);
});В финансовых приложениях хранение и расчет денежных сумм в виде вещественных чисел number с плавающей точкой (0.1, 0.2) категорически запрещено из-за неточностей представления стандарта IEEE 754:
В стандартном JavaScript:
0.1 + 0.2 === 0.30000000000000004 // Ошибка округления в продакшене!Все вычисления должны проводиться в минимальных неделимых единицах (центах, копейках) с использованием целых чисел.
export interface TaxCalculationResult {
readonly netAmountInCents: number;
readonly taxAmountInCents: number;
readonly grossAmountInCents: number;
}
export function calculateGrossWithTax(
netAmountInCents: number,
taxRatePercent: number
): TaxCalculationResult {
if (!Number.isSafeInteger(netAmountInCents) || netAmountInCents < 0) {
throw new TypeError('Чистая сумма должна быть неотрицательным безопасным целым числом');
}
if (!Number.isFinite(taxRatePercent) || taxRatePercent < 0 || taxRatePercent > 100) {
throw new RangeError('Налоговая ставка должна быть числом от 0 до 100%');
}
// Расчет налога с математическим округлением до ближайшего цента
const rawTax = (netAmountInCents * taxRatePercent) / 100;
const taxAmountInCents = Math.round(rawTax);
const grossAmountInCents = netAmountInCents + taxAmountInCents;
if (!Number.isSafeInteger(grossAmountInCents)) {
throw new OverflowError('Превышен безопасный лимит целочисленных вычислений');
}
return {
netAmountInCents,
taxAmountInCents,
grossAmountInCents,
};
}
export class OverflowError extends Error {
constructor(message: string) {
super(message);
this.name = 'OverflowError';
}
}Создадим тестовую спецификацию, проверяющую краевые случаи, округление и ошибки переполнения:
import { describe, it, expect } from 'vitest';
import { calculateGrossWithTax, OverflowError } from './moneyCalculator';
describe('calculateGrossWithTax (Чистые финансовые расчеты)', () => {
describe('Параметризованная проверка корректных расчетов', () => {
// Таблица тестов с проверкой округления центов
test.each`
netInCents | taxRate | expectedTax | expectedGross | description
${10000} | ${20} | ${2000} | ${12000} | ${'Стандартная ставка 20% для 100 рублей'}
${1000} | ${0} | ${0} | ${1000} | ${'Нулевая налоговая ставка'}
${0} | ${15} | ${0} | ${0} | ${'Нулевая сумма заказа'}
${99} | ${10} | ${10} | ${109} | ${'Округление 9.9 центов вверх до 10'}
${105} | ${10} | ${11} | ${116} | ${'Округление 10.5 центов вверх до 11'}
${104} | ${10} | ${10} | ${114} | ${'Округление 10.4 центов вниз до 10'}
${3333} | ${7.5} | ${250} | ${3583} | ${'Дробная налоговая ставка 7.5% (249.975 -> 250)'}
`(
'$description: net=$netInCents + tax($taxRate%) = gross($expectedGross)',
({ netInCents, taxRate, expectedTax, expectedGross }) => {
const result = calculateGrossWithTax(netInCents, taxRate);
expect(result.netAmountInCents).toBe(netInCents);
expect(result.taxAmountInCents).toBe(expectedTax);
expect(result.grossAmountInCents).toBe(expectedGross);
}
);
});
describe('Проверка граничных значений и исключений (Edge Cases)', () => {
test.each([
[-1, 20, TypeError, 'отрицательная сумма net'],
[100.5, 20, TypeError, 'дробная сумма центов (не целое число)'],
[Number.NaN, 20, TypeError, 'NaN в сумме'],
[Number.POSITIVE_INFINITY, 20, TypeError, 'бесконечность в сумме'],
[1000, -5, RangeError, 'отрицательная ставка налога'],
[1000, 105, RangeError, 'ставка налога свыше 100%'],
])(
'выбрасывает %s при некорректных аргументах (%s, %s): %s',
(net, tax, expectedErrorClass) => {
expect(() => calculateGrossWithTax(net as number, tax as number)).toThrow(
expectedErrorClass
);
}
);
it('корректно обрабатывает предельно допустимое безопасное число JavaScript', () => {
// 9007199254740991 центов при 0% налоге должно успешно пройти
const maxSafe = Number.MAX_SAFE_INTEGER;
const result = calculateGrossWithTax(maxSafe, 0);
expect(result.grossAmountInCents).toBe(maxSafe);
});
it('выбрасывает OverflowError при превышении MAX_SAFE_INTEGER после начисления налога', () => {
const nearMax = Number.MAX_SAFE_INTEGER - 100;
expect(() => calculateGrossWithTax(nearMax, 50)).toThrow(OverflowError);
});
});
});Парсинг кортежей или табличной шаблонной строки в массив объектов с типизированными полями.
Происходит на этапе сбора тестов без выполнения раннера.
Регистрация N виртуальных тестов в дереве тестового набора с интерполированными именами.
Каждый тест получает уникальный ID и регистрируется как отдельный узел репортера.
Поочередный или параллельный прогон каждого кейса с передачей распакованных аргументов в тестовую функцию.
При ошибке в одной итерации остальные продолжают выполняться в штатном режиме.
Генерация отчета с подсветкой конкретной упавшей строки матрицы и цветовым diff ожидаемого/фактического значений.
Обеспечивает моментальное понимание того, на каких данных сломался алгоритм.
Какой спецификатор форматирования в строке заголовка test.each([ ... ]) следует использовать для наглядного отображения объектов и словарей в логах Vitest?
Использование обычных дробных чисел (19.99 * 1.2) в JavaScript приводит к неточностям двоичного представления мантиссы.
// ❌ Ошибка: 19.99 * 1.2 = 23.988000000000003
const tax = price * 0.2;
// ✅ Правильное решение: все операции производить в неделимых целых единицах (центах)
// с округлением Math.round() в самом конце вычислений.
const taxInCents = Math.round((priceInCents * taxPercent) / 100);Тестирование функции только на корректных валидных строках без проверки null, undefined, пустой строки "", строк только из пробелов " " и строк с управляющими символами \n\t.
// ❌ Недостаточно: проверка только позитивных данных
test.each(['[email protected]', '[email protected]'])(...);
// ✅ Полный охват: обязательно включать пустые и пробельные строки
test.each([
['', false],
[' ', false],
['\t\n', false],
['user@', false],
['@domain.com', false],
])(...);При использовании нетипизированного массива аргументов TypeScript выводит тип параметров как any или общий union, что лишает тест проверки типов при рефакторинге сигнатуры функции.
// ❌ Слабая типизация
test.each([
[100, '20', true], // '20' передана строкой по ошибке
])('кейс', (a, b, c) => { ... });
// ✅ Строгая типизация через явный интерфейс или кортеж
interface TestCase {
readonly net: number;
readonly rate: number;
readonly expected: number;
}
const cases: readonly TestCase[] = [
{ net: 100, rate: 20, expected: 120 },
];
test.each(cases)('net: $net, rate: $rate', ({ net, rate, expected }) => { ... });Если валидатор или калькулятор вызывает new Date() или Date.now() внутри себя без возможности передачи фиксированной временной метки, тест будет недетерминированным и упадет в новогоднюю ночь или при смене високосного года.
// ❌ Нечистая функция: скрытая зависимость от системных часов
export function isLicenseExpired(license: License): boolean {
return license.expiresAt < Date.now();
}
// ✅ Чистая функция: время передается явно (или с дефолтным параметром)
export function isLicenseExpired(license: License, now: number = Date.now()): boolean {
return license.expiresAt < now;
}