A test is a small, repeatable claim about what your code should do, checked automatically instead of trusted to memory. That sounds almost too simple to need explaining, but nearly every confusing testing decision in a Node.js project - how many integration tests versus unit tests, whether to mock a dependency or spin up a real database, whether Vitest or node:test is the right runner - traces back to trade-offs hiding inside that one-sentence definition.
Testing Basics gets a suite running with working node:test and Supertest examples; the tool-specific pages that follow (node:test, Vitest, Testcontainers, Supertest, Contract/Pact) each go deep on one layer. This page stays one level up: what a test is actually verifying, why the pyramid has the shape it has, and how the pieces - runner, assertions, doubles - relate to each other underneath any specific tool.
A test suite is a portfolio of automated behavioral claims, deliberately layered so that fast, narrow checks catch most bugs and slower, broader checks catch the rest.
Insight: Every testing decision is really a trade-off between confidence (how much of reality a test reflects) and feedback speed (how fast it tells you something's wrong) - understanding that trade-off explains why the pyramid, not a cube or a single layer, is the shape that works.
Key Concepts:test runner, assertion library, test double, determinism, flakiness, coverage.
When to Use: Deciding how many integration tests a feature actually needs, choosing between a mock and a real dependency, diagnosing a flaky suite, or explaining to a team why 100% coverage isn't the goal.
Limitations/Trade-offs: No test suite proves correctness - it proves the specific behaviors someone thought to check still hold. Untested paths remain exactly as risky as if no tests existed at all.
Related Topics: unit vs. integration vs. contract vs. end-to-end testing, mocking and dependency injection, test isolation, continuous integration gates.
Strip away the tooling and a test is three things happening in sequence: some code runs (the subject), the result is compared against an expectation (the assertion), and the outcome is reported as pass or fail without a human watching. Every testing tool in the Node ecosystem, from the built-in node:test module to Vitest to Jest, is ultimately just infrastructure around that same three-step shape.
That infrastructure actually breaks into two separable roles that are easy to conflate because most tools bundle both. A test runner discovers test files, executes them, isolates failures, and reports results - it answers "which tests ran, and did they pass." An assertion library provides the comparison functions inside a test (assert.equal, expect(x).toBe(y)) - it answers "was this specific claim true." Node's own node:test pairs with node:assert; Vitest and Jest bundle a runner with an expect-style assertion API of their own. Knowing these are different concerns explains why you can mix and match - using node:assert inside a Vitest-run file works because the runner doesn't care which assertion style you use.
A useful analogy: think of a test suite as a portfolio of small, independent bets about your code's behavior, each one cheap to place and cheap to check. No single bet proves the whole system works - together, a well-chosen set of them makes it very unlikely something important is silently broken.
// The three-step shape, regardless of which tool wraps itconst result = calculateTax(100, 0.2); // subject runsassert.equal(result, 20); // assertion compares// runner reports pass/fail - no human watched it happen
The testing pyramid - many unit tests, fewer integration tests, still fewer contract tests, a handful of end-to-end tests - isn't a stylistic preference; it falls directly out of a trade-off between two things every test has: confidence and feedback speed.
A unit test that calls a pure function directly is fast (milliseconds) but narrow - it proves that one function behaves correctly in isolation, saying nothing about whether it's wired up correctly to the database, the HTTP layer, or another service. An end-to-end test that hits a real deployed API proves the whole system works together, but it's slow, expensive to run, and touches enough moving parts that a failure could be caused by almost anything. The pyramid shape - many of the fast, narrow tests and few of the slow, broad ones - is the mathematically sensible way to maximize confidence per second of feedback time: catch the bulk of bugs cheaply at the base, and reserve the expensive, systemic checks for the things only they can prove.
Test doubles are the mechanism that lets you keep a test narrow on purpose, replacing a real dependency with a stand-in so the test isolates just the logic it's checking:
A stub returns a canned response, no logic - useful for controlling what a dependency "says" without caring how it's called.
A mock additionally records and verifies how it was called - useful when the interaction itself (was this function called exactly once, with these arguments) is the thing under test.
A fake is a working but simplified implementation - an in-memory repository standing in for a real database - useful when you want real behavior without real infrastructure.
Reaching for a double versus a real dependency is itself a confidence/speed trade-off in miniature: a stubbed database call runs in microseconds but can't catch a real SQL syntax error; a real Postgres instance via Testcontainers catches that error but costs seconds of container startup per suite run. Neither choice is universally correct - it depends on what that specific test is trying to prove.
// A stub: canned response, no verification of how it was calledconst repo = { findById: async () => ({ id: "1", status: "paid" }) };// A mock: same shape, but the test can assert on the call itselfconst repo = { findById: vi.fn().mockResolvedValue({ id: "1", status: "paid" }) };expect(repo.findById).toHaveBeenCalledWith("1"); // verifying the interaction
Two properties determine whether any of this is trustworthy at all: determinism and isolation. A deterministic test produces the same result every run given the same code - no reliance on wall-clock time, random values, or network timing without being pinned or mocked. Isolation means one test's setup or leftover state can't affect another's outcome, which is why beforeEach resetting shared state (a map, a mock, a database transaction) is a structural requirement, not a style preference. A suite that violates either property produces flakiness - tests that fail intermittently for reasons unrelated to the code being wrong - which is corrosive precisely because it teaches engineers to re-run failures instead of trusting them.
Coverage - the percentage of lines or branches a test suite executes - is one of the most consistently misread numbers in software engineering. Coverage measures what code ran during the suite, not what was actually verified. A test that calls a function and asserts nothing meaningful about its result still counts as covering every line that function executed, while proving nothing. Coverage is a useful signal for finding code nobody tests at all; it is a poor target to optimize directly, because chasing a percentage rewards tests that touch code, not tests that catch bugs.
At scale, the pyramid gets a layer the basic version omits: contract tests, which sit between integration and end-to-end. When multiple services evolve independently, an integration test against a real dependency proves today's behavior works, but says nothing about whether tomorrow's deploy of that dependency breaks you - and a full end-to-end environment for every combination of service versions doesn't scale. A contract test instead captures the agreement between a consumer and a provider (this endpoint returns this shape) and verifies both sides against that shared contract independently, catching breaking changes without needing every service running together.
Load testing is a related but distinct discipline worth naming precisely because it's easy to lump in with "testing" and then apply the wrong mental model to it. A functional test asks "is the behavior correct." A load test asks "does correct behavior hold up under concurrency and volume" - it's validating a service-level objective (latency, error rate under N requests/second), not a specific output, which is why it belongs outside the pyramid entirely rather than as its tip.
Layer
Strength
Weakness
Best Fit
Unit tests
Fastest feedback; pinpoints the exact broken function
Proves nothing about wiring between components
Pure logic, calculations, validation rules
Integration tests
Proves components actually work together (real DB, real HTTP)
Slower; failure can implicate several components at once
Repository/DB queries, HTTP route wiring
Contract tests
Catches cross-service breaking changes without a full environment
Only as good as the contract's coverage of real usage
Independently deployed services with shared APIs
End-to-end tests
Highest confidence the deployed system actually works
Slowest, most brittle, hardest to debug on failure
A handful of critical user journeys, not general coverage
The Node ecosystem's tooling choices map onto this same trade-off. The built-in node:test module minimizes dependencies and startup cost, favoring the fast end of the pyramid; Vitest adds a richer mocking API and watch-mode ergonomics for teams doing heavier unit and integration work; Testcontainers exists specifically to make the integration layer's "real dependency" cost tolerable by automating disposable Docker instances instead of a shared, stateful test database.
"More tests always means more confidence." A hundred unit tests with no integration coverage can still miss a wiring bug that a single well-placed integration test would catch - confidence comes from what is tested, not the count.
"100% coverage means the code is well-tested." Coverage proves lines executed, not that anything meaningful was asserted about the result - a suite can hit 100% and still miss real bugs.
"Mocks and stubs are the same thing." A stub returns a value; a mock additionally verifies the interaction itself happened as expected. Using a mock where a stub would do adds brittleness for no benefit.
"A flaky test is a minor annoyance." Flakiness actively damages the suite's value - once a team learns to re-run failures instead of trusting them, real failures start getting re-run away too.
"Integration tests are strictly better than unit tests because they're more realistic." They're realistic and slow and harder to debug on failure - the pyramid shape exists because the base layer's speed and precision matter just as much as the top layer's realism.
What actually makes something a "test," regardless of the tool used?
Three things: code runs, a result is compared against an expectation, and the outcome is reported automatically without a human watching. Every runner and assertion library is infrastructure around that same shape.
Why does the testing pyramid have that specific shape instead of being even across layers?
Because confidence and feedback speed trade off against each other - narrow tests are cheap and fast but prove less, broad tests prove more but cost more. Weighting toward the fast, narrow layer maximizes how much confidence you get per second of test-run time.
How does a test runner differ from an assertion library?
The runner discovers, executes, and reports on test files - it's infrastructure for running things. The assertion library provides the comparison functions used inside a test to state an expectation. Some tools (Vitest, Jest) bundle both; node:test and node:assert are separate modules that pair together.
What's the practical difference between a stub, a mock, and a fake?
A stub returns a canned value with no behavior beyond that.
A mock does the same but also records and can verify how it was called.
A fake is a simplified but genuinely working implementation, like an in-memory store standing in for a real database.
When should I use a real dependency instead of a test double?
When the thing you're actually trying to verify depends on that dependency's real behavior - a SQL query's correctness, for instance, can't be proven by a stub that returns whatever you told it to. Reach for a real dependency (often via Testcontainers) when the double would let a real bug through undetected.
Why do flaky tests matter so much if they eventually pass?
Because a test that sometimes fails for reasons unrelated to the code teaches the team to distrust and re-run failures - which means a genuine failure introduced later is more likely to get re-run away instead of investigated.
What causes flakiness in the first place?
Usually a break in determinism or isolation - reliance on real wall-clock time, unseeded randomness, network timing, or leftover state from a previous test that wasn't reset between runs.
Is high test coverage a good goal to set for a team?
Not directly - coverage measures what code executed during the suite, not what was meaningfully verified. It's useful for finding completely untested code, but optimizing the number itself rewards tests that touch code rather than tests that catch bugs.
Where do contract tests fit that integration tests don't cover?
Integration tests prove today's wiring against a real dependency works right now. Contract tests capture the agreement between a consumer and provider explicitly, so either side can verify independently that a future change hasn't broken the other - without needing both services running together.
Is load testing part of the testing pyramid?
Not really - it answers a different question. Functional tests (the pyramid) ask "is this behavior correct." Load testing asks "does correct behavior hold up under concurrency and volume," validating a service-level objective rather than a specific output.
Should I test private, internal functions directly?
Generally no - testing through the public API (an exported function, an HTTP route) verifies the behavior that actually matters to callers, and survives internal refactors that don't change that behavior. Testing internals couples the suite to implementation details that are free to change.
Why do some Node projects run both `node:test` and Vitest?
Usually not by design - mixing runners in one repo tends to confuse contributors about which suite covers what. Most teams standardize on one per repository, or at most one per package in a monorepo, precisely to avoid that ambiguity.