Your First Test With Vitest
Add Vitest to a project you already have and get real tests passing: a pure function with it.each, then an async function that calls fetch, tested with a fake instead of the network.
This adds Vitest to a project you already have and gets one real test passing in about ten minutes: a pure function first, then an async function that calls fetch, without touching the network. You don't need to restructure anything. Start with one file, one function, and grow from there.
Skip ahead: Vitest already installed? Skip to "Test 1: a pure function".
Before you start
Check what's there. Open package.json:
- A
testscript already uses Jest, Mocha or another runner? Add your test there instead. Two test runners in one project confuse everyone. - A Vite project? Vitest reads your existing
vite.config.js, so imports and aliases already work. - Neither? Continue.
Vitest 5 needs Node 22.12 or later (its engines field says ^22.12.0 || ^24.0.0 || >=26.0.0). These examples were run with Vitest 5.0.4 on Node 24.21.0.
Install
npm install --save-dev vitest
npm pkg set scripts.test="vitest run"
--save-dev puts it in devDependencies, because the app doesn't need it to run. vitest run runs once and exits, which is what you want in CI. For working, npx vitest stays open and reruns the affected tests every time you save.
If your project uses require() instead of import, Vitest still works, but write the test files themselves with import.
Test 1: a pure function
Pick a function that takes input and returns output with no side effects. Those are the easiest to test and usually where the bugs are. Here, src/slug.js turns product names into URL slugs:
export function toSlug(title) {
return title
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
Create src/slug.test.js next to it:
import { describe, it, expect } from "vitest";
import { toSlug } from "./slug.js";
describe("toSlug", () => {
it.each([
["Café Latte", "cafe-latte"],
[" Brewed Coffee (Large) ", "brewed-coffee-large"],
["100% Arabica!", "100-arabica"],
["", ""],
])("turns %j into %j", (title, expected) => {
expect(toSlug(title)).toBe(expected);
});
});
it.each runs the same test for every row, and %j puts each value in the test name. Four cases, one test body. Add a row every time someone reports a slug bug.
Test 2: an async function that calls fetch
Tests shouldn't call real APIs. They get slow, and they fail when the network does. The easy way around it is to let the function take fetch as an argument, with the real one as the default:
export async function loadProduct(id, fetchFn = fetch) {
const response = await fetchFn(`/api/products/${id}`);
if (!response.ok) throw new Error(`Product ${id} not found`);
const product = await response.json();
return { ...product, slug: toSlug(product.name) };
}
The app calls loadProduct(7) as usual. The test passes a fake:
import { vi } from "vitest";
import { loadProduct } from "./slug.js";
describe("loadProduct", () => {
it("adds a slug to the product", async () => {
const fakeFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 7, name: "Café Latte", price: 160 }),
});
const product = await loadProduct(7, fakeFetch);
expect(product).toEqual({ id: 7, name: "Café Latte", price: 160, slug: "cafe-latte" });
expect(fakeFetch).toHaveBeenCalledWith("/api/products/7");
});
it("rejects when the product is missing", async () => {
const fakeFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
await expect(loadProduct(99, fakeFetch)).rejects.toThrow("Product 99 not found");
});
});
vi.fn()makes a fake function that records how it was called.mockResolvedValuemakes it return a promise of that value.- The fake response only needs the parts your code uses:
okandjson. toHaveBeenCalledWithchecks the URL your code built.- For a rejected promise, use
await expect(...).rejects.toThrow(...). Theawaitmatters. Without it, the test can finish before the check runs.
Run it
npx vitest run --reporter=verbose
✓ src/slug.test.js > toSlug > turns "Café Latte" into "cafe-latte" 2ms
✓ src/slug.test.js > toSlug > turns " Brewed Coffee (Large) " into "brewed-coffee-large" 0ms
✓ src/slug.test.js > toSlug > turns "100% Arabica!" into "100-arabica" 0ms
✓ src/slug.test.js > toSlug > turns "" into "" 0ms
✓ src/slug.test.js > loadProduct > adds a slug to the product 1ms
✓ src/slug.test.js > loadProduct > rejects when the product is missing 2ms
Test Files 1 passed (1)
Tests 6 passed (6)
⚠ Warning: Make each new test fail once before you trust it. Change an expected value, run, see red, change it back. A test that can't fail, for example a rejected-promise check without await, gives you a green tick and no protection.
The failure you'll hit first
Using toBe on an object:
AssertionError: expected { id: 7 } to be { id: 7 } // Object.is equality
If it should pass with deep equality, replace "toBe" with "toStrictEqual"
toBe checks it's the same object. Two objects with the same contents are different objects. Use toEqual (or toStrictEqual, which also checks for undefined properties) for objects and arrays.
💡 Tip: If code is hard to test, that's information. A function that reads the DOM, calls an API and does the math all at once needs mocks for everything. Move the math into its own function and test that. The rest gets simpler too.
What next
- Add
npm testto your CI so every push runs it. - Write a test for the next bug before you fix it.
- Leave coverage numbers for later. One test on the money math is worth more than 80% coverage of getters.
Which function in your project would you least like to break on a Friday afternoon? That's your next test.
About
Install Vitest in an existing project, test a pure function and an async fetch function with a fake, and fix the first failure you'll probably hit. After this, you have tests running with npm test.
- Responsible
- Mary Q
- Last Update
- 09/11/2026
- Reading Time
- 5 min
Reviews
Sign in to write a reviewComments (0)
Sign in to commentNo comments yet.