What is Vitest? Write your first test to lock in a bug fix

What is Vitest and how do you write a first test? Lock in a café's "35.000" price fix, use test.each for many cases, and avoid tests that always pass.

What is Vitest? It's a test runner for JavaScript and TypeScript: you write small pieces of code that check "this input must give that result", and Vitest runs them whenever the code changes. A good test turns red the moment an old bug comes back. Let's write a first test that locks in the fix for order 1044.

The 70 đồng bug is fixed, so what's missing?

In the previous post, I used breakpoints in VS Code to find out why order 1044 at Mộc, a small coffee shop in Vietnam, came to a total of 70 đồng for two salt coffees. (The đồng is Vietnam's currency; a coffee costs about 35,000 đồng, a bit over one US dollar, so 70 đồng is essentially nothing.) The shop's old point-of-sale machine wrote prices as strings like "35.000", the way Vietnamese writes thousands, and JavaScript read the dot as a decimal point, so "35.000" * 2 came out as 70. The fix was a parsePrice function that normalizes prices as soon as the data is read. (Mộc and its order data in this series are illustrative.)

The only evidence for the fix was one curl call and a look with my own eyes. The shop owner asked exactly the right question: what if someone changes the code later and breaks it again?

A test answers that. It doesn't only prove the code is right today. It checks again on every later change, even when the person changing the code has never heard that order 1044 was once wrong.

What code needs testing?

All the logic lives in src/orders.ts. I've included the whole file so you can type it in without reading the previous post. Four functions matter. parsePrice turns the old machine's prices into numbers, normalizeOrder calls parsePrice for every item of an order, orderTotal adds up the total, and listOrders builds the list the admin page shows.

src/orders.ts
export type Customer = { name: string; phone: string };
export type OrderItem = { name: string; price: number; quantity: number };
export type Order = {
  id: number;
  createdAt: string;
  source: "web" | "old-pos";
  // Walk-in orders from the old point-of-sale machine (source "old-pos") may have no customer.
  customer?: Customer;
  items: OrderItem[];
};

// Data as read from the file, not checked yet. The old POS writes prices as text with dots between thousands ("35.000").
export type RawOrder = Omit<Order, "items"> & {
  items: { name: string; price: number | string; quantity: number }[];
};

// "35.000" → 35000. Anything else unusual throws right away instead of guessing.
export function parsePrice(raw: number | string): number {
  if (typeof raw === "number") return raw;
  const text = raw.trim();
  if (/^\d{1,3}(\.\d{3})+$/.test(text)) return Number(text.replaceAll(".", ""));
  if (/^\d+$/.test(text)) return Number(text);
  throw new Error(`Invalid price: "${raw}"`);
}

export function normalizeOrder(raw: RawOrder): Order {
  return {
    ...raw,
    items: raw.items.map((item) => ({ ...item, price: parsePrice(item.price) })),
  };
}

export function customerLabel(order: Order): string {
  if (!order.customer) return "WALK-IN CUSTOMER";
  return order.customer.name.toUpperCase();
}

export function orderTotal(order: Order): number {
  return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

export function listOrders(orders: Order[]) {
  return orders.map((order) => ({
    id: order.id,
    customer: customerLabel(order),
    total: orderTotal(order),
  }));
}

The data is four orders from one morning. The first two come from the website, the last two were entered on the old machine at the counter, and order 1044 has its price as a string:

data/orders.json
[
  {
    "id": 1041,
    "createdAt": "2026-11-03T08:12:00+07:00",
    "source": "web",
    "customer": { "name": "Nguyễn An", "phone": "0901000001" },
    "items": [
      { "name": "Iced milk coffee", "price": 29000, "quantity": 2 },
      { "name": "Egg banh mi", "price": 25000, "quantity": 1 }
    ]
  },
  {
    "id": 1042,
    "createdAt": "2026-11-03T09:40:00+07:00",
    "source": "web",
    "customer": { "name": "Trần Bình", "phone": "0901000002" },
    "items": [{ "name": "Bac xiu", "price": 32000, "quantity": 1 }]
  },
  {
    "id": 1043,
    "createdAt": "2026-11-03T10:05:00+07:00",
    "source": "old-pos",
    "items": [{ "name": "Black coffee", "price": 25000, "quantity": 1 }]
  },
  {
    "id": 1044,
    "createdAt": "2026-11-03T10:30:00+07:00",
    "source": "old-pos",
    "customer": { "name": "Lê Chi", "phone": "0901000004" },
    "items": [{ "name": "Salt coffee", "price": "35.000", "quantity": 2 }]
  }
]

The project also has a src/server.ts that returns the order list over HTTP, but this post doesn't need it. The tests call the functions in orders.ts directly, with no server running.

Why Vitest?

This project is written in TypeScript and runs on Node.js. Vitest runs .ts files straight away with no extra configuration, and its test and expect syntax matches Jest, so docs and examples you find online still apply. It's also what Tech Notes itself uses, with more than 500 tests that run in a few seconds.

Node.js has a built-in test runner too (node --test), and for a project this small it's perfectly good enough. I picked Vitest because its failure messages are easier for a beginner to read, as you'll see below, and because when the project grows into a web app with React, you won't have to switch tools.

Setup is one package and two scripts in package.json:

package.json
{
  "name": "moc-orders-03",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/server.ts",
    "test": "vitest run src",
    "test:watch": "vitest"
  },
  "devDependencies": {
    "vitest": "5.1.0"
  }
}

vitest run src runs every file ending in .test.ts in the src folder once and exits, which is what you want before a commit or on a CI server. vitest without run starts watch mode: every time you save a file, the related tests run again. I usually keep watch mode open in a separate terminal while I'm changing code.

How was my first test wrong?

The first time I wrote a test for orderTotal, I wrote this. I keep it in a wrong-tests/ folder in the demo so you can compare; don't write tests this way:

wrong-tests/orders-copied-logic.test.ts
import { readFileSync } from "node:fs";
import { expect, test } from "vitest";

import { orderTotal, type Order } from "../src/orders.ts";

const orders: Order[] = JSON.parse(readFileSync(new URL("../data/orders.json", import.meta.url), "utf8"));

test("total of order 1044", () => {
  const order = orders.find((o) => o.id === 1044)!;
  const expected = order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  expect(orderTotal(order)).toBe(expected);
});
npx vitest run wrong-tests
 Test Files  1 passed (1)
      Tests  1 passed (1)

Green. But look closely: it reads the raw data without going through normalizeOrder, so the price is still the string "35.000", and orderTotal returns 70. The expected value is also 70, because the expected line recomputes it with exactly the same formula that's inside orderTotal. The test compares the code with itself, so it passes even though the total is wrong.

This mistake is more common than you'd think, especially when you let AI write your tests. The rule I took from it: the expected result must be a number you know is right from outside the code. Here, two salt coffees at 35,000 đồng are 70,000 đồng. That number comes from your head and the shop's price list, not from the function under test.

How do you write a test that locks in the order 1044 fix?

Here's the real test file, placed next to the code as src/orders.test.ts. I go through each block below:

src/orders.test.ts
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";

import { listOrders, normalizeOrder, orderTotal, parsePrice, type RawOrder } from "./orders.ts";

describe("parsePrice", () => {
  test.each([
    [35000, 35000], // web order: the price is already a number, keep it
    ["35.000", 35000], // old POS: dots between thousands, the exact bug in order 1044
    ["1.250.000", 1250000], // several groups of thousands
    [" 35.000 ", 35000], // the old POS sometimes adds spaces
    ["35000", 35000], // a string of digits only
  ])("reads %j as %d", (raw, expected) => {
    expect(parsePrice(raw)).toBe(expected);
  });

  test.each([
    ["35,000"], // comma: US style (35 thousand) or European style (35 point 0)? we can't tell
    ["35.00"], // the group after the dot isn't 3 digits: could be a decimal
    ["35k"], // shorthand: the old POS never writes this, so it's a sign something is off
    [""], // the price field was left empty
  ])("rejects %j instead of guessing", (raw) => {
    expect(() => parsePrice(raw)).toThrow("Invalid price");
  });
});

describe("order 1044 (the 70 đồng bug)", () => {
  // Exactly the data the old POS sent: the price is the string "35.000".
  const order1044: RawOrder = {
    id: 1044,
    createdAt: "2026-11-03T10:30:00+07:00",
    source: "old-pos",
    customer: { name: "Lê Chi", phone: "0901000004" },
    items: [{ name: "Salt coffee", price: "35.000", quantity: 2 }],
  };

  test("2 salt coffees at 35,000 cost 70,000 đồng", () => {
    expect(orderTotal(normalizeOrder(order1044))).toBe(70000);
  });
});

describe("the real data in data/orders.json", () => {
  const rawOrders: RawOrder[] = JSON.parse(readFileSync(new URL("../data/orders.json", import.meta.url), "utf8"));

  test("totals of all four orders", () => {
    const totals = listOrders(rawOrders.map(normalizeOrder)).map(({ id, total }) => ({ id, total }));
    expect(totals).toEqual([
      { id: 1041, total: 83000 }, // 2 iced milk coffees at 29,000 + 1 egg banh mi at 25,000
      { id: 1042, total: 32000 },
      { id: 1043, total: 25000 },
      { id: 1044, total: 70000 },
    ]);
  });
});

describe groups tests on the same subject, so when you read the results you know immediately which part each test belongs to. test (also available as it) is one test case. expect(value).toBe(expected) compares two values with Object.is, which suits numbers and strings, while toEqual compares objects and arrays field by field.

The parsePrice block uses test.each to run the same test with several sets of data. Every row has a comment saying why it exists. That's a habit I picked up working on Tech Notes: three months later, the comment tells you which row is a real bug you hit and which is just being careful. %j and %d in the test name are replaced with each row's values, so every case shows up as its own test.

The second group matters just as much. The four "rejects" rows lock in a design decision from the previous post: when the format is unfamiliar, throw an error instead of guessing. If someone later "improves" parsePrice to accept "35,000" as well, the test turns red, and that person has to answer for themselves whether the comma means thousands or decimals before they change the test.

The order 1044 block uses exactly the data the old machine sent, written directly in the test file. I wrote it inline rather than reading orders.json because the data file may change, while this test must always tell the story of the 70 đồng bug.

The last block checks all four orders along the exact path the server uses, from raw data through normalizeOrder to listOrders. The expected results are numbers I worked out by hand from the price list.

Running the tests and reading the results

npx vitest run src --reporter=verbose
 ✓ src/orders.test.ts > parsePrice > reads 35000 as 35000 2ms
 ✓ src/orders.test.ts > parsePrice > reads "35.000" as 35000 0ms
 ✓ src/orders.test.ts > parsePrice > reads "1.250.000" as 1250000 0ms
 ✓ src/orders.test.ts > parsePrice > reads " 35.000 " as 35000 0ms
 ✓ src/orders.test.ts > parsePrice > reads "35000" as 35000 0ms
 ✓ src/orders.test.ts > parsePrice > rejects "35,000" instead of guessing 1ms
 ✓ src/orders.test.ts > parsePrice > rejects "35.00" instead of guessing 0ms
 ✓ src/orders.test.ts > parsePrice > rejects "35k" instead of guessing 0ms
 ✓ src/orders.test.ts > parsePrice > rejects "" instead of guessing 1ms
 ✓ src/orders.test.ts > order 1044 (the 70 đồng bug) > 2 salt coffees at 35,000 cost 70,000 đồng 0ms
 ✓ src/orders.test.ts > the real data in data/orders.json > totals of all four orders 1ms
 Test Files  1 passed (1)
      Tests  11 passed (11)

I added --reporter=verbose to see each test's name; a plain npm test only prints the summary. Look at the line Tests 11 passed (11): nine cases for parsePrice, one for order 1044 and one for all four orders.

Eleven green ticks feel good, but they don't say much yet. A test is only worth something if it turns red when the code is wrong. The real question is: if the bug comes back, does the test catch it?

Breaking the code on purpose: does the test catch it?

I'll play a colleague a few months from now. They read normalizeOrder, see that prices on the website are always numbers, and decide parsePrice is redundant. They change one line in src/orders.ts:

src/orders.ts (broken version, only for this experiment)
export function normalizeOrder(raw: RawOrder): Order {
  return {
    ...raw,
    items: raw.items as OrderItem[], // "prices are already numbers, parsePrice is redundant"
  };
}

TypeScript doesn't object, because as OrderItem[] tells the compiler to trust that the type is right. Run the tests again:

npm test (after removing parsePrice)
 ❯ src/orders.test.ts (11 tests | 2 failed) 14ms
   ❯ order 1044 (the 70 đồng bug) (1)
     × 2 salt coffees at 35,000 cost 70,000 đồng 5ms
   ❯ the real data in data/orders.json (1)
     × totals of all four orders 3ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/orders.test.ts > order 1044 (the 70 đồng bug) > 2 salt coffees at 35,000 cost 70,000 đồng
AssertionError: expected 70 to be 70000 // Object.is equality

- Expected
+ Received

- 70000
+ 70

 ❯ src/orders.test.ts:38:51
     36|
     37|   test("2 salt coffees at 35,000 cost 70,000 đồng", () => {
     38|     expect(orderTotal(normalizeOrder(order1044))).toBe(70000);
       |                                                   ^
     39|   });
     40| });

The part I cut off after this is the second failure, which also shows order 1044 with a "total" of 70 instead of 70000. Read the message from the top. The FAIL line tells you which test went red, and because the test name tells the story, the colleague understands what just happened without asking anyone. The - Expected and + Received lines put the two numbers side by side. The code snippet at the end points to the exact line that did the comparison.

The seven parsePrice tests are still green, because the function itself didn't change; it just isn't called anymore. That's why I also test the whole path from raw data to total, instead of stopping at tests for each small function.

And the badly written test in wrong-tests/? I ran it against the broken code, and it was still green. A test that never turns red protects nothing.

The fix and the tests are still only on my machine

There are now 11 tests that run in under a second, and anyone who brings back the 70 đồng bug will see red. There's just one problem. The whole fix, parsePrice, normalizeOrder and the test file, is still only on my machine. My colleagues don't have it, and neither does the shop's server.

Getting it into the shared code properly means creating a branch, splitting the commits so they're easy to read, and opening a pull request so someone else can review it before it's merged. That's the subject of the next post. You can follow the whole story in the series Taking over a legacy project. If you want to write clear commits starting now, the post on how to write good commit messages has a few templates, and the interview question about unit tests is in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.