Free lesson · Node.js Deep Dive
The Testing Mindset: What to Test and Why
Lesson 12.1 - The Testing Mindset: What to Test and Why
The question "should we test this?" has a precise answer: test the things that hurt to break and would be hard to detect otherwise. That's it. Not "test everything." Not "test what's easy to test." Not "test until coverage hits 80%." The discipline of testing is the discipline of choosing.
The engineer who tests the right things confidently makes changes that the engineer with 100% line coverage on the wrong things does not. Coverage is a measurement; it isn't the goal. The goal is the freedom to change code without fear, with high confidence that you'll catch breakage before users do.
This lesson is the framing for the rest of the module. We'll cover the test pyramid (the heuristic that organizes testing thinking), the cost-and-value calculus that justifies each test, and the patterns that distinguish testing as discipline from testing as ritual.
I came to most of these opinions through painful experience at Microsoft. The dev tools team I worked on had an extensive end-to-end test suite for VS Code Remote - comprehensive but slow, flaky, and surprisingly bad at catching the bugs that actually shipped. We spent six months reorganizing the test suite around pyramidshaped priorities. The result: faster CI, more reliable signal, fewer escaped bugs. The lessons travel.
Why We Test (And Why Not)
The standard answer: "to find bugs." This is true but incomplete and misleading. If finding bugs were the only goal, manual QA and production monitoring could do it.
The more useful answer: tests exist to enable change. A codebase without tests is a codebase you can't safely modify. Every refactor is a gamble; every new feature might silently break something. Tests bound the cost of change. They make the future cheaper.
This framing matters because it changes what you optimize for. If tests are about catching bugs, you maximize coverage - test every line, mock everything, write tests that pass with any code. If tests are about enabling change, you optimize for confidence - focus on the things that would actually break, write tests that fail when the code is wrong but pass through refactors, keep the test suite fast enough that you actually run it.
The corollary: tests that don't catch real breakage are negative-value. They cost time to maintain, slow down CI, contribute to flake. If a test has never caught a real bug and never will, deleting it is a net win.
The Test Pyramid
The most useful heuristic in testing is the pyramid:
flowchart TB
E2E["End-to-End Tests<br/>(few, slow, broad coverage)"]
INT["Integration Tests<br/>(some, medium, real dependencies)"]
UNIT["Unit Tests<br/>(many, fast, isolated)"]
E2E --> INT --> UNIT
UNIT -.-> Bottom["Foundation:<br/>fast feedback, easy to write,<br/>specific failure signal"]
INT -.-> Middle["Middle:<br/>tests real wiring,<br/>catches integration bugs"]
E2E -.-> Top["Apex:<br/>verifies critical user flows,<br/>slow and expensive"]
style UNIT fill:#34d399,stroke:#10b981,color:#000
style INT fill:#fbbf24,stroke:#f59e0b,color:#000
style E2E fill:#f87171,stroke:#ef4444,color:#000
A pyramid diagram of testing levels. At the wide base are unit tests - many in number, fast to run, easy to write, providing specific failure signals when they break. In the middle are integration tests - fewer in number, medium speed, testing the real wiring between components and catching bugs that unit tests can't. At the narrow apex are end-to-end tests - few in number, slow, expensive to write and maintain, but providing broad coverage of critical user flows. The pyramid shape captures the principle that each level should have proportionally fewer tests than the one below it.
The shape isn't just aesthetic - it captures real cost characteristics.
Unit tests. Test a single function or class in isolation. Run in milliseconds. Failures point precisely at the broken code. Easy to write because they test small, well-defined things. The cost per test is low; you can have many.
Integration tests. Test multiple components together - a controller calling a service calling a real database, say. Run in tens to hundreds of milliseconds. Failures point at the broken interaction but not always the broken line. Catch bugs that unit tests can't (wrong assumptions about external systems, contract mismatches between components).
End-to-end tests. Test the full system through the same interface users would use - HTTP requests to a running server, or a real browser driving the app. Run in seconds. Failures often require digging to diagnose. Catch bugs in critical flows that no other test would find.
The cost-value gradient is real. A unit test that catches one bug per year is worth keeping. An end-to-end test that catches one bug per year is borderline - the maintenance cost may exceed the value. The pyramid shape isn't dogma; it's an acknowledgment that the same coverage costs more at higher levels.
How the Pyramid Goes Wrong
Three common deformations:
The ice cream cone. Lots of E2E tests, few integration tests, almost no unit tests. Tests pass when the system works end-to-end; nothing catches problems at the component level. Slow CI; flaky tests; refactors that break twenty E2E tests because one component changed.
This is what we had at Microsoft initially. Easy to build (the team had Selenium expertise; nobody wrote unit tests for the dev tools' core logic). Painful to maintain. The fix: pushing tests down - replacing E2E tests with integration tests where possible, and integration tests with unit tests where possible.
The hourglass. Lots of unit tests and lots of E2E tests, no integration tests. Each component is well-tested in isolation; the full system is tested; nothing tests the wiring between components. Bugs that appear in production are usually integration bugs - the unit tests didn't catch them because each unit was fine; the E2E tests didn't catch them because they tested high-level flows.
The fix: deliberate integration testing. Test that the controller's call to the service produces the right database state. Test that two services that share a contract actually agree on it.
The inverted pyramid. Lots of E2E tests, fewer integration, very few unit. Same as the ice cream cone but worse - even fewer unit tests. Slowest CI; most fragile.
This usually emerges in teams where testing was retrofitted onto an existing codebase. Easier to test from the outside than to refactor for testability. The fix is harder - usually requires refactoring code to make it unit-testable.
The Cost-Value Calculus
Per test, ask:
What's the cost?
- Authoring cost - how long to write?
- Maintenance cost - how often does it break for non-bug reasons (refactors, dependency changes)?
- Execution cost - how long does it take to run? Multiplied by every CI run and every developer run.
- Cognitive cost - does it make the codebase harder to reason about?
What's the value?
- Bug-catching frequency - how often does it catch a real regression?
- Coverage of high-risk paths - does it cover code where bugs would be expensive (data loss, security, customer-facing)?
- Documentation value - does the test serve as executable specification for behavior?
- Refactoring confidence - does it enable changes that would otherwise be scary?
A test where cost > value is a net loss. Many tests in real codebases are net losses, but no one removes them because "deleting tests" feels wrong. It isn't.
Specifically:
- A test that's never failed in 12 months and tests trivial code (a getter, a passthrough) is probably costing more than it gives.
- A test that fails on every refactor because it asserts implementation details is anti-value - it actively makes the codebase harder to change.
- A test that takes 30 seconds to run, fails intermittently, and tests a non-critical path probably should be replaced with monitoring.
What to Test, Specifically
A reasonable starting framework:
Test thoroughly:
- Business logic. The functions that decide what your application does. Pricing, permissions, validation, calculations.
- Data transformations. Mapping between formats, parsing inputs, formatting outputs.
- Edge cases - empty inputs, boundary values, error paths. The conditions less likely to be exercised by hand testing.
- Contract boundaries. The interfaces between major components. Tests here catch when one side changes assumptions.
- Anything that's been a source of bugs. Past bugs are signals of fragile areas.
Test lightly or not at all:
- Trivial getters/setters and pure data carriers.
- Code that's a thin pass-through to a well-tested library.
- UI styling and layout. Manual testing or visual regression tools are more appropriate.
- Configuration loading (test the consumers of config, not the loading).
- Code that's easier to verify by inspection than by writing a test.
Test through higher levels:
- The full HTTP request/response cycle for critical endpoints. Integration or E2E.
- Critical user flows. The 5-10 things users actually do, end-to-end.
- Anything involving multiple services or external systems.
The goal isn't full coverage of every line. It's confidence that the things that matter work, and that you'd know if they broke.
Tests as Design Pressure
Hard-to-test code is usually a sign of design problems. Functions with many implicit dependencies, classes that do too much, modules with tangled state - testing them is painful. Refactoring them for testability usually also makes them clearer and more maintainable.
This is one of the most important lessons. If a test is hard to write, the production code is the problem, not the test. The fix is to refactor the production code - extract a smaller function, depend on an interface, separate the side effects from the calculations - until testing becomes natural.
Examples of code that's hard to test, and the design fix:
- A function that reads from a file directly. Fix: accept the data as a parameter. The caller reads the file; the function does the work.
- A class that constructs its own database connection. Fix: accept the connection as a constructor argument. Now you can pass a fake in tests.
- A function that calls
Date.now()internally. Fix: accept a clock as a parameter (or use a library that lets you control time). Now tests can have deterministic time.
This pattern - accepting dependencies as parameters rather than constructing them - is called dependency injection. It's the single most important pattern for testability. You don't need a DI framework; you just need to consistently pass dependencies in.
Tests as Documentation
A good test is a usage example for the code under test. New engineers reading the tests learn how the code is intended to work. Tests describe behavior in a way that comments don't - they're verified, executable, and updated when behavior changes.
This implies:
- Test names should describe behavior, not implementation.
it('returns 404 when user does not exist')notit('calls findById then returns'). - Test bodies should read like specifications. Arrange (setup), Act (call), Assert (verify). The "AAA pattern" is universal.
- Tests should fail with clear messages. The diagnostic on failure is the moment the test pays its full value; make it good.
Compare:
// Poor: implementation-focused, useless on failure
it('works', () => {
const result = userService.getUser('42');
assert(result);
});
// Better: behavior-focused, clear failure
it('returns user object when ID matches', async () => {
const user = await userService.getUser('42');
assert.strictEqual(user.id, '42');
assert.strictEqual(user.name, 'Alice');
});
The second test fails with messages like "Expected user.name to equal 'Alice', got 'undefined'". You know exactly what's wrong. The first test fails with "Expected truthy, got false" - useless.
Flake Is Worse Than No Tests
A flaky test - one that passes sometimes and fails sometimes for the same code - is worse than no test. It trains the team to ignore failures ("oh, that one's flaky, just rerun CI"), and when a real bug eventually appears as a "flaky" failure, no one investigates.
Common causes of flake:
- Timing assumptions ("the operation completes within 100ms"). Tests should wait for explicit conditions, not race against time.
- Shared state between tests. One test pollutes state; subsequent tests see different behavior depending on order.
- Real network calls. The network is non-deterministic.
- Random data with insufficient seeding.
Math.random()produces different values each run. - Time-of-day dependencies. Tests that pass at noon but fail at midnight.
Treat flake as a P0 bug. Don't merge a flaky test "to fix later." Either fix the flake or delete the test. The cost of training the team to ignore failures is high.
What I Want You to Take From This Lesson
Six things.
One: Tests exist to enable change. Not to find bugs in the abstract. Optimize for the future cost of modification.
Two: The test pyramid is the right shape. Many unit tests, fewer integration, few E2E. Inverting it is a common failure mode.
Three: Test the right things, not all the things. Business logic, transformations, edge cases, contract boundaries. Skip trivial code.
Four: Hard-to-test code is the problem, not the test. Refactor for testability. Dependency injection is the single most important pattern.
Five: Tests are documentation. Name them by behavior; structure them as specifications.
Six: Flake is poison. Fix it or delete it. Don't train the team to ignore failures.
Try It
Exercise: Warmup (15 minutes). Take a function in a project you work on. Write three tests for it - one happy path, one edge case, one error case. Note which was easiest to write and which was hardest.
Exercise: Core (30 minutes). Audit a project's test suite. Categorize each test as unit, integration, or E2E. Compute the shape. Is it pyramid-shaped, hourglass, or ice cream cone? What would you change?
Exercise: Core (20 minutes). Find a test in a codebase that mocks heavily (multiple mocks per test). Read it. Can you tell what behavior it actually verifies, or does it mostly verify the mocks? Consider whether the production code could be refactored to need fewer mocks.
Exercise: Stretch (45 minutes). Find a function in your codebase that's hard to test - one with hidden dependencies or side effects. Refactor it to accept dependencies as parameters. Write tests for it. Reflect on how the refactor changed the code.
What's Next
Lesson 12.2 is reference material on Node's built-in test runner - the node:test module that became stable in Node 20 and is the modern default for "just write tests without dependencies."