Node.js Internals
Testing Internals: Node.js Test Runner
В 2015 Google потратил **2 млн CPU-часов в день** на запуск тестов. Встроенный тест-раннер Node.js решает проблему по-другому: **ноль зависимостей, мгновенный старт, нативная интеграция с V8**. Никаких `npm install`, никаких конфликтов версий - просто `node --test`.
- **Vercel** перешёл с Jest на `node:test` в 2023 - **сократил время CI в 2 раза** (не нужно устанавливать 80 MB зависимостей)
- **Cloudflare Workers** использует встроенные тесты для edge-функций - они запускаются в изолированных V8 isolates, где нельзя использовать npm
- **Deno** изначально встроил тест-раннер - Node.js последовал этому примеру, чтобы конкурировать
Встроенный тест-раннер Node.js
С Node.js 18 появился **встроенный тест-раннер** - `node:test`. Это стабильный API с нулевыми зависимостями для запуска тестов.
**Зачем нужен встроенный раннер?** Jest/Mocha добавляют 50+ зависимостей в `node_modules`. Встроенный раннер работает из коробки - никаких `npm install`, никаких конфликтов версий.
История появления
- **Node.js 16.17.0** (2022) - первая экспериментальная версия `node:test`
- **Node.js 18.0.0** (2022) - стабилизация API, добавление `describe/it`
- **Node.js 20.0.0** (2023) - встроенное покрытие кода, `mock` API
- **Node.js 22.0.0** (2024) - `--test-reporter`, параллельные тесты
Сравнение с внешними библиотеками
| Параметр | node:test | Jest | Mocha + Chai |
|---|---|---|---|
| Зависимости | 0 | ~50 | ~20 |
| Размер установки | 0 MB | ~80 MB | ~40 MB |
| Время установки | 0 сек | ~30 сек | ~15 сек |
| Скорость запуска | Мгновенно | ~1-2 сек | ~0.5 сек |
| Mocking API | Встроен | Встроен | Требует Sinon |
| Coverage | V8 native | Istanbul | Требует nyc/c8 |
**Ограничения:** `node:test` не поддерживает snapshot-тестирование и автоматический mocking модулей (как `jest.mock`). Для этого нужны внешние библиотеки.
Почему встроенный тест-раннер быстрее стартует?
API тест-раннера: describe, it, hooks
API `node:test` напоминает Jest/Mocha, но с нюансами. Основные функции: `describe`, `it`, `test`, `before/after`, `beforeEach/afterEach`.
Базовая структура теста
Жизненный цикл тестов
**Порядок выполнения:** `before` → (`beforeEach` → `test` → `afterEach`) для каждого теста → `after`. Если тест упадёт, `afterEach` всё равно выполнится.
Модификаторы: only, skip, todo
Вложенные describe
**Разница с Jest:** В node:test нет автоматического hoisting для `beforeEach`. Они выполняются в порядке объявления. В Jest `beforeEach` всегда идут перед `it`, даже если написаны после.
Сколько раз выполнится `beforeEach` для 5 тестов?
Моки: функции, методы, таймеры, модули
**Mocking** - замена реальных зависимостей на контролируемые заглушки. Node.js предоставляет API для моков функций, методов объектов, таймеров и модулей.
mock.fn() - замена функций
mock.method() - замена методов объектов
mock.timers() - управление временем
Module mocking через register()
**Автоочистка моков:** В node:test все моки автоматически восстанавливаются после завершения теста. В Jest нужно вызывать `jest.clearAllMocks()` вручную.
**Ограничение:** `mock.module()` работает только с ES-модулями (`import/export`). Для CommonJS (`require`) нужно использовать `proxyquire` или аналоги.
Как проверить, что мок-функция была вызвана с определённым аргументом?
Покрытие кода: --experimental-test-coverage
**Code Coverage** - метрика, показывающая, какой процент кода был выполнен во время тестов. Node.js 20+ имеет встроенное покрытие через V8, без сторонних инструментов.
Запуск с покрытием
Типы покрытия
| Тип | Описание | Пример |
|---|---|---|
| **Line Coverage** | % выполненных строк | 5 из 10 строк = 50% |
| **Function Coverage** | % вызванных функций | 2 из 3 функций = 66% |
| **Branch Coverage** | % пройденных веток if/else | if/else: только if = 50% |
| **Statement Coverage** | % выполненных инструкций | Обычно ≈ Line Coverage |
Интеграция с c8
**Разница между встроенным покрытием и c8:** Встроенное (`--experimental-test-coverage`) выводит только процент. c8 создаёт подробные HTML-отчёты с подсветкой непокрытых строк.
Пример вывода покрытия
**100% покрытие ≠ отсутствие багов.** Можно покрыть все строки, но не проверить граничные случаи (null, пустые массивы, переполнение).
Какой тип покрытия показывает, что выполнены обе ветки if/else?
Стратегии тестирования: Unit, Integration, E2E
**Стратегия тестирования** определяет, что и как тестировать. Основные уровни: **Unit** (изолированные функции), **Integration** (взаимодействие модулей), **E2E** (полный сценарий пользователя).
Test Pyramid - пирамида тестов
**Правило пирамиды:** Чем выше уровень, тем медленнее и дороже тесты. 70% unit-тестов дают быстрый фидбек при разработке. 10% E2E покрывают критические сценарии.
Unit тесты - изоляция зависимостей
Integration тесты - реальная БД
E2E тесты - полный сценарий
TDD Workflow (Test-Driven Development)
- **Red:** Написать тест, который падает (функция ещё не реализована)
- **Green:** Написать минимальный код, чтобы тест прошёл
- **Refactor:** Улучшить код, не ломая тесты
**TDD - не серебряная пуля.** Для прототипов и экспериментов TDD замедляет. Используй для критичных модулей (платежи, авторизация).
Нужно стремиться к 100% покрытию кода тестами
80% покрытия + тесты на критичные сценарии лучше, чем 100% формального покрытия
Последние 20% часто покрывают тривиальный код (геттеры, логгеры). Время лучше потратить на E2E-тесты критичных flow (регистрация, оплата). 100% покрытия не гарантирует отсутствие багов - важнее качество проверок, а не количество строк.
Почему unit-тестов должно быть больше, чем E2E?
Ключевые идеи
- **node:test** (Node.js 18+) - тест-раннер с нулевыми зависимостями, API как у Jest/Mocha
- **Моки:** `mock.fn()` для функций, `mock.method()` для объектов, `mock.timers` для setTimeout/setInterval
- **Coverage:** Встроенное V8-покрытие через `--experimental-test-coverage`, c8 для HTML-отчётов
- **Test Pyramid:** 70% unit (быстро), 20% integration (реальная БД), 10% E2E (полные сценарии)
- **TDD workflow:** Red (упавший тест) → Green (минимальная реализация) → Refactor (улучшение кода)
Связанные темы
Тестирование пересекается с внутренностями Node.js:
- V8 Isolates — node:test запускает каждый файл в отдельном контексте через vm.Module (как в Cloudflare Workers)
- Module System — mock.module() использует import.meta.resolve и loader hooks для подмены модулей
- Event Loop — mock.timers перехватывает C++ биндинги uv_timer_start для управления временем
Вопросы для размышления
- Почему Google тратит миллионы CPU-часов на тесты? Какую проблему это решает в масштабе монорепозитория на 2 млрд строк кода?
- В каких случаях 100% покрытие кода вредит проекту? Когда лучше написать один E2E-тест вместо 10 unit-тестов?
- Как mock.timers реализован внутри? Что происходит с event loop'ом Node, когда вызываешь mock.timers.tick(1000)?