Node.js Internals
Testing Internals: Node.js Test Runner
In 2015, Google spent **2 million CPU hours per day** running tests. The built-in Node.js test runner solves the problem differently: **zero dependencies, instant start, native integration with V8**. No `npm install`, no version conflicts - just `node --test`.
- **Vercel** switched from Jest to `node:test` in 2023 - **halved CI time** (no need to install 80 MB of dependencies)
- **Cloudflare Workers** uses built-in tests for edge functions - they run in isolated V8 isolates, where npm cannot be used.
- **Deno** initially built in a test runner - Node.js followed this example to compete
Built-in Node.js test runner
With Node.js 18, a **built-in test runner** - `node:test` - was introduced. It is a stable API with zero dependencies for running tests.
**Why do you need a built-in runner?** Jest/Mocha add 50+ dependencies to `node_modules`. The built-in runner works out of the box - no `npm install`, no version conflicts.
How it came to be
- **Node.js 16.17.0** (2022) - the first experimental version of `node:test`
- **Node.js 18.0.0** (2022) - API stabilization, addition of `describe/it`
- **Node.js 20.0.0** (2023) - built-in code coverage, `mock` API
- **Node.js 22.0.0** (2024) - `--test-reporter`, parallel tests
Comparison with external libraries
| Parameter | node:test | Jest | Mocha + Chai |
|---|---|---|---|
| Dependencies | 0 | ~50 | ~20 |
| Installation size | 0 MB | ~80 MB | ~40 MB |
| Installation time | 0 sec | ~30 sec | ~15 sec |
| Launch speed | Instantly | ~1-2 sec | ~0.5 sec |
| Mocking API | Built-in | Built-in | Requires Sinon |
| Coverage | V8 native | Istanbul | Requires nyc/c8 |
**Limitations:** `node:test` does not support snapshot testing and automatic module mocking (like `jest.mock`). External libraries are needed for this.
Why does the built-in test runner start faster?
API test runner: describe, it, hooks
The `node:test` API resembles Jest/Mocha, but with nuances. Main functions: `describe`, `it`, `test`, `before/after`, `beforeEach/afterEach`.
Basic test structure
Test lifecycle
**Execution order:** `before` → (`beforeEach` → `test` → `afterEach`) for each test → `after`. If a test fails, `afterEach` will still execute.
Modifiers: only, skip, todo
Nested describe blocks
**Difference with Jest:** In node:test, there is no automatic hoisting for `beforeEach`. They are executed in the order they are declared. In Jest, `beforeEach` always runs before `it`, even if written after.
How many times will `beforeEach` be executed for 5 tests?
Mocks: functions, methods, timers, modules
**Mocking** - replacing real dependencies with controlled stubs. Node.js provides an API for mocking functions, object methods, timers, and modules.
mock.fn() - replacing functions
mock.method() - replacing object methods
mock.timers() - controlling time
Module mocking via register()
**Auto-cleanup of mocks:** In node:test, all mocks are automatically restored after the test is completed. In Jest, you need to call `jest.clearAllMocks()` manually.
**Limitation:** `mock.module()` works only with ES modules (`import/export`). For CommonJS (`require`), you need to use `proxyquire` or similar alternatives.
How to check that a mock function was called with a specific argument?
Code coverage: --experimental-test-coverage
**Code Coverage** - a metric that shows what percentage of code was executed during tests. Node.js 20+ has built-in coverage through V8, without third-party tools.
Running with coverage
Coverage types
| Type | Description | Example |
|---|---|---|
| **Line Coverage** | % of completed lines | 5 out of 10 lines = 50% |
| **Function Coverage** | % of called functions | 2 out of 3 functions = 66% |
| **Branch Coverage** | % of if/else branches covered | if/else: only if = 50% |
| **Statement Coverage** | % of instructions completed | Usually ≈ Line Coverage |
Integration with c8
**Difference between built-in coverage and c8:** Built-in (`--experimental-test-coverage`) only outputs the percentage. c8 creates detailed HTML reports with highlighting of uncovered lines.
Example coverage output
**100% coverage ≠ no bugs.** You can cover all lines, but not check edge cases (null, empty arrays, overflow).
What type of coverage shows that both branches of if/else have been executed?
Testing Strategies: Unit, Integration, E2E
**Testing Strategy** defines what and how to test. Main levels: **Unit** (isolated functions), **Integration** (module interactions), **E2E** (full user scenario).
The Test Pyramid
**Pyramid Rule:** The higher the level, the slower and more expensive the tests. 70% unit tests provide quick feedback during development. 10% E2E cover critical scenarios.
Unit tests - isolating dependencies
Integration tests - against a real database
E2E tests - the full scenario
TDD Workflow (Test-Driven Development)
- **Red:** Write a test that fails (the function is not yet implemented)
- **Green:** Write the minimal code to pass the test
- **Refactor:** Improve the code without breaking the tests
**TDD is not a silver bullet.** For prototypes and experiments, TDD slows down. Use it for critical modules (payments, authorization).
One should strive for 100% code coverage with tests.
80% coverage + tests for critical scenarios are better than 100% formal coverage
The last 20% often cover trivial code (getters, loggers). It's better to spend time on E2E tests for critical flows (registration, payment). 100% coverage does not guarantee the absence of bugs - the quality of checks is more important than the number of lines.
Why should there be more unit tests than E2E?
Key Ideas
- **node:test** (Node.js 18+) - a zero-dependency test runner with an API similar to Jest/Mocha
- **Mocks:** `mock.fn()` for functions, `mock.method()` for objects, `mock.timers` for setTimeout/setInterval
- **Coverage:** Built-in V8 coverage via `--experimental-test-coverage`, c8 for HTML reports
- **Test Pyramid:** 70% unit (fast), 20% integration (real DB), 10% E2E (full scenarios)
- **TDD workflow:** Red (failing test) → Green (minimal implementation) → Refactor (code improvement)
Related topics
Testing intersects with the internals of Node.js:
- V8 Isolates — node:test runs each file in a separate context via vm.Module (as in Cloudflare Workers)
- Module System — `mock.module()` uses `import.meta.resolve` and loader hooks to substitute modules
- Event Loop — mock.timers intercepts the C++ bindings of uv_timer_start to control time
Вопросы для размышления
- Why does Google spend millions of CPU hours on tests? What problem does this solve on the scale of a monorepository with 2 billion lines of code?
- In what cases does 100% code coverage harm the project? When is it better to write one E2E test instead of 10 unit tests?
- How is mock.timers implemented internally? What happens with Node's event loop when you call mock.timers.tick(1000)?