← Lập trình JavaScript nâng cao

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 testdescribe (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 +actual - thứ code trả về thật; dấu -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

test(), assert, mock.fn, mock.method đã ổn định; riêng đồng hồ ảo (MockTimers) trên Node 22 vẫn là tính năng thử nghiệm - Node in hẳn ExperimentalWarning như trên, API có thể đổi ở phiên bản sau. Dùng được, nhưng đừng ngạc nhiên nếu một ngày nâng Node phải sửa vài dòng test.
  • 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ốngChọ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ạnVitest / Jest

Tiếp theo: ráp tất cả thành một dịch vụ thật

Đồ nghề đã đủ một vòng: HTTP & REST, SQLite, worker chạy nền, process & CLI - và giờ là test để giữ mọi thứ không vỡ khi sửa. Bài cuối Dự án: dịch vụ backend nhỏ sẽ ráp toàn bộ thành một dịch vụ hoàn chỉnh: REST API + node:sqlite + một worker chạy nền - kèm bộ test bảo vệ từng phần lõi, đúng kiểu vừa học.

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"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) →

Không. Coverage chỉ đo "test đã CHẠY QUA dòng nào", không đo assertion có đủ chặt không - một bài test gọi hàm rồi chẳng khẳng định gì vẫn đẩy coverage lên 100%. Hãy dùng coverage như la bàn chỉ vùng trống (hàm nào chưa test nào ghé tới), đừng dùng như điểm số để tối đa hoá bằng mọi giá.

Khi cái bị thay chính là logic bạn cần kiểm tra - mock cả lõi tính toán thì test chỉ còn kiểm tra… chính cái mock. Quy tắc gọn: logic của mình thì tách thuần để test thẳng; mock chỉ dành cho phần biên không kiểm soát được trong test (đĩa, mạng, đồng hồ, API bên ngoài).

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

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. 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ùng assert.throws khẳng định soCa bằng 0 hoặc âm thì ném RangeError.

    Hoàn thành khi: node --test xanh; chạy lại coverage thấy kho-ca.js về 100% cột funcs, cột "uncovered lines" trống.

  2. 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ạy echo $? ngay sau đó.

    Hoàn thành khi: Chỉ đúng + = actual (code trả về), - = expected (test kỳ vọng); echo $? in ra 1. Sửa lại cho xanh trước khi làm bài khác.

  3. 3

    Đường lỗi thứ hai của docKhoCa

    Tạo kho-hong.json chứa JSON sai cú pháp (ví dụ thiếu ngoặc đóng). Viết test dùng assert.rejects khẳng định docKhoCa("kho-hong.json") ném SyntaxError.

    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ải ENOENT (file vẫn tồn tại).

  4. 4

    Hẹn cho cá ăn sau 24 giờ

    Viết hàm nhacChoCaAn(thongBao) gọi thongBao() sau 24 giờ bằng setTimeout với 86_400_000 mili-giây. Viết test dùng t.mock.timers + tick khẳ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. 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 ℹ tests vớ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ố ℹ tests tụ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. 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.