Vitest Explained: Tests That Finish Before You Blink
You change one line in the checkout code, a discount that should only apply above fifty dollars, and now you need to know whether anything else broke. The honest answer is that you do not know, and guessing costs more than checking. Running the shop by hand means adding items to the cart, applying the code, and doing the maths yourself, every single time you change anything. A test runner takes that job over. Vitest watches your files, reruns the checks the moment you save, and tells you in under a second whether the totals still add up. This post sets it up on a small shop cart, breaks one test on purpose, and fixes it.
What I would check before using it
My judgement is that Vitest is the right default when the project already runs on Vite, and a good choice for most new JavaScript and TypeScript projects besides. If your front end builds with Vite, Vitest reuses the same config, the same plugins, and the same module handling, so setup is quick. Its test API will also feel familiar if you have used Jest: describe, test, and expect work the way you would guess, so the team learns the runner, not a new dialect. The watch mode is the part that changes daily work. It reruns only the tests touched by your last save, so the feedback arrives while the change is still fresh in your head, instead of at the end of a long run.
Before adopting it, I would check three things. First, the Node version. Vitest 5 needs Node 22.12 or newer, or Node 24, so an older Node on a shared server or a team laptop will refuse to install it, and that is a real blocker, not a warning. Second, whether the project is still CommonJS. Vitest is built for ESM, and a codebase full of require calls and __dirname tricks will fight it. Third, what the existing tests already use. A large Jest suite with heavy custom mocks and module name mapping is not worth migrating for speed alone; the migration cost will eat the benefit for months.
The verdict, in short: choose Vitest for Vite projects and new ESM codebases, where it is fast to set up and fast to run. That recommendation flips if the project runs on old Node, leans on CommonJS, or already has a big Jest suite that works. In those cases, keep what works and revisit the question when the project moves to ESM.
A worked example that is not a toy
The shop in this example sells stationery. Three order rows are enough to show the problem: a notebook at 19.99, two pen sets at 4.99 each, and sticky notes at 7.50. The subtotal is 37.47, and with 17 percent tax the total should be 43.84. Two functions do the work, one that adds up price times quantity and one that adds tax, and the test file checks both numbers.
// cart.js
export const ORDER_ITEMS = [
{ name: "Notebook", price: 19.99, qty: 1 },
{ name: "Pen set", price: 4.99, qty: 2 },
{ name: "Sticky notes", price: 7.5, qty: 1 },
];
export function cartTotal(items) {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
export function totalWithTax(items, rate) {
return cartTotal(items) * (1 + rate);
}
// cart.test.js
import { describe, expect, test } from "vitest";
import { ORDER_ITEMS, cartTotal, totalWithTax } from "./cart.js";
describe("cart totals", () => {
test("adds up the three order rows", () => {
expect(cartTotal(ORDER_ITEMS)).toBe(37.47);
});
test("adds 17 percent tax to the total", () => {
expect(totalWithTax(ORDER_ITEMS, 0.17)).toBe(43.84);
});
});
The project was set up with npm install --save-dev vitest, and the tests were run with npx vitest run. The subtotal test passed, and the tax test failed:
AssertionError: expected 43.83989999999999 to be 43.84 // Object.is equality
Test Files 1 failed (1)
Tests 1 failed | 1 passed (2)
Start at 04:03:07
Duration 307ms (transform 51%, import 23%, tests 18%, worker 8%)
The problem is floating point arithmetic, not Vitest. In binary, 37.47 times 1.17 is not exactly 43.84, it is 43.83989999999999, and toBe checks exact equality, so the test fails even though the shop maths is right. Money should never be compared exactly in floating point. The fix belongs in the function, not in the test: round the result to cents before returning it, so the shop always charges a whole number of cents.
export function totalWithTax(items, rate) {
const total = cartTotal(items) * (1 + rate);
return Math.round(total * 100) / 100;
}
Running the tests again gave a clean pass, and the whole run took 256 milliseconds:
RUN v5.1.0 /home/hatch/workspace/.toolchains/verify/vitest-demo
✓ cart.test.js (2 tests) 5ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 04:03:18
Duration 256ms (transform 52%, import 28%, worker 11%, tests 9%)
That timing is the point of the tool. The feedback arrives before you have time to switch to another tab, which is what makes running the tests after every save feel cheap instead of dutiful. The same failure would have been caught just as well by a slower runner, but a slow runner gets run less often, and a test suite that is run less often catches the bug later, when the cause is harder to find.
Common mistakes and how to fix them
1. Comparing money with toBe. Floating point totals almost never land exactly, so expect(totalWithTax(items, 0.17)).toBe(43.84) fails with AssertionError: expected 43.83989999999999 to be 43.84. The fix is to round money to cents in the code, as the worked example does, or use toBeCloseTo(43.84).
2. Forgetting to await a promise in a test. A test that calls fetchOrderTotal(items, 0.17).then((total) => expect(total).toBe(999.99)) without awaiting or returning the promise passes, even though the expected value is plainly wrong. Vitest finishes the test before the promise resolves, so the check inside never runs. The fix is to mark the test async and await the call, or return the promise.
3. Naming the test file cart.tests.js. Vitest only picks up files ending in .test.js or .spec.js, so the file is ignored and the run ends with No test files found, exiting with code 1. The fix is to rename it to cart.test.js.
4. Importing from the wrong path. A test that imports from ./missing-module.js fails before any test runs, with Error: Cannot find module './missing-module.js'. The fix is to check the relative path from the test file, not from the project root, and to remember that Vitest needs the file extension in ESM imports.
Where this leaves you
Vitest earns its place the first time a save triggers a run that finishes before you reach for the mouse. It is a fast runner with a familiar API, and its real value is that testing becomes cheap enough to do after every change. The habits that matter are small: round money before comparing it, await every promise, and name test files so the runner finds them. The next step is to add one test file to a project you already work on, run npx vitest, and change one line of the code under test. When the failing test names the line you just touched, the loop will make sense.
Comments
Post a Comment