Bài 12 · Vận dụng · 22 phút
Kiểm thử với node:test
Biên soạn bởi Nguyễn Anh Tuấn
Kiểm thử (testing) với node:test tích hợp sẵn: assertions, test bất đồng bộ (async), và đo độ phủ code (coverage) mà không cần cài thư viện ngoài.
Mười bài qua, bạn đã viết kha khá đồ thật: một CLI ở bài Process, môi trường & công cụ CLI, CRUD với SQLite ở bài Cơ sở dữ liệu với node:sqlite… Mỗi lần sửa một hàm, mèo con lấy gì bảo đảm chỗ khác chưa vỡ? "Chạy thử tay" - kiểm thử (test) thủ công - có hai điểm chết: không lặp lại được y hệt, và không tự kêu lên khi kết quả lệch.
Tin tốt: từ Node 18 (ổn định từ Node 20), Node mang sẵn một test runner - bộ máy tìm test, chạy test, báo đậu/rớt: module node:test + lệnh node --test, không cài thêm gói nào. Nguyên liệu dễ test nhất là hàm thuần - cùng đầu vào cho cùng đầu ra, không đụng file hay mạng:
kho-ca.js - lõi thuần của kho cá 🐟
// Lõi thuần: chỉ nhận dữ liệu, trả kết quả - không đọc file, không in gì
export function tinhTongCa(cacGio) {
return cacGio.reduce((tong, gio) => tong + gio, 0);
} Một bài test = gọi hàm với đầu vào định trước + một assertion - câu lệnh khẳng định "kết quả phải thế này, sai thì hô lên":
kho-ca.test.js - dự án ESM (package.json có type: module); chạy: node --test kho-ca.test.js (thời gian làm tròn; lược vài dòng ℹ)
import { test } from "node:test";
import assert from "node:assert/strict";
import { tinhTongCa } from "./kho-ca.js";
test("cộng đúng số cá trong các giỏ", () => {
assert.equal(tinhTongCa([3, 5, 2]), 10);
});
test("kho rỗng thì tổng là 0", () => {
assert.equal(tinhTongCa([]), 0);
}); Kết quả khi chạy
✔ cộng đúng số cá trong các giỏ (0.8ms) ✔ kho rỗng thì tổng là 0 (0.2ms) ℹ tests 2 ℹ pass 2 ℹ fail 0
- ▸Test tự động lặp lại được hàng nghìn lần y hệt và tự báo đậu/rớt - "chạy thử tay" không có cả hai.
- ▸node:test + node --test có sẵn trong Node: không cài gì, ở đâu có Node là test chạy.
- ▸Hàm thuần là nguyên liệu test dễ nhất - triết lý "tách lõi thuần khỏi UI" từ khoá Cơ bản chính là để code TEST ĐƯỢC.
Đồ nghề nằm ở hai module: node:test cho test và describe (gom test thành nhóm có tên), node:assert/strict cho assertion. Hai assertion gõ nhiều nhất: equal - so sánh bằng ===, hợp với số/chuỗi/boolean; và deepEqual - so sánh sâu (deep equality): đi vào từng thuộc tính, vì hai object "trông giống nhau" vẫn là hai hộp khác nhau trong bộ nhớ.
giai-phau.test.js - chạy lên: cả hai ✔, describe hiện thành nhóm ▶ và được đếm là 1 suite
import { test, describe } from "node:test";
import assert from "node:assert/strict";
describe("equal và deepEqual", () => {
test("equal: so sánh số, chuỗi, boolean", () => {
assert.equal(2 + 3, 5);
});
test("deepEqual: so sánh object theo NỘI DUNG", () => {
const a = { ten: "Mướp", soCa: 3 };
const b = { ten: "Mướp", soCa: 3 };
assert.notEqual(a, b); // hai object là hai "hộp" riêng → equal sẽ trượt
assert.deepEqual(a, b); // nhưng nội dung sâu bên trong giống hệt → đậu
});
}); Kỹ năng quan trọng không kém viết test: đọc test đỏ. Cố ý làm sai một bài xem Node báo gì:
sai-co-y.test.js - khối recap cuối output của node --test (lược phần đầu và stack trace); đọc xong nhớ XOÁ file
import { test } from "node:test";
import assert from "node:assert/strict";
test("cố ý sai để tập đọc thông báo lỗi", () => {
assert.deepEqual({ ten: "Mướp", soCa: 3 }, { ten: "Mướp", soCa: 4 });
}); Kết quả khi chạy
test at sai-co-y.test.js:4:1
✖ cố ý sai để tập đọc thông báo lỗi (2.8ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
{
+ soCa: 3,
- soCa: 4,
ten: 'Mướp'
}Cặp dòng quý nhất nằm trong diff: dấu + là actual - thứ code trả về thật; dấu - là expected - thứ test kỳ vọng (ở đây soCa thật là 3, kỳ vọng 4). Có test đỏ, node --test thoát với exit code 1 - máy chủ CI chỉ cần nhìn số đó là biết chặn bản lỗi. Vài assertion khác hay gặp: assert.ok(x) - x phải truthy; assert.throws(fn) - hàm đồng bộ fn phải ném lỗi; assert.match(chuoi, regex) - chuỗi khớp mẫu; còn bản async của throws là assert.rejects - gặp ngay bước sau.
Nửa số hàm bạn viết trong khoá này là async. Test chúng không cần nghi lễ gì: khai báo callback của test là async rồi await như code thường - runner đợi Promise xong mới chấm. Còn con đường PHẢI ném lỗi - thứ hay bị bỏ quên nhất - có assert.rejects:
kho-file.js - lõi async đọc kho cá từ đĩa
import { readFile } from "node:fs/promises";
export async function docKhoCa(duongDan) {
const noiDung = await readFile(duongDan, "utf8");
return JSON.parse(noiDung);
} bat-dong-bo.test.js - chạy từ thư mục dự án, cạnh đó có sẵn file kho-ca.json với trường tongCa bằng 10 (lược vài dòng ℹ; thời gian làm tròn)
import { test } from "node:test";
import assert from "node:assert/strict";
import { docKhoCa } from "./kho-file.js";
import { tinhTongCa } from "./kho-ca.js";
test("đọc được kho cá từ file JSON", async () => {
const kho = await docKhoCa("kho-ca.json");
assert.equal(kho.tongCa, 10);
});
test("file không tồn tại thì PHẢI ném lỗi", async () => {
await assert.rejects(() => docKhoCa("khong-co-dau.json"), { code: "ENOENT" });
});
test("tinhTongCa - gom các trường hợp", async (t) => {
await t.test("một giỏ duy nhất", () => assert.equal(tinhTongCa([7]), 7));
await t.test("giỏ 0 con cá vẫn cộng đúng", () => assert.equal(tinhTongCa([4, 0, 6]), 10));
}); Kết quả khi chạy
✔ đọc được kho cá từ file JSON (6.2ms) ✔ file không tồn tại thì PHẢI ném lỗi (2.3ms) ▶ tinhTongCa - gom các trường hợp ✔ một giỏ duy nhất (0.3ms) ✔ giỏ 0 con cá vẫn cộng đúng (0.1ms) ✔ tinhTongCa - gom các trường hợp (0.9ms) ℹ tests 5 ℹ pass 5
Cụm cuối là subtest: test lồng trong test. Callback của test cha nhận tham số t, mỗi t.test(...) sinh một test con - tiện gom các trường hợp của cùng một hàm, output thụt dòng theo đúng cấu trúc.
- ▸Hàm async: cứ await ngay trong test - runner đợi Promise xong mới chấm điểm.
- ▸assert.rejects khẳng định một Promise PHẢI lỗi (soi được chi tiết như mã ENOENT) - nhớ await chính nó, quên thì khẳng định có thể chưa kịp chạy khi test đã xong.
- ▸Subtest t.test cũng phải await; cha và con đều được đếm trong tổng ℹ tests.
Hàm thuần test thẳng được, nhưng code thật luôn có phần biên: đọc đĩa, gọi mạng, chờ đồng hồ. Test mà đụng biên thật thì chậm, dễ hỏng vặt - và có cái không chờ nổi: ai đợi 24 giờ cho một setTimeout? Giải pháp là mock - bản giả có kiểm soát, đứng thay một mảnh thật trong lúc test. node:test tích hợp sẵn ba món:
mock.test.js - dòng ExperimentalWarning là Node v22 in thật, số PID trong (node:…) mỗi lần mỗi khác (lược dòng gợi ý --trace-warnings, vài dòng ℹ; thời gian làm tròn)
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
test("mock.fn: đếm số lần gọi và xem đối số", () => {
const baoTin = mock.fn();
baoTin("Mướp");
baoTin("Tam Thể", 3);
assert.equal(baoTin.mock.callCount(), 2);
assert.deepEqual(baoTin.mock.calls[1].arguments, ["Tam Thể", 3]);
});
test("mock.method: thay tạm fs để khỏi đụng đĩa thật", (t) => {
t.mock.method(fs, "readFileSync", () => '{ "tongCa": 99 }');
const kho = JSON.parse(fs.readFileSync("kho-gia.json", "utf8"));
assert.equal(kho.tongCa, 99);
assert.equal(fs.readFileSync.mock.callCount(), 1);
}); // hết test này, fs.readFileSync tự về bản thật
test("mock timers: tua nhanh 60 giây trong nháy mắt", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let daChoXong = false;
setTimeout(() => (daChoXong = true), 60_000);
t.mock.timers.tick(60_000); // tua đồng hồ ảo đi 60 giây - 0 giây thật
assert.equal(daChoXong, true);
}); Kết quả khi chạy
(node:6856) ExperimentalWarning: The MockTimers API is an experimental feature and might change at any time ✔ mock.fn: đếm số lần gọi và xem đối số (2.1ms) ✔ mock.method: thay tạm fs để khỏi đụng đĩa thật (0.4ms) ✔ mock timers: tua nhanh 60 giây trong nháy mắt (0.7ms) ℹ tests 3 ℹ pass 3
Trung thực: mock timers còn experimental
- ▸mock.fn() = hàm giả ghi sổ: đếm số lần gọi (callCount), soi đối số từng cuộc (mock.calls).
- ▸t.mock.method thay tạm method trên object thật và TỰ trả lại bản gốc khi test xong.
- ▸Chỉ mock phần biên (đĩa, mạng, đồng hồ); logic của mình thì tách thuần để test thẳng.
Đặt tên theo quy ước *.test.js (nhận cả .test.mjs / .test.cjs) thì chỉ cần gõ node --test - runner tự quét thư mục và chạy tất cả. Đang sửa dở, muốn chạy đúng vài test liên quan? Lọc theo tên bằng --test-name-pattern="rỗng". Nhưng 12 test xanh thì xanh được bao nhiêu phần code? Độ phủ (coverage) đo "test đã chạy qua những dòng nào". Để thấy nó lợi hại, thêm vào kho-ca.js một hàm chưa kịp viết test:
thêm vào cuối kho-ca.js
// Hàm mới thêm sáng nay, CHƯA kịp viết test
export function datMua(cacGio, soCa) {
if (soCa <= 0) throw new RangeError("phai mua it nhat 1 con ca");
return [...cacGio, soCa];
} chạy cả bộ kèm đo coverage - 4 file *.test.js được tự tìm thấy (lược danh sách ✔ và vài dòng ℹ giữa)
node --test --experimental-test-coverage Kết quả khi chạy
ℹ tests 12 ℹ pass 12 ℹ start of coverage report ℹ -------------------------------------------------------------------- ℹ file | line % | branch % | funcs % | uncovered lines ℹ -------------------------------------------------------------------- ℹ bat-dong-bo.test.js | 100.00 | 100.00 | 100.00 | ℹ giai-phau.test.js | 100.00 | 100.00 | 100.00 | ℹ kho-ca.js | 70.00 | 100.00 | 66.67 | 8-10 ℹ kho-ca.test.js | 100.00 | 100.00 | 100.00 | ℹ kho-file.js | 100.00 | 100.00 | 100.00 | ℹ mock.test.js | 100.00 | 100.00 | 100.00 | ℹ -------------------------------------------------------------------- ℹ all files | 96.51 | 100.00 | 95.00 | ℹ -------------------------------------------------------------------- ℹ end of coverage report
Đọc bảng (bảng tính cả chính các file test): kho-ca.js chỉ còn 70% số dòng, 66.67% số hàm, cột cuối chỉ thẳng 8-10 - đúng ba dòng thân của datMua chưa được test nào chạy tới (phủ nó là bài tập 1 bên dưới). Chữ experimental trong tên cờ không phải trang trí: trên Node 22, đo coverage vẫn là tính năng thử nghiệm - số liệu dùng được, nhưng cờ và định dạng bảng có thể đổi ở phiên bản Node sau. Cuối cùng, chọn công cụ cho đúng việc:
| Tình huống | Chọn gì |
|---|---|
| Backend Node thuần, CLI, thư viện nhỏ | node:test - không thêm dependency |
| Front-end cần DOM giả; dự án Vite/SvelteKit (như chính site này) | Vitest |
| Monorepo lớn, cần hệ plugin/snapshot dày dạn | Vitest / Jest |
Tiếp theo: ráp tất cả thành một dịch vụ thật
Câu hỏi thường gặp
Tuỳ dự án. Backend Node thuần, CLI, thư viện nhỏ: node:test là đủ - không thêm dependency nào, ở đâu có Node là test chạy. Dự án front-end cần DOM giả (jsdom), test component, monorepo nhiều workspace: Vitest/Jest tiện hơn hẳn. Chính nền tảng Mèo Ham Học bạn đang học chạy hàng trăm bài test bằng Vitest - vì nó là một dự án Vite. Quan trọng nhất: kỹ năng test/assertion/mock học hôm nay chuyển nguyên vẹn sang mọi framework.
Bản thường so sánh kiểu lỏng: assert.equal(1, "1") ĐẬU vì dùng ==. Bản strict làm equal nghĩa là ===, còn deepEqual thành so sánh sâu nghiêm ngặt - 1 và "1" là khác nhau. Test mà dễ dãi thì có cũng như không, nên bài này (và chính tài liệu Node) khuyên luôn import từ node:assert/strict.
Vì hàm thuần - cùng đầu vào luôn cho cùng đầu ra, không đụng file/mạng/DOM - là loại code test sướng nhất: không cần dựng môi trường, không cần mock. Mọi mô phỏng trên site này đều có lõi thuần kèm bộ test riêng; bài Dự án của khoá Cơ bản cũng tách lõi To-Do khỏi UI vì đúng lý do đó. Viết code dễ test và viết code tốt, đa số trường hợp, là một việc.
Bài Dự án: app web nhỏ (khoá JS Cơ bản) →Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Chạy node --test và một test dùng assert.deepEqual thất bại. node --test thoát với exit code nào, và vì sao điều đó quan trọng?
- 1
Phủ nốt datMua
Viết test cho
datMuaở Bước 5: một test đường thành công (kho mới có thêm giỏ, kho CŨ không bị sửa), một test dùngassert.throwskhẳng địnhsoCabằng 0 hoặc âm thì némRangeError.Hoàn thành khi:
node --testxanh; chạy lại coverage thấykho-ca.jsvề 100% cột funcs, cột "uncovered lines" trống. - 2
Tập đọc diff khi test đỏ
Lấy một test
deepEqualđang xanh, cố ý đổi vế kỳ vọng lệch một thuộc tính rồi chạy lại. Đọc cặp dòng+/-, rồi chạyecho $?ngay sau đó.Hoàn thành khi: Chỉ đúng
+= actual (code trả về),-= expected (test kỳ vọng);echo $?in ra1. Sửa lại cho xanh trước khi làm bài khác. - 3
Đường lỗi thứ hai của docKhoCa
Tạo
kho-hong.jsonchứa JSON sai cú pháp (ví dụ thiếu ngoặc đóng). Viết test dùngassert.rejectskhẳng địnhdocKhoCa("kho-hong.json")némSyntaxError.Hoàn thành khi: Test xanh; mèo con giải thích được vì sao lỗi là
SyntaxError(JSON.parse hỏng) chứ không phảiENOENT(file vẫn tồn tại). - 4
Hẹn cho cá ăn sau 24 giờ
Viết hàm
nhacChoCaAn(thongBao)gọithongBao()sau 24 giờ bằngsetTimeoutvới 86_400_000 mili-giây. Viết test dùngt.mock.timers+tickkhẳng định callback đã chạy.Hoàn thành khi: Cả bộ test xong trong chưa đầy một giây thật - dù lời hẹn là 24 giờ.
- 5
Lọc một test giữa cả bộ
Trong dự án kho cá, chạy
node --test --test-name-pattern="rỗng"rồi so dòngℹ testsvới lần chạy đủ cả bộ.Hoàn thành khi: Chỉ test có chữ "rỗng" thực sự chạy; con số
ℹ teststụt hẳn so với 12 (file không có test khớp chỉ hiện một dòng mang tên file). - 6
Trả nợ test cho CLI bài trước
Quay lại CLI mèo con dựng ở bài Process & CLI: tách phần phân tích đối số thành hàm thuần (nếu chưa), rồi viết 3 test cho 3 cách gõ lệnh khác nhau.
Hoàn thành khi: 3 test xanh mà không phải chạy CLI thật lần nào - vì phần lõi đã là hàm thuần.