Vitest là gì? Viết test đầu tiên để khóa lỗi đơn hàng 70 đồng
Vitest là gì và viết test đầu tiên ra sao: khóa lỗi giá "35.000" của đơn hàng quán Mộc, dùng test.each cho nhiều trường hợp, và tránh kiểu test luôn xanh.
Vitest là gì? Đó là một công cụ chạy test cho JavaScript và TypeScript: bạn viết các đoạn code kiểm tra "đầu vào này phải ra kết quả kia", và Vitest chạy chúng mỗi khi code thay đổi. Test tốt sẽ đỏ ngay khi lỗi cũ quay lại. Cùng mình viết test đầu tiên để khóa lỗi đơn 1044 nhé!
Lỗi 70 đồng đã được sửa, vậy còn thiếu gì?
Ở bài trước, mình dùng breakpoint trong VS Code để tìm ra vì sao đơn 1044 của quán Mộc, gồm hai ly cà phê muối, có tổng tiền 70 đồng. Máy bán hàng cũ của quán ghi giá dạng chuỗi "35.000", và JavaScript đọc dấu chấm thành dấu thập phân nên "35.000" * 2 ra 70. Bản sửa là hàm parsePrice chuẩn hóa giá ngay khi đọc dữ liệu. (Quán Mộc và dữ liệu đơn hàng trong series là minh họa.)
Bằng chứng duy nhất cho bản sửa là một lần gọi curl và nhìn bằng mắt. Chủ quán hỏi đúng câu cần hỏi: lỡ sau này ai đó sửa code làm hỏng lại thì sao?
Test trả lời câu đó. Nó không chỉ chứng minh hôm nay code đúng, mà còn tự kiểm tra lại ở mọi lần thay đổi sau này, kể cả khi người sửa code không biết chuyện đơn 1044 từng sai.
Code đang cần kiểm tra là gì?
Toàn bộ logic nằm trong src/orders.ts. Mình đưa nguyên file vào đây để bạn gõ lại được mà không cần đọc bài trước. Có bốn hàm đáng chú ý. parsePrice đổi giá của máy cũ thành số, normalizeOrder gọi parsePrice cho từng món của một đơn, orderTotal tính tổng tiền, còn listOrders dựng danh sách để trang quản lý hiển thị.
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" | "may-cu";
// Đơn từ máy bán hàng cũ (source "may-cu") bán tại quầy có thể không có khách hàng.
customer?: Customer;
items: OrderItem[];
};
// Dữ liệu đọc từ file, chưa kiểm tra. Máy bán hàng cũ ghi giá dạng chuỗi có dấu chấm ngăn hàng nghìn ("35.000").
export type RawOrder = Omit<Order, "items"> & {
items: { name: string; price: number | string; quantity: number }[];
};
// "35.000" → 35000. Định dạng lạ thì báo lỗi ngay, không đoán.
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(`Giá không hợp lệ: "${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 "KHÁCH TẠI QUẦY";
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,
khach: customerLabel(order),
tong: orderTotal(order),
}));
}Dữ liệu là bốn đơn của một buổi sáng. Hai đơn đầu đến từ website, hai đơn sau được nhập ở máy bán hàng cũ tại quầy, và đơn 1044 có giá dạng chuỗi:
[
{
"id": 1041,
"createdAt": "2026-11-03T08:12:00+07:00",
"source": "web",
"customer": { "name": "Nguyễn An", "phone": "0901000001" },
"items": [
{ "name": "Cà phê sữa", "price": 29000, "quantity": 2 },
{ "name": "Bánh mì trứng", "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": "Bạc xỉu", "price": 32000, "quantity": 1 }]
},
{
"id": 1043,
"createdAt": "2026-11-03T10:05:00+07:00",
"source": "may-cu",
"items": [{ "name": "Cà phê đen", "price": 25000, "quantity": 1 }]
},
{
"id": 1044,
"createdAt": "2026-11-03T10:30:00+07:00",
"source": "may-cu",
"customer": { "name": "Lê Chi", "phone": "0901000004" },
"items": [{ "name": "Cà phê muối", "price": "35.000", "quantity": 2 }]
}
]Dự án còn một file src/server.ts trả danh sách đơn qua HTTP, nhưng bài này không cần tới nó. Test gọi thẳng các hàm trong orders.ts, không cần bật máy chủ.
Vì sao chọn Vitest?
Dự án này viết bằng TypeScript và chạy bằng Node.js. Vitest chạy được file .ts ngay mà không cần cấu hình gì thêm, cú pháp test, expect giống Jest nên tài liệu và ví dụ trên mạng dùng lại được. Nó cũng là thứ chính blog Tech Notes đang dùng, với hơn 500 test chạy trong vài giây.
Node.js cũng có sẵn trình chạy test (node --test), và với một dự án nhỏ như thế này nó hoàn toàn đủ dùng. Mình chọn Vitest vì thông báo lỗi của nó dễ đọc hơn cho người mới, như bạn sẽ thấy ở mục sau, và vì khi dự án lớn lên thành web app có React, bạn không phải đổi công cụ.
Cài đặt chỉ cần một gói, và thêm hai lệnh vào package.json:
{
"name": "don-hang-moc-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 chạy mọi file có đuôi .test.ts trong thư mục src một lần rồi thoát, phù hợp để chạy trước khi commit hoặc trên máy chủ CI. vitest không có run thì chạy ở chế độ watch: mỗi lần bạn lưu file, các test liên quan tự chạy lại. Mình hay mở chế độ này ở một terminal riêng khi đang sửa code.
Test đầu tiên của mình đã sai thế nào?
Lần đầu viết test cho orderTotal, mình viết thế này. Mình để nó trong thư mục test-sai/ của demo để bạn so sánh, đừng dùng cách viết này:
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("tổng tiền đơn 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);
}); Test Files 1 passed (1)
Tests 1 passed (1)Test xanh. Nhưng bạn nhìn kỹ sẽ thấy nó đọc dữ liệu thô mà không qua normalizeOrder, nên giá vẫn là chuỗi "35.000", và orderTotal trả về 70. Kết quả mong đợi cũng là 70, vì dòng expected tính lại bằng đúng công thức nằm trong orderTotal. Test so sánh code với chính nó, nên nó xanh dù tổng tiền sai.
Lỗi này phổ biến hơn bạn nghĩ, nhất là khi để AI viết test hộ. Quy tắc mình rút ra là kết quả mong đợi phải là một con số bạn biết đúng từ bên ngoài code. Ở đây, hai ly cà phê muối giá 35.000 đồng là 70.000 đồng. Con số đó đến từ đầu bạn và từ bảng giá của quán, không đến từ hàm đang được kiểm tra.
Viết test để khóa lỗi đơn 1044 thế nào?
Đây là file test thật, đặt cạnh file code với tên src/orders.test.ts. Mình đọc qua từng khối ở bên dưới:
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], // đơn từ web: giá đã là số, giữ nguyên
["35.000", 35000], // máy cũ: dấu chấm ngăn hàng nghìn — đúng lỗi của đơn 1044
["1.250.000", 1250000], // nhiều nhóm nghìn
[" 35.000 ", 35000], // máy cũ đôi khi thêm dấu cách
["35000", 35000], // chuỗi chỉ có chữ số
])("đọc %j thành %d", (raw, expected) => {
expect(parsePrice(raw)).toBe(expected);
});
test.each([
["35,000"], // dấu phẩy: không biết là kiểu Mỹ (35 nghìn) hay kiểu châu Âu (35 phẩy 0)
["35.00"], // nhóm sau dấu chấm không đủ 3 chữ số: có thể là số thập phân
["35k"], // viết tắt: máy cũ không ghi kiểu này, gặp là bất thường
[""], // ô giá bị bỏ trống
])("từ chối %j thay vì đoán", (raw) => {
expect(() => parsePrice(raw)).toThrow("Giá không hợp lệ");
});
});
describe("đơn 1044 (lỗi 70 đồng)", () => {
// Đúng dữ liệu máy cũ đã gửi: giá là chuỗi "35.000".
const don1044: RawOrder = {
id: 1044,
createdAt: "2026-11-03T10:30:00+07:00",
source: "may-cu",
customer: { name: "Lê Chi", phone: "0901000004" },
items: [{ name: "Cà phê muối", price: "35.000", quantity: 2 }],
};
test("2 ly cà phê muối giá 35.000 là 70.000 đồng", () => {
expect(orderTotal(normalizeOrder(don1044))).toBe(70000);
});
});
describe("dữ liệu thật trong data/orders.json", () => {
const rawOrders: RawOrder[] = JSON.parse(readFileSync(new URL("../data/orders.json", import.meta.url), "utf8"));
test("tổng tiền của cả bốn đơn", () => {
const totals = listOrders(rawOrders.map(normalizeOrder)).map(({ id, tong }) => ({ id, tong }));
expect(totals).toEqual([
{ id: 1041, tong: 83000 }, // 2 cà phê sữa 29.000 + 1 bánh mì trứng 25.000
{ id: 1042, tong: 32000 },
{ id: 1043, tong: 25000 },
{ id: 1044, tong: 70000 },
]);
});
});describe gom các test cùng chủ đề thành một nhóm, để khi đọc kết quả bạn biết ngay test nào thuộc phần nào. test (hoặc tên khác của nó là it) là một trường hợp kiểm tra. expect(giá trị).toBe(mong đợi) so sánh hai giá trị bằng Object.is, phù hợp cho số và chuỗi, còn toEqual so sánh từng trường của object hay mảng.
Khối parsePrice dùng test.each để chạy cùng một test với nhiều bộ dữ liệu. Mỗi dòng có một chú thích nói vì sao dòng đó tồn tại. Đây là thói quen mình học được khi làm Tech Notes: ba tháng sau đọc lại, chú thích cho biết dòng nào là lỗi thật đã gặp, dòng nào chỉ là phòng xa. %j và %d trong tên test được thay bằng giá trị của từng dòng, nên mỗi trường hợp hiện ra như một test riêng.
Nhóm thứ hai đáng nói không kém. Bốn dòng "từ chối" khóa lại một quyết định thiết kế từ bài trước: gặp định dạng lạ thì báo lỗi chứ không đoán. Nếu sau này có người "cải tiến" parsePrice để đọc luôn "35,000", test sẽ đỏ, và người đó phải tự trả lời câu hỏi dấu phẩy nghĩa là nghìn hay thập phân trước khi đổi test.
Khối đơn 1044 dùng đúng dữ liệu máy cũ đã gửi, viết thẳng trong file test. Mình viết thẳng thay vì đọc từ orders.json vì file dữ liệu có thể thay đổi, còn test này phải luôn kể đúng câu chuyện của lỗi 70 đồng.
Khối cuối kiểm tra cả bốn đơn qua đúng đường đi mà máy chủ dùng, từ dữ liệu thô qua normalizeOrder tới listOrders. Kết quả mong đợi là những con số mình tự tính bằng tay theo bảng giá.
Chạy test và đọc kết quả
RUN v5.1.0 C:/Users/NCpc/Desktop/conghv3/web/codex-practise/tech-notes-demo/ky-nang/03-test-vitest
✓ src/orders.test.ts > parsePrice > đọc 35000 thành 35000 2ms
✓ src/orders.test.ts > parsePrice > đọc "35.000" thành 35000 0ms
✓ src/orders.test.ts > parsePrice > đọc "1.250.000" thành 1250000 0ms
✓ src/orders.test.ts > parsePrice > đọc " 35.000 " thành 35000 0ms
✓ src/orders.test.ts > parsePrice > đọc "35000" thành 35000 0ms
✓ src/orders.test.ts > parsePrice > từ chối "35,000" thay vì đoán 1ms
✓ src/orders.test.ts > parsePrice > từ chối "35.00" thay vì đoán 0ms
✓ src/orders.test.ts > parsePrice > từ chối "35k" thay vì đoán 0ms
✓ src/orders.test.ts > parsePrice > từ chối "" thay vì đoán 1ms
✓ src/orders.test.ts > đơn 1044 (lỗi 70 đồng) > 2 ly cà phê muối giá 35.000 là 70.000 đồng 0ms
✓ src/orders.test.ts > dữ liệu thật trong data/orders.json > tổng tiền của cả bốn đơn 1ms
Test Files 1 passed (1)
Tests 11 passed (11)Mình thêm --reporter=verbose để thấy tên từng test, còn npm test bình thường chỉ in phần tổng kết. Bạn nhìn dòng Tests 11 passed (11): chín trường hợp của parsePrice, một cho đơn 1044 và một cho cả bốn đơn.
Mười một dấu tích xanh thì dễ chịu, nhưng chưa nói lên nhiều. Test chỉ có giá trị nếu nó đỏ khi code sai. Câu hỏi thật là: lỗi quay lại thì test có bắt được không?
Thử làm hỏng code: test có bắt được không?
Mình đóng vai một đồng nghiệp vài tháng sau. Đồng nghiệp đọc normalizeOrder, thấy giá trên website lúc nào cũng là số, và nghĩ parsePrice là thừa. Họ đổi một dòng trong src/orders.ts:
export function normalizeOrder(raw: RawOrder): Order {
return {
...raw,
items: raw.items as OrderItem[], // "giá đã là số rồi, parsePrice thừa"
};
}TypeScript không phản đối, vì as OrderItem[] bảo trình biên dịch tin là kiểu đúng. Chạy lại test:
❯ src/orders.test.ts (11 tests | 2 failed) 14ms
❯ đơn 1044 (lỗi 70 đồng) (1)
× 2 ly cà phê muối giá 35.000 là 70.000 đồng 6ms
❯ dữ liệu thật trong data/orders.json (1)
× tổng tiền của cả bốn đơn 3ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/orders.test.ts > đơn 1044 (lỗi 70 đồng) > 2 ly cà phê muối giá 35.000 là 70.000 đồng
AssertionError: expected 70 to be 70000 // Object.is equality
- Expected
+ Received
- 70000
+ 70
❯ src/orders.test.ts:38:49
36|
37| test("2 ly cà phê muối giá 35.000 là 70.000 đồng", () => {
38| expect(orderTotal(normalizeOrder(don1044))).toBe(70000);
| ^
39| });
40| });Phần cắt bớt phía sau là lỗi thứ hai, cũng chỉ ra đơn 1044 có "tong": 70 thay vì 70000. Bạn đọc thông báo từ trên xuống. Dòng FAIL cho biết test nào đỏ, và vì tên test kể đúng câu chuyện, người đồng nghiệp hiểu ngay chuyện gì vừa xảy ra mà không cần hỏi ai. Phần - Expected và + Received đặt hai con số cạnh nhau. Ô code cuối chỉ đúng dòng đã so sánh.
Bảy test của parsePrice vẫn xanh, vì bản thân hàm không đổi, nó chỉ không còn được gọi. Đó là lý do mình viết thêm test cho cả đường đi từ dữ liệu thô tới tổng tiền, chứ không dừng ở test cho từng hàm nhỏ.
Còn file test viết sai cách ở test-sai/? Mình chạy nó với code đã bị làm hỏng, và nó vẫn xanh. Một test không bao giờ đỏ thì không bảo vệ được gì.
Bản sửa và test vẫn đang nằm trên máy mình
Giờ đã có 11 test chạy trong chưa đầy một giây, và bất kỳ ai làm hỏng lại lỗi 70 đồng đều sẽ thấy màu đỏ. Chỉ có một vấn đề. Toàn bộ bản sửa, parsePrice, normalizeOrder và file test, vẫn nằm trên máy của mình. Đồng nghiệp chưa có, máy chủ của quán cũng chưa có.
Đưa chúng vào code chung đúng cách nghĩa là tạo branch, chia commit sao cho dễ đọc, rồi mở pull request để người khác review trước khi merge. Đó là chủ đề của bài viết tiếp theo. Bạn theo dõi cả câu chuyện ở series Nhận dự án cũ. Nếu muốn viết commit rõ ràng ngay từ bây giờ, bài viết commit message dễ đọc có sẵn vài mẫu, còn câu hỏi phỏng vấn về unit test có trong ngân hàng câu hỏi phỏng vấn.