What you'll learn
- How to install Vitest as a devDependency and wire up a test script
- What describe, it, and expect each do, and the arrange-act-assert shape inside every test
- Which functions in a real app deserve the first tests, and which can wait
- How to read a failing test: the address line, expected versus received
- Why a test you have never seen fail is unverified, and the thirty-second ritual that fixes it
on this page
You already test your code. Every time you hit save, flip to the browser, click through the form, and squint at the result, you are running a test. The problem is that you are the test runner: the checks live in your head, they happen only when you remember, and they take minutes instead of milliseconds.
A test is nothing more exotic than code that calls your code and complains when the answer is wrong. That’s it. No certification, no philosophy degree. This guide takes you from zero to a small real suite with Vitest, and along the way covers the part most beginner tutorials skip: how to read a failing test without panic, because you will have broken one on purpose.
Install the runner
Vitest is a test runner: a tool that finds your test files, executes them, and reports what passed and what didn’t. It understands TypeScript and modern import syntax out of the box, it’s fast, and its API is nearly identical to Jest’s, so everything you learn here transfers. All it asks for is a reasonably current Node.
One install:
npm install -D vitest
The -D flag matters. It puts Vitest in devDependencies instead of dependencies: the section of package.json for tools you use while building the project, as opposed to code your app needs at runtime. Your users will never run your tests, so the runner has no business shipping with the app.
Then add a script so you never have to remember the binary’s name:
{
"scripts": {
"test": "vitest run"
}
}
vitest run does a single pass: run everything once, print the results, exit. From now on npm test runs your suite. (test is one of npm’s blessed script names, so you can skip the usual npm run prefix.)
Your first test file
Create src/math.test.ts next to your code. The name is the wiring: Vitest automatically picks up any file ending in .test.ts, .test.js, .spec.ts, and friends. No config file needed.
// src/math.test.ts
import { describe, it, expect } from "vitest";
describe("addition", () => {
it("adds two numbers", () => {
expect(2 + 2).toBe(4);
});
});
Three imports, three jobs:
describegroups related tests under a label. Think of it as a folder in your results.itis a single test. The name is written so it reads as a sentence: “it adds two numbers.”expectmakes the actual claim.expect(2 + 2).toBe(4)says “this value must be 4, or fail loudly.”toBeis called a matcher, and it compares with===strictness.
This particular test is useless on purpose. It tests arithmetic, which means it tests JavaScript itself, and JavaScript was working before you showed up. It exists so you can learn the shape with nothing else going on. Run it, then sabotage it:
npm install -D vitest One package, into devDependencies. Tooling, never shipped to users.
npx vitest run Test Files 1 passed (1)
Tests 1 passed (1)
Start at 23:41:07
Duration 412ms run means one pass and exit. Without it, Vitest starts watch mode and stays open.
npx vitest run FAIL src/math.test.ts > addition > adds two numbers
AssertionError: expected 4 to be 5 // Object.is equality
- Expected
+ Received
- 5
+ 4
src/math.test.ts:6:19
Test Files 1 failed (1)
Tests 1 failed (1) Same command, but the assertion was edited to toBe(5) first. Deliberately broken, so you can see what red looks like while nothing is at stake.
Reading a failure out loud
That red output is the single most useful screen in testing, and beginners tend to scroll past it looking for something scarier. Read it line by line instead.
FAIL src/math.test.ts > addition > adds two numbers is an address: file, then the describe label, then the it label. Before you open a single file, you already know which behavior broke.
AssertionError: expected 4 to be 5 is the claim that didn’t hold, worded from your code’s side: it produced 4, the test demanded 5. That word order trips almost everyone once, so the diff underneath spells it out. Expected is the value written in your assertion. Received is what your code actually returned. The - and + prefixes work like a git diff: what the test wanted, versus what showed up.
src/math.test.ts:6:19 points at the exact expect call, down to the column.
That’s the whole skill: which behavior, what it wanted, what it got, where the claim lives. Every failure you’ll ever debug starts from those four facts.
Now the principle behind the sabotage, because it wasn’t just a demo. A test you have never seen fail is unverified. Tests can pass while testing nothing: the assertion sits inside a callback that never runs, the test checks a hand-computed value instead of calling the real function, an async result isn’t awaited so the test finishes before the check happens. Green tells you a test didn’t fail. Only a deliberate red tells you it could.
So make it a ritual. Every time a new test passes on its first run, spend thirty seconds: break the code it covers (or the expected value), rerun, confirm red, undo, confirm green. Now the test is load-bearing and you know it.
Point the first tests at the gnarly functions
Here’s where beginners waste their first week of testing: writing twenty tests for the easy functions, because easy functions make passing tests, and passing tests feel like progress.
In a real app, the first tests belong on the function you secretly don’t trust. You know the one. Some reliable suspects:
- Date math. “30 days from January 31” lands where, exactly? Month boundaries, leap years, and the 23:59 edge are where date code goes to lie to you.
- Price calculation. Discounts, rounding, and the famous floating-point trap:
0.1 + 0.2is0.30000000000000004in JavaScript, which is why money code counts in integer cents. - Input parsing. Empty strings, stray whitespace, a user typing
10,50when your code expected10.50. Anything that touches text a human typed.
The common thread: many branches, sneaky edges, and consequences when they’re wrong. Say your project has a cart:
// src/cart.ts
export interface LineItem {
name: string;
unitPriceCents: number;
quantity: number;
}
export function cartTotal(items: LineItem[], discountPercent = 0): number {
if (items.length === 0) {
throw new Error("cart is empty");
}
const subtotal = items.reduce(
(sum, item) => sum + item.unitPriceCents * item.quantity,
0,
);
return Math.round(subtotal * (1 - discountPercent / 100));
}
Short, and still carrying four decisions worth pinning down: summing across items, applying the discount, rounding fractional cents, refusing an empty cart. Those decisions are exactly what the tests state:
// src/cart.test.ts
import { describe, it, expect } from "vitest";
import { cartTotal } from "./cart";
describe("cartTotal", () => {
it("adds up multiple line items", () => {
// Arrange: build the world the function needs
const items = [
{ name: "coffee", unitPriceCents: 450, quantity: 2 },
{ name: "bagel", unitPriceCents: 325, quantity: 1 },
];
// Act: call the thing, once
const total = cartTotal(items);
// Assert: state what must be true
expect(total).toBe(1225);
});
it("applies a percentage discount to the subtotal", () => {
const items = [{ name: "headphones", unitPriceCents: 8000, quantity: 1 }];
expect(cartTotal(items, 25)).toBe(6000);
});
it("rounds fractional cents instead of returning them", () => {
// 99 * 0.5 = 49.5, and nobody can pay half a cent
const items = [{ name: "sticker", unitPriceCents: 99, quantity: 1 }];
expect(cartTotal(items, 50)).toBe(50);
});
it("rejects an empty cart", () => {
expect(() => cartTotal([])).toThrow("cart is empty");
});
});
One mechanical detail in that last test: the arrow function wrapper. You hand expect a function to call, so it can catch the throw itself. Write expect(cartTotal([])) instead and the error fires before expect ever gets involved, which crashes the test rather than passing it.
The arrange-act-assert shape
Look back at the first cart test. The comments mark a rhythm that every good test follows, whether or not anyone writes the labels:
- Arrange: build the inputs and any setup the function needs.
- Act: call the function under test. Once.
- Assert: state what must now be true.
Tiny tests compress all three beats into one line, like the discount test above, and that’s fine. The shape is still there. What the shape protects you from is the sprawling test that acts five times and asserts fourteen things, because when that one fails, the address line tells you nothing about which of the fourteen claims broke. If you catch yourself acting twice in one test, you’re usually looking at two tests wearing a trench coat.
Name tests as behavior, not implementation
There’s a reason “rejects an empty cart” was the name and not “calls the validation logic.” Test names are sentences about behavior, and the difference shows up in two places.
First, in failure output. FAIL src/cart.test.ts > cartTotal > rejects an empty cart is a complete bug report: empty carts are no longer being rejected. A name like it("calls validateCart") fails as cartTotal > calls validateCart, which tells you a function you may have renamed last sprint is no longer being called. Is that a bug? You’ll have to go read the code to find out, which is exactly the work the name was supposed to save.
Second, over time. Behavior names survive refactors, because the behavior is the contract. Implementation names rot the moment the internals change, and rotten names are worse than no names: they confidently describe a function that no longer exists.
The test of a test name: would the person using your app recognize the sentence? “Rejects an empty cart,” sure. “Calls validateCart,” never. A pleasant side effect is that npx vitest run output turns into a readable spec of what your module promises, one sentence per promise.
Watch mode is the loop
So far every run has been vitest run: one pass, exit. Drop the run and you get the mode you’ll actually live in:
npx vitest
This starts watch mode. Vitest stays open, watches your files, and the moment you save it reruns the affected tests, typically in well under a second. (On a CI server it detects the environment and does a single pass instead, so the same command behaves sensibly in both places.)
This is the replacement for the save-flip-click-squint loop you’ve been running by hand. Editor on one side, watch mode on the other, and every save answers “did I just break something?” before you’ve finished reaching for the browser. Worth its own script:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Working on the gnarly function with watch mode running changes how the work feels. You stop rechecking the discount math in your head, because the four sentences pinning it down recheck themselves on every keystroke.
Tying it back to shipping
Shipped projects change after they ship. The cart grows a coupon system, the date code gets a time zone fix, the parser meets a user who pastes from Excel. Without tests, every one of those changes means manually re-clicking through everything the old code did right, and the first regression you miss gets found by a user instead of by you.
With even this small suite, the deal is different. You refactor cartTotal, the screen goes red in under a second, and the address line names the exact promise you just broke, at your desk, before anyone else ever sees it. That’s what the tests are for: they make shipped code safe to change.
One gap remains, and it’s the human one: these tests run only when you remember to run them, and the day you forget is reliably the day it matters. The fix is to make a machine remember for you, which is the next guide: tests in CI, where every push runs the suite whether you thought about it or not.
signal check
Can you answer these without scrolling back up?
-
Why does Vitest go in devDependencies instead of dependencies?
It is a tool you run while building the project, like a bundler or a linter. Your users never run your tests, so the package has no business shipping with production code. The -D flag keeps it on the tooling side of package.json.
-
Your brand-new test passed on its very first run. What should you do before trusting it?
Make it fail on purpose. Break the code it covers (or the expected value in the assertion), rerun, and confirm it goes red. A test that has never failed might not be wired to anything. Then undo the damage and watch it go green again.
-
What is wrong with naming a test 'it calls validateCart'?
It describes the implementation instead of the behavior. Rename or remove validateCart during a refactor and the test name becomes a lie even while the behavior stays correct. 'It rejects an empty cart' survives any refactor and tells you exactly what broke when it fails.
progress is stored in this browser only