Testing From Zero
Why We Test, and the Shapes of a Test Suite
What tests prove and do not prove, the pyramid, trophy and honeycomb as models, what to test, a first node:test run, and bad tests.
On this page
- Words used in this lesson
- What a test is
- What tests prove, and what they do not
- Why we test
- The three things every test trades: speed, cost, confidence
- The shapes: pyramid, trophy, honeycomb
- What to test, and what to skip
- Your first test, run for real
- The cost of bad tests
- Where tests run
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: a test is a small program that checks another program and says pass or fail. Tests do not prove your code is right. They give you fast, repeatable evidence that known things still work, so you can change code without fear.
This is lesson 0 of a short series, Testing From Zero: What, Why and How. You need no testing background. By the end of this lesson you will know what a test is, what tests can and cannot tell you, what the famous "pyramid", "trophy" and "honeycomb" shapes mean, how to decide what to test, and you will have written and run your first test.
The next lessons go deeper: Lesson 1 covers unit tests and test doubles, Lesson 2 covers UI components, Lesson 3 covers integration and API tests, Lesson 4 covers end-to-end tests and flaky tests, and Lesson 5 covers TDD, property-based testing, mutation testing and a team test strategy. For tool questions asked in interviews see Full-Stack Lesson 21. For how to test a frontend as a whole see Frontend Depth Lesson 5. For testing a backend API see Backend Lesson 11. For the team and money side (debt, quality, test strategy) see Team Lead Lesson 6.
What I ran / checked. Every code block below was run on Node.js v22.22.0 (npm 10.9.4) on Linux. The test runner is the built-in node:test. Nothing here needs an install. Output is pasted as printed, but I shortened long file paths to ... and cut a few lines of stack trace where marked. Timings are from this one machine and only show rough size, not a benchmark. I read the Node.js test runner docs (doc/api/test.md on the v22.x branch of nodejs/node) and the Testing Library guiding principle in testing-library/react-testing-library. The pyramid, trophy and honeycomb come from blog posts that I could not open (they are not on GitHub). I know them from the reading list in charlax/professional-programming, which links to them with one-line notes, so I describe the ideas in my own words and do not quote them. Who first drew each shape (Mike Cohn, Kent C. Dodds, the Spotify team) is from memory, not from a source I opened.
Words used in this lesson
- Test: code that runs other code with known input and checks the result against what you expect.
- Assertion: one check inside a test, such as "this value equals 120". If it is false, the test fails.
- Test suite: all the tests of a project.
- Test runner: the tool that finds your tests, runs them and prints the result. Examples:
node --test, Vitest, Jest. - Pass / fail: a test passes when all its assertions are true and nothing throws. It fails otherwise.
- Red / green: "red" is a failing test, "green" is a passing one. Named after the colours most runners print.
- Regression: something that used to work and now does not. A regression test is a test that would have caught it.
- Unit test: a test of one small piece (a function or class), usually without network or database.
- Integration test: a test where several real pieces work together (for example your code and a real database).
- End-to-end (E2E) test: a test that drives the whole app like a user, through a real browser.
- Confidence: how sure you are that the software works when the tests are green.
- Flaky test: a test that sometimes passes and sometimes fails with no code change.
What a test is
A test has three parts. You set up a situation, you do one thing, and you check the result.
const result = totalWithTax(100, 0.2); // do one thingif (result !== 120) throw new Error('wrong total'); // checkThat is already a test. A test runner just adds the boring parts for you: it finds all the test files, runs each test on its own, catches the errors, and prints a clear summary. A good runner also shows you what was expected and what you got.
Why not just try the app by hand? Because a person gets bored, forgets steps, and takes minutes. A test takes milliseconds and does the same steps every time. Run it on every change and it works like a guard that never sleeps.
What tests prove, and what they do not
This is the most important idea of the lesson.
A passing test proves only that the cases you wrote down worked on the day you ran them.
Tests can find bugs. They cannot show that there are no bugs. Three honest limits:
- Tests only cover what you thought of. A case you did not imagine is not tested.
- A test can be wrong. If the expected value in the test is wrong, you get a green test for a bug.
- A test can be weak. It runs the code but checks almost nothing.
Here is the third point for real. We will write a function with a bug, and two weak tests that still pass. First the buggy code. It should add the tax to the subtotal, but it only returns the tax:
// price.jsexport function totalWithTax(subtotal, rate) { return subtotal * rate; // bug: forgot "subtotal +"}// weak.test.jsimport { test } from 'node:test';import assert from 'node:assert/strict';import { totalWithTax } from './price.js';test('returns a number', () => { assert.equal(typeof totalWithTax(100, 0.2), 'number');});test('zero stays zero', () => { assert.equal(totalWithTax(0, 0.2), 0);});$ node --test --test-reporter=spec weak.test.js✔ returns a number (0.745632ms)✔ zero stays zero (0.142207ms)ℹ tests 2ℹ suites 0ℹ pass 2ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 88.650741Both tests are green and the function is wrong. "Returns a number" is true for any number. "Zero stays zero" is also true for the bug (0 * 0.2 is 0). Neither test checks the one thing that matters: the value for a normal input. Lesson 5 shows a tool (mutation testing) that finds weak tests like these automatically.
Coverage is not proof either. "Code coverage" tells you which lines ran during the tests. Both weak tests above run every line of totalWithTax, so coverage is 100%, and the bug is still there. Coverage can show you code that is never run. It cannot show you that the checks were good.
Why we test
Tests are not for "quality" in general. They give you specific things:
| Benefit | What it means in practice |
|---|---|
| Catch regressions | A change in one place breaks something far away. A test tells you in seconds, not after release. |
| Safe refactoring | You can clean up the inside of code and know the behaviour is the same. See Team Lead Lesson 6 on small steps with tests. |
| Documentation that runs | A test named "rejects a password under 8 characters" tells the next developer a rule, and it cannot go out of date without failing. |
| Faster feedback | A bug found in one second on your laptop is cheaper than a bug found by a user. |
| Better design | Code that is hard to test is often code with too many hidden dependencies. Testing pushes you to make small, clear pieces. |
And the costs, which people forget:
- Writing and reading tests takes time.
- Every test must be kept up to date when behaviour changes on purpose.
- Slow or flaky tests make people stop running them.
A test suite is an investment with running costs. The goal is the most confidence for the least cost, not the most tests.
The three things every test trades: speed, cost, confidence
Every kind of test sits somewhere on three scales.
- Speed: how long one run takes.
- Cost: how hard it is to write, to keep working, and to find the cause when it fails.
- Confidence: how sure you are that real users will be fine if it is green.
| Kind | Speed | Cost to write and keep | Confidence it gives | Typical cause of a false alarm |
|---|---|---|---|---|
| Unit | Very fast (milliseconds) | Low | Narrow: this piece works alone | Test is tied to how the code is built |
| Integration | Medium (tens of ms to seconds) | Medium | Wider: pieces fit together | Test data, shared state |
| End-to-end | Slow (seconds to minutes) | High | Widest: a real user path works | Timing, network, browser, environment |
Rough sizes from this machine, only to show the order of magnitude (a single run each, not a benchmark): the unit tests in this lesson took about 1 ms each. The tests of Lesson 2 with a fake browser (jsdom, a JavaScript copy of the browser's page model) took roughly 10 to 340 ms per test. I did not time a real browser test for this lesson. Commonly they take seconds. Treat these numbers as "about 1x, 10 to 100x, much more", not as exact.
There is no free lunch. The wide, realistic tests give the most confidence but are slow and fail for many reasons. The small tests are fast and precise but can miss problems between the pieces. This trade-off is the reason test shapes exist.
The shapes: pyramid, trophy, honeycomb
Teams drew pictures to show how many tests of each kind to have. These pictures are models. A model is a simplified idea that helps you think. It is not a law, and no picture fits every project.
The test pyramid
The idea (from Mike Cohn, written up by Martin Fowler): many unit tests at the bottom, fewer integration tests in the middle, and only a few end-to-end tests on top.
/\ few E2E slow, expensive, realistic / \ /----\ some integration / \ /--------\ many unit fast, cheap, narrowThe reasoning follows the table above: put most tests where they are fast and cheap. It works well when your code has a lot of logic that can be tested alone (calculations, rules, parsers).
The testing trophy
The idea (from Kent C. Dodds, who wrote "Write tests. Not too many. Mostly integration."): a trophy shape. A thin base of static checks (types and a linter, which catch typos and wrong types for free), a small number of unit tests, the largest part as integration tests, and a few end-to-end tests on top.
___ / E2E \ a few |-------| | | | Integ.| the biggest part | | \-----/ | Unit | some |-------| | Static| types + linter: always onThe reasoning: in a modern app, most bugs happen where pieces meet (a component and an API, a service and a database), not inside one pure function. Integration tests check those meetings and are still quick enough. This matches the Testing Library principle: the more your tests resemble the way your software is used, the more confidence they can give you. It suits frontend and API-heavy apps. Lesson 2 and Lesson 3 follow this style.
The honeycomb
The idea (from the Spotify engineering team, for services that talk to many other services): very few implementation-detail tests, a large middle of integration tests of one service with its neighbours, and very few "integrated" end-to-end tests. The honeycomb says: a small service has little logic of its own, so most of its risk is in how it talks to other things. I know this shape only from secondary descriptions, so treat my summary as light.
How to use the shapes
| Shape | Says "most tests should be..." | Fits best when |
|---|---|---|
| Pyramid | unit tests | much pure logic, libraries, algorithms |
| Trophy | integration tests | UI apps, apps built from many libraries |
| Honeycomb | integration tests of one service | many small services that call each other |
All three agree on two things: end-to-end tests are few (they are slow and fragile) and tests should match where your risk is. They disagree about the middle. That is fine. The web.dev article "Pyramid or Crab? Find a testing strategy that fits" in the same reading list makes the same point: choose a shape that fits your system, and be able to say why.
Do not argue about the shape in a review. "Your tests are the wrong shape" is not a useful comment. Ask instead: what is the riskiest part of this code, and does a fast, reliable test cover it?
What to test, and what to skip
You cannot test everything, and you should not try. Use risk as your guide: how likely is it to break, and how bad is it if it does?
Test these first:
- Business rules: prices, discounts, permissions, limits. A bug here costs money or trust.
- Edge cases: empty list, zero, one item, the largest allowed value, the first day of the month, a missing field.
- Error paths: what happens when the network fails, the input is wrong, the user is not allowed.
- Bugs you already had: for each real bug, write a test that fails with the bug and passes with the fix. This is a regression test and is the best value you will get.
- Paths users cannot lose: sign up, log in, pay.
Usually skip these:
- Code you do not own. Do not test that
Array.prototype.mapworks or that your database library saves a row. Test your use of it. - Pure wiring with no decision, such as a function that only passes arguments to another function.
- Exact layout and styling, such as "this div is 12 pixels wide". These tests break on every design change and prove little.
- Private details. If a test needs to look inside an object to pass, it is testing how the code is built, not what it does.
- Generated code and simple data holders.
A short question to ask before writing a test: "If this test fails, will I know that something real is broken?" If the honest answer is "no, I will just update the test", do not write it.
Your first test, run for real
Node.js has a test runner built in (node:test), so you need no install. In Node v22 the runner is marked Stable in the official docs. By default node --test finds files named *.test.js, *-test.js and *_test.js (also .mjs and .cjs).
Make a folder with two files. First the function, with the bug from before so that we can see a red test:
// price.jsexport function totalWithTax(subtotal, rate) { return subtotal * rate;}// price.test.jsimport { test } from 'node:test';import assert from 'node:assert/strict';import { totalWithTax } from './price.js';test('adds 20% tax to 100', () => { assert.equal(totalWithTax(100, 0.2), 120);});Both files use import, so the folder needs "type": "module" in its package.json (or use the .mjs extension). node:assert/strict is the built-in assertion module. Its equal uses strict comparison (===).
Run it. I used the spec reporter to get readable output. By default, when the output is not a terminal, Node prints the machine format called TAP.
Red
$ node --test --test-reporter=spec✖ adds 20% tax to 100 (13.309606ms)ℹ tests 1ℹ suites 0ℹ pass 0ℹ fail 1ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 123.382626✖ failing tests:test at price.test.js:5:1✖ adds 20% tax to 100 (13.309606ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 20 !== 120 at TestContext.<anonymous> (file:///.../price.test.js:6:10) at Test.runInAsyncScope (node:async_hooks:214:14)(I cut the rest of the stack trace.) Read it like this: the test is called "adds 20% tax to 100", it is on line 5, and the actual value was 20 while the expected value was 120. The message points straight at the bug. That is what a good failure does.
Green
Fix the function:
// price.jsexport function totalWithTax(subtotal, rate) { return subtotal + subtotal * rate;}$ node --test --test-reporter=spec✔ adds 20% tax to 100 (1.021289ms)ℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 121.161049That is the whole cycle: red (the test fails for the right reason), change the code, green (it passes). The ~120 ms total is mostly Node starting up. The test itself took about 1 ms.
Always watch a test fail once. A test you never saw fail may be a test that can never fail (like the two weak ones above). If you write the test after the code, break the code on purpose, see red, then fix it. Lesson 5 turns this habit into a method called TDD (test-driven development).
Add the real cases
One test is rarely enough. Think about the edges: zero tax, zero subtotal, a rate that creates fractions. In a real project you would add them and see them run. The pattern stays the same, so I will not repeat it here. Lesson 1 shows how to name tests and how to arrange them cleanly.
The cost of bad tests
A bad test is worse than no test, because people trust it. Common kinds:
| Bad test | Problem |
|---|---|
| Weak (checks almost nothing) | Gives false confidence. You saw this above. |
| Brittle (breaks on harmless changes) | Teaches the team to ignore red, or to update the test blindly. |
| Flaky (sometimes fails) | Teaches the team to re-run until green. Real failures then hide. |
| Slow | People stop running the suite before pushing. |
| Unclear (name says nothing) | When it fails, nobody knows what broke. |
| Mirror of the code (repeats the logic) | If the code is wrong, the test is wrong in the same way. |
A brittle test, shown
Here is a cart. The "bad" test looks inside the object. The "good" test only uses what a caller can see.
// cart.jsexport class Cart { _items = []; // "private" by convention only add(name, cents) { this._items.push({ name, cents }); } total() { return this._items.reduce((s, i) => s + i.cents, 0); }}// cart.test.jsimport { test } from 'node:test';import assert from 'node:assert/strict';import { Cart } from './cart.js';test('BAD: looks inside the object', () => { const c = new Cart(); c.add('pen', 150); assert.deepEqual(c._items, [{ name: 'pen', cents: 150 }]);});test('GOOD: checks what a user of Cart can see', () => { const c = new Cart(); c.add('pen', 150); c.add('book', 1200); assert.equal(c.total(), 1350);});$ node --test --test-reporter=spec cart.test.js (before the refactor)✔ BAD: looks inside the object (2.463733ms)✔ GOOD: checks what a user of Cart can see (0.440761ms)ℹ tests 2Now we refactor. Instead of keeping a list, the cart keeps a running total. The behaviour a user sees (add, total) is the same.
// cart.js (refactored)export class Cart { _totalCents = 0; add(name, cents) { this._totalCents += cents; } total() { return this._totalCents; }}$ node --test --test-reporter=spec cart.test.js (after the refactor)✖ BAD: looks inside the object (3.98871ms)✔ GOOD: checks what a user of Cart can see (0.509779ms)ℹ tests 2ℹ suites 0ℹ pass 1ℹ fail 1Nothing is broken, yet one test is red. If this happens ten times a month, people stop trusting red, and refactoring starts to feel dangerous, which is the opposite of why we test. The rule: test behaviour (what goes in, what comes out, what the user can see), not construction (how the code is built inside). Lesson 1 uses this rule again for mocks.
Where tests run
Tests are useful in three places, and a good setup uses all three:
- In your editor or terminal while you work, often in watch mode (the runner reruns when you save). Needs to be fast.
- Before you push, sometimes as a git hook. Run the fast tests.
- In CI (continuous integration), a server that runs the full suite on every pull request. This is the shared safety net. A red CI should block a merge.
If the suite is slow, you will skip step 1 and 2 and only learn about problems at step 3. That is another reason to keep most tests fast.
Try it
- Predict.
totalWithTax(50, 0.1)with the fixed code returns what? Write a test for it and run it. Answer:55. I checked withnode -e "console.log(50 + 50*0.1)", which printed55. Be careful with other numbers:0.1 + 0.2prints0.30000000000000004in JavaScript, because decimal fractions are stored approximately. That is why money code uses whole cents (integers), as the cart above does. - Break it on purpose. Change
+to-in the fixed function. Which of the three tests from this lesson (price.test.js, the two weak tests) turn red? Answer: onlyprice.test.jsturns red. The two weak tests stay green, which shows why they are weak. - Classify. For each, say unit, integration or end-to-end: (a) a test that calls
totalWithTax; (b) a test that starts a server and a real database and sends an HTTP request; (c) a test that opens a browser, types into the login form and clicks the button. Answer: (a) unit, (b) integration, (c) end-to-end. - Skip or test? Decide for each: (a) the discount rule "10% off orders over 100"; (b) the colour of a button; (c) that your ORM can save a row. Answer: test (a). Skip (b), or cover it with a visual check outside the unit suite. Skip (c) (the library's job), but do test your own query logic.
Interview questions
Why do we write tests? (Must know)
I write tests to get fast, repeatable evidence that important behaviour still works after a change. The big wins are catching regressions early, making refactoring safe, and documenting rules in a way that cannot go out of date silently. I also say that tests have a cost, so the goal is the most confidence for the least cost, not the highest number of tests.
Does a passing test suite mean the software has no bugs? (Must know)
No. Tests can show that a bug exists, but they cannot show that none exist. A green suite only means the cases we wrote passed. Cases we never thought of, wrong expected values and weak assertions can all hide bugs. I would add that coverage does not fix this: you can run every line and still check nothing.
What is the test pyramid? (Must know)
It is a model that says to have many fast unit tests at the bottom, fewer integration tests in the middle, and few slow end-to-end tests on top. The reason is the trade-off between speed, cost and confidence: cheap tests where they are cheap, expensive realistic tests only for the most important paths. I always add that it is a model, not a law.
Follow-up: "What is the testing trophy?" It keeps the idea of few E2E tests, but makes integration tests the biggest layer and adds static checks (types, linter) as the base. The argument is that most bugs in modern apps happen where pieces meet, so tests that exercise several real pieces together give more confidence per test.
Pyramid or trophy: which is right? (Senior signal)
Neither is always right. I look at where the risk is. For a library full of pure logic (parsing, pricing), the pyramid fits, so I write many unit tests. For a React app that mostly wires components to an API, I lean to the trophy, with component tests that use a mocked network, a few full-path tests, and strong types. For a service in a mesh of other services, I focus on tests of the service with its neighbours. Then I check the result by asking: when a real bug happened last quarter, which layer would have caught it? If the answer is "none", the shape is wrong for us.
What do you test and what do you skip? (Must know)
I test by risk: business rules, edge cases, error paths, flows the user cannot lose, and every bug we have already had. I skip other people's code (I test my use of a library, not the library), pure wiring with no decision, exact styling and private details. A check I use: if this test fails, will I know that something real is broken? If I would only update the test, it is not worth writing.
What makes a test bad? (Senior signal)
Weak tests that check almost nothing, brittle tests that break when the inside changes but the behaviour does not, flaky tests that fail at random, slow tests, and tests with unclear names. All of them cost trust. When the team stops trusting red, the suite loses its value. I demonstrated this in a small case: a test that read a private array failed after a harmless refactor, while a test of the public total() stayed green.
Is 100% code coverage a good goal? (Trap)
It is a poor goal on its own. Coverage shows which lines ran, not whether anything was checked. I showed that two weak tests can reach full coverage of a function with a bug. I use coverage in one direction only: low coverage in important code is a hint to look there. High coverage proves nothing. If the team wants a number, I pair it with a review of assertion quality or with mutation testing.
What does red-green mean, and why watch a test fail first? (Must know)
Red is a failing test, green is a passing one. I write or run the test and see it fail for the right reason, then change the code and see it pass. Watching it fail proves the test can detect the problem. A test I never saw fail may be unable to fail.
Where should tests run? (Senior signal)
In three places: locally while I work (fast, ideally in watch mode), before pushing (the quick set), and in CI on every pull request (the full set, which blocks merging when red). If the suite is slow, people skip the first two, so I protect speed: most tests should be small and fast, and the slow ones should be few and run in CI.
A teammate says "we don't have time to write tests." What do you say? (Trap)
I do not argue about the principle. I make it concrete: which bugs cost us time last month, and would a small test have caught them? I suggest a low-cost start: a regression test for every bug we fix, and tests for the rules that cost money. Time spent on tests is often returned when a change that would have taken a day of manual checking now takes a minute. If the code is untested and risky, I propose adding tests around it before refactoring, not a big testing project.
Key takeaways
- A test runs code with known input and checks the result. A runner finds, runs and reports many tests.
- Passing tests show that the cases you wrote worked. They never show that there are no bugs.
- Weak assertions and "100% coverage" can hide a real bug. Check values, not just that something ran.
- Every test trades speed, cost and confidence. Unit tests are fast and narrow, E2E tests are slow and wide.
- The pyramid, trophy and honeycomb are models. Pick the shape that matches where your risk is.
- Test by risk: rules, edges, errors, past bugs, critical paths. Skip other people's code and inside details.
- Watch a test go red before green. Test behaviour, not construction.
- A bad test costs more than no test, because the team trusts it.
What's next
Next: Unit Tests and Test Doubles, Lesson 1. Practise first: take one function from your own project, write three tests with real values (normal, edge, error), and break the function on purpose to see which test notices.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Building an Evaluation Harness for an LLM Feature · AI Engineering
- Documenting APIs: OpenAPI, Contracts and Testing · API Design
- Testing an API: Unit, Integration and Contract · Backend in Depth
Part of the study guide
Related series