AlgoInsight

Free lesson · Node.js Deep Dive

The Testing Mindset: What to Test and Why

Medium10 min readnodejsjavascriptbackend

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?

What's the value?

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:


What to Test, Specifically

A reasonable starting framework:

Test thoroughly:

Test lightly or not at all:

Test through higher levels:

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:

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:

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:

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."

Back to Node.js Deep Dive