What you'll learn
- A test is code that calls your code and asserts on the result
- Why hand-testing stops working as a project grows
- What a regression is and why tests exist mostly to catch them
- Why the real payoff of a test arrives later, when you change code
- What is not worth testing when you are starting out
on this page
You already test your code. Every time you change something, you hit save, alt-tab to the browser, click through the form, watch the page update, and nod. That nod is a test passing. You ran your code with a known input and compared the result to the picture in your head.
So when someone tells you to “write tests,” the request is smaller than it sounds: write down the check you already perform, in a form a machine can repeat. The machine version runs in seconds, never gets bored, and never forgets an edge case it learned three weeks ago. The hand version has none of those properties, and the rest of this guide is that sentence with details.
The ritual you already run
Here’s what hand-testing looks like written out as the procedure it is:
- Change the code.
- Save.
- Alt-tab to the browser, reload.
- Click through the part you changed.
- Squint. Nod. Back to the editor.
It works, which is why everyone starts this way. The problems only show up as the project grows, and there are three of them.
It doesn’t scale. With one feature, the ritual costs thirty seconds. With ten features that can affect each other, an honest check after each change means walking all ten, and you make dozens of changes in a session. Nobody does that. What people actually do is check the one thing they touched and hope the other nine are fine.
It forgets. Three weeks ago you discovered that an empty cart crashed the checkout page, fixed it, and checked the fix by hand. That knowledge lived in your head, briefly. Today it would never occur to you to re-check the empty cart, because you moved on and the ritual has no memory. Every edge case you ever hand-tested quietly expires.
It looks in the wrong place. When a change breaks something, it usually breaks the thing you weren’t looking at. You verified the new discount logic carefully; the thing that broke was the order history page, because it shares a function with the cart. The ritual only ever points your eyes at the code you just touched.
A test is the ritual written down
A test is that procedure converted to code, so the machine performs it instead of you. Same steps: set up a known input, run your code, compare the result to what you expected. The only difference is who does the comparing.
Once written down, the check never expires. The empty-cart discovery stops being a memory and becomes a permanent tripwire. Every test you add joins a pile of recorded checks, and the whole pile re-runs in seconds whenever you ask. That’s the robot that remembers: every lesson the project ever taught you, replayed on demand.
What a test literally is
Strip away the tooling and a test is two moves: call your code, then assert something about the result. An assertion is a statement that must be true, checked by the machine. If it’s false, the test fails and the runner tells you which one and why.
Here’s a function worth protecting. It totals a shopping cart and applies a discount, with prices in cents so we dodge floating-point weirdness:
// src/cart.js
export function cartTotal(items, discountCents = 0) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
return Math.max(0, subtotal - discountCents);
}
Notice it makes two decisions: multiply price by quantity and sum it up, and refuse to go below zero when the discount is bigger than the cart. Decisions are what tests protect. Here’s the test file:
// src/cart.test.js
import { test, expect } from "vitest";
import { cartTotal } from "./cart.js";
test("adds up price times quantity across items", () => {
const items = [
{ price: 500, qty: 2 },
{ price: 250, qty: 1 },
];
expect(cartTotal(items)).toBe(1250);
});
test("a discount can never push the total below zero", () => {
const items = [{ price: 300, qty: 1 }];
expect(cartTotal(items, 1000)).toBe(0);
});
Read the second one out loud and you can hear the hand-check it replaced: “if the coupon is worth more than the cart, the total should be zero, not a negative number.” The expect(...).toBe(...) line is the assertion. Everything above it is setup.
The third piece is the test runner, the program that finds files named like *.test.js, runs every test inside them, and prints a report. Here that’s Vitest, a popular runner for JavaScript projects:
npm install -D vitest
Then add a script to package.json so npm test works:
{
"scripts": {
"test": "vitest run"
}
}
One small flag worth knowing: vitest run executes the suite once and exits. Plain vitest stays open, watches your files, and re-runs on every save, which is the robot doing the ritual every single time you hit save.
Regressions are the actual enemy
A regression is a feature that used to work and broke because of a later change. The name fits the feeling: the project went backward.
Bugs in brand-new code are annoying but survivable, because you’re already staring at that code and you tend to find them fast. Regressions are the dangerous kind. They appear in code you weren’t looking at, caused by a change that seemed unrelated, and nothing announces them. The checkout worked in March. It’s broken in June. Nobody knows which of the four hundred changes in between did it, and the first person to notice is a user.
Hand-testing is decent against new-code bugs and nearly useless against regressions, because catching a regression means re-checking everything after every change, and that’s precisely the part of the ritual nobody performs.
This is why “tests catch bugs” undersells the deal. Catching today’s bug is a pleasant side effect. The product you’re actually buying is next month: the ability to rename a function or restructure half a file, run one command, and know within seconds whether anything you ever cared about stopped working. Code you can change without fear stays alive. Code you’re afraid to touch is where projects go to die.
Why beginners skip it, and the moment it flips
The reasons nobody writes tests at the start are rational, so let’s not pretend otherwise. Deadlines: a test produces nothing you can see, and the thing is due Friday. And toy projects die young: a project that lives for two weekends never feels the cost of change, and the cost of change is the only thing tests reduce. Skipping tests on a true throwaway is a fair trade.
The catch is that you don’t get to know in advance which projects are throwaways. Every real project starts as a toy that refused to die, and by the time you know it’s real, you’re standing on three months of unchecked behavior.
The flip happens the first time a test catches a regression for you. It usually goes like this: months after writing cartTotal, you rework it to support percentage discounts, and somewhere in the shuffle the Math.max guard falls out. The code looks right. It runs without errors.
npm test ✓ src/cart.test.js (2 tests) 4ms
Test Files 1 passed (1)
Tests 2 passed (2) Before the refactor: the suite at green. This is the baseline you get to keep forever.
npm test ❯ src/cart.test.js (2 tests | 1 failed)
× a discount can never push the total below zero
→ expected -700 to be +0
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2) After the refactor, about four seconds later. The broken rule, by name. (The +0 is just how the runner prints zero.)
That failure names the exact promise that broke, before you ever opened a browser. Without the test, this ships. Some user with a generous coupon sees a negative total, support gets a ticket that says “checkout looks weird sometimes,” and you spend a Friday night digging through a month of commits trying to find which one did it.
Four seconds versus a Friday night. Once you’ve lived that trade from the good side, nobody has to nag you about testing again.
What not to test on day one
New converts tend to swing too far and try to test everything, which burns people out about as fast as testing nothing. Three categories you can skip with a clear conscience:
Framework internals. Don’t write a test proving that React updates the page when state changes, or that Express routes a GET request to your handler. That’s the framework authors’ code, and they have more tests for it than you will ever write. Test the decisions inside your handler, and trust the framework to deliver the request.
Trivial pass-throughs. A function like getName(user) { return user.name } contains no decision. A test for it restates the line and can only fail if you change the line, at which point you’ll dutifully update the test to match. You’ve doubled the typing and protected nothing.
Pixel positions and exact markup. Asserting that a button sits 12 pixels from the left edge, or that the page’s HTML matches a saved copy character for character, produces a test that fails every time the design breathes and says nothing about whether checkout works. Behavior, meaning what the user can actually do, is worth checking. Where things sit on screen this week is not.
The rule underneath all three: test the decisions you made. Anywhere your code chooses (math, branching, what happens with an empty list, a zero, a discount bigger than the cart), that choice is yours, nobody else is testing it, and it’s exactly the kind of thing a refactor silently flips.
The first one to write
Shipping is where this stops being theory. The day your project is deployed, two things become true at once: strangers are feeding it inputs you never imagined, and you still have to keep changing the code while they do. Hand-checking can’t cover that combination. A suite can, because it re-checks the old promises every time you touch anything.
So here’s the concrete move. Next time you finish a feature and catch yourself doing the ritual (save, alt-tab, click, nod), take the one check you just performed and write it down: one file, one test, one assertion, cartTotal-sized. That single test is the difference between a project you can ship once and a project you can keep shipping, because version two gets built on top of version one, and somebody has to remember what version one promised. Better the robot than you.
signal check
Can you answer these without scrolling back up?
-
What is a regression?
A feature that used to work and broke because of a later change, often one that looked unrelated. They are the main thing tests protect you from, because nobody re-checks every old feature by hand after every change.
-
What are the two parts every test has?
It calls your code with a known input, then asserts that the result matches what you expected. If the assertion is false, the runner reports the failing test by name.
-
Name one thing this guide says not to test on day one.
Framework internals (React, Express), trivial pass-through code like a getter, or exact pixel positions in the UI. Test the decisions your own code makes instead.
progress is stored in this browser only