Built-in Test Runner
node:test — ครบครันในตัว
หัวข้อที่มีชื่อว่า “node:test — ครบครันในตัว”ตั้งแต่ Node 18 เป็นต้นมา คุณสามารถเขียนและรันการทดสอบได้โดยไม่ต้องพึ่ง third-party dependency เลย โมดูล node:test มี test(), describe(), it(), hooks before/after, และ output แบบ TAP ให้ใช้งาน จับคู่กับ node:assert สำหรับ assertions
โครงสร้างพื้นฐานของการทดสอบ
หัวข้อที่มีชื่อว่า “โครงสร้างพื้นฐานของการทดสอบ”ไฟล์ทดสอบคือไฟล์ .js (หรือ .mjs) ธรรมดาที่ import node:test และ node:assert:
import { test, describe, it } from 'node:test';import assert from 'node:assert/strict';
test('add returns the correct sum', function() { assert.equal(2 + 3, 5);});รันจาก terminal:
node --test math.test.jsหรือรันทั้ง suite ด้วยการค้นหาไฟล์แบบ default:
node --testเมื่อไม่ระบุ path node --test จะค้นหาแบบ recursive ให้ทั้ง *.test.{js,mjs,cjs}, *-test.*, *_test.*, และ test-*.* — รวมถึงทุกไฟล์ที่อยู่ในไดเรกทอรีชื่อ test/ ไม่ว่าไฟล์นั้นจะชื่ออะไรก็ตาม
describe / it — การจัดกลุ่มการทดสอบ
หัวข้อที่มีชื่อว่า “describe / it — การจัดกลุ่มการทดสอบ”describe ใช้จัดกลุ่มการทดสอบที่เกี่ยวข้องกัน ส่วน it เป็น alias ของ test ที่อ่านได้เป็นธรรมชาติมากกว่าเมื่อใช้ภายใน describe:
import { describe, it } from 'node:test';import assert from 'node:assert/strict';
describe('String utilities', function() { it('trim removes leading and trailing whitespace', function() { assert.equal(' hello '.trim(), 'hello'); });
it('split divides by delimiter', function() { assert.deepEqual('a,b,c'.split(','), ['a', 'b', 'c']); });});node --test string-utils.test.js# TAP output:# ok 1 - String utilities > trim removes leading and trailing whitespace# ok 2 - String utilities > split divides by delimiterการทดสอบแบบ Async
หัวข้อที่มีชื่อว่า “การทดสอบแบบ Async”ส่ง async function ให้กับ test หรือ it ได้เลย runner จะ await ให้ — ถ้า promise ถูก reject การทดสอบจะล้มเหลว:
import { test } from 'node:test';import assert from 'node:assert/strict';
test('fetch-like async test', async function() { const result = await Promise.resolve(42); assert.equal(result, 42);});Lifecycle hooks — before / after / beforeEach / afterEach
หัวข้อที่มีชื่อว่า “Lifecycle hooks — before / after / beforeEach / afterEach”before และ after รันครั้งเดียวต่อ describe block หนึ่งบล็อก (หรือครั้งเดียวสำหรับทั้งไฟล์ถ้าอยู่ระดับบนสุด) ส่วน beforeEach/afterEach รันล้อมรอบทุกการทดสอบใน scope นั้น:
import { describe, it, before, after, beforeEach, afterEach } from 'node:test';import assert from 'node:assert/strict';
describe('a resource pool', () => { let pool; before(() => { pool = []; console.log('before: pool created once'); }); after(() => { console.log('after: pool torn down once'); }); beforeEach(() => { pool.push('conn'); console.log('beforeEach: connection added'); }); afterEach(() => { pool.pop(); console.log('afterEach: connection removed'); });
it('has one connection during the test', () => { assert.equal(pool.length, 1); }); it('still has exactly one connection in the next test', () => { assert.equal(pool.length, 1); });});before: pool created oncebeforeEach: connection addedafterEach: connection removedbeforeEach: connection addedafterEach: connection removedafter: pool torn down once▶ a resource pool ✔ has one connection during the test ✔ still has exactly one connection in the next test✔ a resource poolℹ tests 2ℹ suites 1ℹ pass 2ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0before/after รันแค่ครั้งเดียวไม่ว่าใน block จะมีกี่การทดสอบ ส่วน beforeEach/afterEach รีเซ็ต shared state เพื่อไม่ให้การทดสอบรั่วไหลข้ามกัน — สังเกตว่า pool กลับมามีความยาว 1 อีกครั้งในการทดสอบที่ สอง เพราะ afterEach ได้ pop connection ที่การทดสอบแรกเพิ่มไว้ออกไปแล้ว
คู่มือ node:assert/strict
หัวข้อที่มีชื่อว่า “คู่มือ node:assert/strict”// @skip-verify placeholder identifiers (actual/expected/obj1/obj2) — signature reference, not runnableimport assert from 'node:assert/strict';
assert.equal(actual, expected); // == (strict: ===)assert.notEqual(actual, expected); // !==assert.deepEqual(obj1, obj2); // deep structural equalityassert.notDeepEqual(obj1, obj2);assert.throws(fn, /pattern/); // fn must throw matching errorassert.rejects(asyncFn, /pattern/); // async fn must rejectassert.ok(value); // truthy checkควรใช้ node:assert/strict เสมอแทนที่จะใช้ node:assert เพราะ strict variant ใช้ === สำหรับการเปรียบเทียบ scalar และหลีกเลี่ยงความผิดพลาดจาก type coercion
Demo แบบ runnable
หัวข้อที่มีชื่อว่า “Demo แบบ runnable”snippet ด้านล่างเป็น node:test mini-suite ที่ทำงานได้เอง รันในโหมด node เพื่อดู TAP output จริง:
Needs the Node.js runtime — open in StackBlitz to run.
demo นี้ไม่มีแผง “verified output” โดยตั้งใจ: reporter ของ node:test โดยค่าเริ่มต้นพิมพ์ duration_ms ในทุกบรรทัด ซึ่งไม่เท่ากันในแต่ละครั้งที่รัน การแปะตัวเลขจากการรันจริงครั้งหนึ่งลงใน expected จะทำให้แผงนั้นดูแม่นยำเกินจริงแบบผิด ๆ demo ที่ตามมาข้างล่างใช้ mock/assert โดยตรง (ไม่ผ่าน test()) เพื่อให้ output แน่นอน 100% และปลอดภัยที่จะแสดงเป็น verified
Mocking และ snapshot — stable และมีในตัว
หัวข้อที่มีชื่อว่า “Mocking และ snapshot — stable และมีในตัว”| ฟีเจอร์ | สถานะใน Node 24 / 26 |
|---|---|
mock.fn(), mock.method(), mock.timers | Stable |
mock.module() | Experimental — ต้องใช้ --experimental-test-module-mocks และมี warning |
Snapshot testing — t.assert.snapshot(value) | Stable ตั้งแต่ 23.4 |
| Code coverage | Experimental — --experimental-test-coverage |
--watch สำหรับ test | Stable — ไม่ต้องใช้ flag และไม่มี warning |
Test tags — --experimental-test-tag-filter | Early development (26.2+) |
mock.fn — ติดตามการเรียกโดยไม่ต้องใช้ library
หัวข้อที่มีชื่อว่า “mock.fn — ติดตามการเรียกโดยไม่ต้องใช้ library”mock.fn(implementation) ห่อฟังก์ชันแล้วบันทึกทุกการเรียก — argument, ค่าที่ return, และจำนวนครั้ง — ไว้ที่ .mock มันทำงานแบบ standalone ได้ นอกเหนือจาก test() ซึ่งมีประโยชน์สำหรับการเช็กเร็ว ๆ ในหน้านี้เลย:
Output (verified on Node 24)
result: 5
call count: 1
first call args: [ 2, 3 ]
assertion passed: called exactly oncemock.method — กับดักของการ mock ผิดตัวอ้างอิง
หัวข้อที่มีชื่อว่า “mock.method — กับดักของการ mock ผิดตัวอ้างอิง”mock.method(object, 'methodName', impl) แทนที่ object.methodName ในตำแหน่งเดิม มันจะมีผลเฉพาะจุดที่เรียกโดยดูค่าจาก object ตอนเรียกจริง เท่านั้น — reference ที่ถูก destructure ไว้ก่อนหน้านั้นเป็นคนละ binding ที่ mock ไม่แตะต้องเลย:
Output (verified on Node 24)
result: { id: 1, name: 'Ada' }
mock was actually called: 0
assertion passed: mocking db.save missed the real call site entirelymock.module — ยังเป็น experimental บน Node 24
หัวข้อที่มีชื่อว่า “mock.module — ยังเป็น experimental บน Node 24”mock.module() แทนที่ export ทั้งหมดของโมดูล รวมถึง ESM ด้วย — แต่บน Node 24 มันยังอยู่หลัง --experimental-test-module-mocks และพิมพ์ ExperimentalWarning:
// @skip-verify needs --experimental-test-module-mocks, not passed by default hereimport { test, mock } from 'node:test';import assert from 'node:assert/strict';
test('mock.module replaces a module', async () => { mock.module('node:os', { namedExports: { hostname: () => 'fake-host' }, }); const os = await import('node:os'); assert.equal(os.hostname(), 'fake-host');});node --experimental-test-module-mocks mock-os.test.mjs✔ mock.module replaces a module(node:12345) ExperimentalWarning: Module mocking is an experimental feature and might change at any timeℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ถ้าไม่ใส่ flag mock.module จะไม่ใช่ function เลย (TypeError: mock.module is not a function) — มันไม่เงียบแล้วไม่ทำอะไร (no-op) แต่ fail ดังชัดเจน ทำให้ flag ที่ลืมใส่ทำให้ CI ล้มเหลวแบบเห็นได้ชัด แทนที่จะให้ผลบวกลวง
fake timers ช่วยให้ทดสอบ logic ของ retry และ timeout ได้โดยไม่ต้องรอจริง:
import { test, mock } from 'node:test';import assert from 'node:assert/strict';
// your helper — note the setTimeout is scheduled inside a .then(), not synchronouslyfunction retry(fn, options) { return fn().catch(function(err) { if (options.attempts <= 1) throw err; return new Promise(function(resolve) { setTimeout(resolve, options.delayMs); }) .then(function() { return retry(fn, { attempts: options.attempts - 1, delayMs: options.delayMs }); }); });}
test('retries call the fetcher twice', async function(t) { const fetcher = mock.fn(async function() { throw new Error('flaky'); }); mock.timers.enable({ apis: ['setTimeout'] }); // fake setTimeout
const pending = retry(fetcher, { attempts: 2, delayMs: 1000 });
await null; // let the pending .then() run and register the timer mock.timers.tick(1000); // advance the fake clock instantly
await assert.rejects(pending, /flaky/); assert.equal(fetcher.mock.callCount(), 2);
mock.timers.reset();});setTimeout ด้านบนถูกเรียกอยู่ภายใน callback ของ .then() ซึ่งรันเป็น microtask เท่านั้น — ณ ตอนที่บรรทัดถัดไปเรียก tick() แบบ synchronous, timer ตัวนี้ยังไม่ถูกลงทะเบียนเลย await null ช่วยให้ event loop ไป drain microtask ที่ค้างอยู่นั้นก่อน ทำให้ timer ถูกตั้งก่อนที่ tick() จะมาหา ถ้าข้ามขั้นตอนนี้ไป การทดสอบนี้จะค้างตลอดไป เพราะ node:test ไม่มี timeout ต่อ test เป็นค่าเริ่มต้น ควรใส่ --test-timeout=<ms> เสมอ (เช่น node --test --test-timeout=2000) เพื่อให้ความผิดพลาดแบบนี้ล้มเหลวเร็วแทนที่จะทำให้ CI ค้าง
ข้อพิสูจน์ว่าการข้าม await null ทำให้ fake clock ไม่มีอะไรให้เดินหน้าจริง ๆ — ไม่ต้องมี test wrapper ไม่ต้องค้าง แค่ดู state ของ fake timer:
Output (verified on Node 24)
tick() returned -- fake clock advanced, but nothing was scheduled yetถ้าอยู่ใน test() จริง ๆ pending promise ด้านบนจะไม่มีทาง settle เลย await assert.rejects(pending, ...) จะรอมันไปตลอดกาล และ — ถ้าไม่ได้ตั้ง --test-timeout ไว้ — การรัน test ทั้งชุดจะไม่มีวันจบ
Test ที่ผ่านทั้งที่ผิด
หัวข้อที่มีชื่อว่า “Test ที่ผ่านทั้งที่ผิด”test ที่อันตรายที่สุดไม่ใช่ตัวที่ล้มเหลว — แต่คือตัวที่รายงานว่าผ่านทั้งที่ไม่ได้ทดสอบอะไรเลย node:test ไม่ได้จับสิ่งเหล่านี้ให้คุณ คุณต้องรู้ว่าต้องมองหาอะไรตอนอ่านไฟล์ทดสอบ (ของคุณเองหรือที่ AI เขียน)
ไม่มี assertion เลย
หัวข้อที่มีชื่อว่า “ไม่มี assertion เลย”import { test } from 'node:test';
test('checks that createUser sets a default role', async () => { const user = { name: 'Ada' }; // BUG: forgot to call createUser() and check user.role // no assert.* call at all -- the test body just runs to completion});node --test no-assertion.test.mjs✔ checks that createUser sets a default roleℹ tests 1ℹ suites 0ℹ pass 1ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0test ที่ไม่เคยเรียก assert.* (หรือ t.assert.*) เลยไม่มีทางล้มเหลวได้ — มัน “ผ่าน” เพราะไม่ได้ทำอะไรเลย เวลาอ่านไฟล์ทดสอบ ให้เช็กเสมอว่าทุก body ของ it/test จบด้วย assertion อย่างน้อยหนึ่งอันที่ตรวจพฤติกรรมจริงที่มันอ้างว่าทดสอบ
งาน async ที่อยู่รอดเกินอายุของ test — Node 24 จับสิ่งนี้ได้จริง
หัวข้อที่มีชื่อว่า “งาน async ที่อยู่รอดเกินอายุของ test — Node 24 จับสิ่งนี้ได้จริง”// @expect-throw file exits non-zero -- Node 24 reports the leaked async assertion as a whole-file failureimport { test } from 'node:test';import assert from 'node:assert/strict';
test('rejects an invalid user (BUG: missing await/return)', () => { // BUG: no `return` and no `await` in front of the async work below -- // the test function itself returns undefined synchronously, so node:test // considers it "passed" before the promise below ever settles. Promise.resolve().then(() => { assert.equal(1, 2); // this failure is never seen by the test runner directly });});node --test missing-await.test.mjs✔ rejects an invalid user (BUG: missing await/return)ℹ Error: Test "rejects an invalid user (BUG: missing await/return)" at missing-await.test.mjs:4:1 generated asynchronous activity after the test ended. This activity created the error "AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
1 !== 2" and would have caused the test to fail, but instead triggered an unhandledRejection event.✖ missing-await.test.mjsℹ tests 2ℹ suites 0ℹ pass 1ℹ fail 1ℹ cancelled 0ℹ skipped 0ℹ todo 0test ตัวนั้นยังแสดง ✔ อยู่ — node:test คืนการควบคุมไปแล้วก่อนที่ .then() นั้นจะรัน แต่ runner ของ Node 24 ติดตาม async activity ต่อ test และสังเกตเห็นงานที่เกิดขึ้น หลัง test จบไปแล้ว มันรายงานว่าทั้งไฟล์ fail แทนที่จะกลืนมันไปเงียบ ๆ ซึ่งปลอดภัยกว่า Node เวอร์ชันเก่า อย่างไรก็ตามอย่าพึ่งพา safety net นี้ — ให้ return/await promise ที่ test body สร้างขึ้นเสมอ เพื่อให้ความล้มเหลวถูกระบุไปที่ test ที่ถูกต้องและไม่ต้องพึ่งการตรวจจับนี้:
// fixedimport { test } from 'node:test';import assert from 'node:assert/strict';
test('rejects an invalid user', async () => { await assert.rejects(async () => { throw new Error('invalid'); });});mock.method ผิดตัวอ้างอิง
หัวข้อที่มีชื่อว่า “mock.method ผิดตัวอ้างอิง”อธิบายไว้ละเอียดข้างบน — การ mock db.save ไม่มีผลกับ save ที่ถูก destructure ออกมาก่อนที่ mock จะรัน test อาจยังผ่านอยู่ (มันอาจ assert ว่า db.save ตัวจริงไม่เคยถูกเรียก โดยคาดหวังว่าจะเป็น mock) ทั้งที่จริง ๆ แล้วพิสูจน์ว่า mock ไม่มีผลอะไรกับโค้ดที่ทดสอบเลย
test.only, —test-only, และ t.skip/t.todo
หัวข้อที่มีชื่อว่า “test.only, —test-only, และ t.skip/t.todo”import { test } from 'node:test';import assert from 'node:assert/strict';
test('this one is skipped without --test-only handling', () => { assert.equal(1, 1);});
test.only('only this one runs when --test-only is set', () => { assert.equal(2, 2);});# without --test-only: .only has NO effect, both tests runnode --test only.test.mjs
# with --test-only: only the marked test runs, the rest are skippednode --test --test-only only.test.mjs# without --test-only✔ this one is skipped without --test-only handling✔ only this one runs when --test-only is setℹ 'only' and 'runOnly' require the --test-only command-line option.ℹ tests 2ℹ pass 2
# with --test-only✔ only this one runs when --test-only is setℹ tests 1ℹ pass 1test.only ไม่มีผลอะไรเลยจนกว่า runner จะถูกสั่งให้ยอมรับมัน นั่นคือเหตุผลที่มันอันตราย: CI script ที่ใส่ --test-only ไว้เสมอจะรัน test น้อยลงแบบเงียบ ๆ ตลอดไปถ้ามีคนลืมลบ .only ที่ใช้ตอน debug ออก และการรันก็ยังรายงานว่าสำเร็จอยู่ดี
t.skip (หรือ { skip: true }) และ t.todo (หรือ { todo: true }) คือเวอร์ชันที่ตั้งใจและมองเห็นได้ของ “อย่ารันตัวนี้ตามปกติ”:
import { test } from 'node:test';import assert from 'node:assert/strict';
test('skipped test', { skip: true }, () => { assert.fail('never runs');});
test('todo test', { todo: true }, () => { assert.fail('reported as todo, does not fail the run');});
test('a normal passing test', () => { assert.ok(true);});node --test skip-todo.test.mjs﹣ skipped test # SKIP✖ todo test # TODO✔ a normal passing testℹ tests 3ℹ pass 1ℹ skipped 1ℹ todo 1test ที่เป็น todo แล้ว fail ยังถูกรายงานอยู่ (ด้วย ✖ และ # TODO) แต่ไม่ทำให้การรันทั้งหมด fail — exit code ยังเป็น 0 ต่างจาก bug ที่ผ่านแบบเงียบ ๆ skip/todo แสดงชัดเจนในตัวเลขสรุป ทำให้ reviewer เห็นได้ว่ามี test กี่ตัวที่ไม่ได้ถูกบังคับใช้จริง ๆ
Reporter และ coverage
หัวข้อที่มีชื่อว่า “Reporter และ coverage”--test-reporter กำหนดรูปแบบ output spec (ค่าเริ่มต้น ที่แสดงตลอดหน้านี้) อ่านง่ายสำหรับมนุษย์ tap คือ TAP13 protocol ดิบที่เครื่องมืออื่น parse ได้ dot คือสรุปแบบกระชับหนึ่งตัวอักษรต่อ test junit สร้าง XML ที่ CI ใช้ได้ รวมกับ --test-reporter-destination เพื่อเขียนลงไฟล์:
node --test --test-reporter=dot small.test.mjs.node --test --test-reporter=tap small.test.mjsTAP version 13# Subtest: addsok 1 - adds --- type: 'test' ...1..1# tests 1# suites 0Coverage ยังเป็น --experimental-test-coverage บน Node 24 และ 26 — มันใช้งานได้จริง แต่ชื่อ flag คือสัญญาณที่ซื่อสัตย์ว่ารูปแบบ output ของมันยังเปลี่ยนได้:
node --test --experimental-test-coverage --test-coverage-functions=100 partial.test.mjs✔ only uses one functionℹ tests 1ℹ pass 1ℹ Error: 50.00% function coverage does not meet threshold of 100%.ℹ start of coverage report------------------------------------------------------------file | line % | branch % | funcs % | uncovered lines------------------------------------------------------------partial.mjs | 100.00 | 100.00 | 50.00 |------------------------------------------------------------all files | 100.00 | 100.00 | 50.00 |------------------------------------------------------------end of coverage reportการรันนั้น exit แบบ non-zero: --test-coverage-functions=100 (มี --test-coverage-lines และ --test-coverage-branches threshold แบบเดียวกัน) ทำให้ process ทั้งหมด fail เมื่อ coverage จริงต่ำกว่าที่กำหนด มีประโยชน์เป็น CI gate เมื่อคุณตัดสินใจแล้วว่าตัวเลข coverage คุ้มค่าที่จะบังคับใช้
Snapshot testing
หัวข้อที่มีชื่อว่า “Snapshot testing”t.assert.snapshot(value) เปรียบเทียบค่ากับไฟล์ .snapshot ที่เก็บไว้ข้างไฟล์ทดสอบ ซึ่ง serialize ด้วย util.inspect การรันครั้งแรก (หรือรันด้วย --test-update-snapshots) จะเขียนไฟล์นั้น การรันครั้งต่อ ๆ ไปจะเทียบกับมัน:
// @skip-verify needs a pre-recorded .snapshot file (or --test-update-snapshots first) to passimport { test } from 'node:test';
test('snapshot demo', (t) => { t.assert.snapshot({ a: 1, b: [1, 2, 3] });});# first time, or after an intentional change: record the snapshotnode --test --test-update-snapshots snapshot.test.mjsexports[`snapshot demo 1`] = `{ "a": 1, "b": [ 1, 2, 3 ]}`;# every later run: compare against the recorded snapshot, fail on driftnode --test snapshot.test.mjscommit ไฟล์ *.snapshot ที่ถูกสร้างขึ้นเข้า version control และ review diff ของมันเหมือนกับที่คุณ review โค้ดที่เปลี่ยนแปลงอื่น ๆ — การอัปเดต snapshot ปลอดภัยก็ต่อเมื่อมนุษย์ยืนยันแล้วว่าค่าใหม่ถูกต้อง ไม่ใช่แค่ต่างไปจากเดิม
Test lifecycle ทั้งหมด
หัวข้อที่มีชื่อว่า “Test lifecycle ทั้งหมด”flowchart TB
Discover["node --test discovers *.test.js, *-test.*, *_test.*, test-*.*, and everything under test/"] --> Before["before() -- once per file/describe"]
Before --> Loop{"For each test"}
Loop --> BE["beforeEach()"]
BE --> Run["test body runs (assert.* / t.assert.*)"]
Run --> AE["afterEach()"]
AE --> Loop
Loop -->|"all tests done"| After["after() -- once per file/describe"]
After --> Report["reporter (spec/tap/dot/junit) + coverage + exit code"] แบบจำลองความคิด (Mental Model)
หัวข้อที่มีชื่อว่า “แบบจำลองความคิด (Mental Model)”node:test ไม่ใช่ mini-framework ที่ยัดเข้าไปใน Node — มันเป็น layer บาง ๆ ที่ซื่อตรง: test()/describe()/it() ลงทะเบียนงาน hooks ห่อรอบมัน mock/assert เป็นแค่ object ธรรมดาที่ใช้ได้ทั้งมีหรือไม่มี test() wrapper และ reporter เป็นสิ่งสุดท้ายที่รันจริง ๆ ไม่มีอะไรมหัศจรรย์ตรงนี้: test “ผ่าน” เพราะ function ของมัน return (หรือ resolve) โดยไม่ throw และไม่มี unhandled rejection — นั่นคือเหตุผลที่ assertion ที่ไม่ได้ await หรือการไม่มี assert.* เลยหลุดผ่านไปแบบเงียบ ๆ ได้
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ต้นทุน |
|---|---|---|
node:test แทน Jest/Vitest | ไม่ต้องมี dependency มาพร้อมทุกการติดตั้ง Node ไม่ต้องมี config file | ไม่มี coverage UI ในตัว ไม่มีเครื่องมือ review snapshot ไม่มีการจำลอง DOM/browser environment |
ตั้ง --test-timeout ไว้ชัดเจน | test ที่ค้าง (ลืม await null, promise ที่ไม่ settle) fail เร็วใน CI แทนที่จะทำให้ pipeline ค้าง | ต้องจำ flag เพิ่มอีกตัวในทุก CI script |
mock.module() | แทนที่แม้แต่ built-in module อย่าง node:os ได้เพื่อการทดสอบ | Experimental — อยู่หลัง flag พิมพ์ warning และพฤติกรรมไม่รับประกันว่าจะเสถียรในทุก release |
| Snapshot testing | เขียนเร็วสำหรับ output ที่มีรูปร่างซับซ้อน | การกด --test-update-snapshots แบบไม่ได้ตรวจสอบหลังการเปลี่ยนแปลงทำให้ snapshot test นั้นหยุดจับอะไรเลย |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “test ที่แสดงเครื่องหมายถูกสีเขียวแปลว่ามันทดสอบอะไรบางอย่างจริง ๆ” — ความจริง: test ที่ไม่มีการเรียก
assert.*เลย หรือ assertion ของมันรันหลังจาก function ของ test คืนค่าไปแล้ว จะรายงานว่าผ่านไม่ว่าจะเกิดอะไรขึ้นก็ตาม - “mock.method(obj, ‘name’, fn) มีผลกับทุก reference ของฟังก์ชันนั้นทั่วทั้ง codebase” — ความจริง: มันแพตช์แค่ property slot บน
objเท่านั้น reference ที่ถูก destructure หรือจับไว้ก่อนหน้านั้นจากฟังก์ชันต้นฉบับจะไม่ถูกแตะต้องเลย - “การรองรับ mocking, coverage, และ snapshot ที่มีในตัวของ Node เสถียรเท่ากันหมด” — ความจริง:
mock.fn/mock.method/mock.timersและ snapshot เป็น stable แล้ว แต่mock.module()และ--experimental-test-coverageยังเป็น experimental อย่างชัดเจนบน Node 24 และ 26 - “test.only เป็นวิธีที่ปลอดภัยในการโฟกัสที่ test เดียวตอน debug” — ความจริง: มันปลอดภัยตอน debug ในเครื่อง เพราะ
--test-only(ส่วนใหญ่) ปิดอยู่ตรงนั้น แต่ใน CI script ที่เปิด--test-onlyไว้เสมอ.onlyที่ลืมลบจะทำให้ test อื่นทั้งหมดหลุดออกจากการรันไปแบบเงียบ ๆ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- test body จบลงโดยไม่มีการเรียก
assert.*/t.assert.*แม้แต่ครั้งเดียวหรือไม่? ถ้าใช่ มันจะผ่านไม่ว่าโค้ดที่ทดสอบจะทำอะไรจริง ๆ ก็ตาม - งานภายในของ async test เริ่มด้วย
.then()/เรียกPromiseเปล่า ๆ แทนที่จะawaitหรือreturnหรือไม่? test อาจจบและรายงานว่าผ่านก่อนที่งานนั้น — หรือ assertion ของมัน — จะรันเสร็จ mock.methodเล็งไปที่ property ของ object ในขณะที่โค้ดที่ทดสอบ import หรือ destructure ฟังก์ชันเดียวกันนั้นมาโดยตรงหรือไม่? mock กับจุดเรียกจริงอาจไม่ใช่ binding เดียวกัน- มี
test.onlyหลงเหลืออยู่ในไฟล์ทดสอบโดยไม่มี comment อธิบายเหตุผล ก่อนจะ commit หรือไม่? มันอาจไม่มีผลอะไรเลย (ถ้า CI ไม่มี--test-only) หรือกำลังปิด test ตัวอื่นทั้งหมดแบบเงียบ ๆ (ถ้ามี) - test ของ
mock.timersเรียกtick()ทันทีหลังเริ่มงาน async โดยไม่มีawait null(หรือการ yield ที่เทียบเท่า) คั่นกลางหรือไม่? timer อาจยังไม่ถูกลงทะเบียน และ test อาจค้างตลอดไปโดยไม่มี timeout เป็นค่าเริ่มต้น
🐛 หา bug ในโค้ดนี้
import { test, mock } from 'node:test';import assert from 'node:assert/strict';
const emailer = {send(to, body) { /* real network call */ },};
import { send } from './emailer.js'; // destructured at module load time
async function notifyUser(user) {return send(user.email, 'Welcome!');}
test('notifyUser sends an email', async () => {mock.method(emailer, 'send', () => Promise.resolve('sent'));
assert.equal(emailer.send.mock.callCount(), 1);});เฉลย
notifyUser เรียก send ที่ import (destructure) มาโดยตรงจาก ./emailer.js — เป็นคนละ binding กับ emailer.send การ mock emailer.send แทนที่ property บน object emailer แต่การเรียก send(...) ของ notifyUser ไม่เคยดูที่ object นั้นเลย ดังนั้น mock จึงไม่เคยถูกเรียกใช้ ขึ้นอยู่กับว่า send จริง ๆ ทำอะไร test นี้อาจ throw (มีการเรียก network จริงเกิดขึ้นระหว่าง “การทดสอบ”) หรือแย่กว่านั้นคือผ่านด้วยเหตุผลผิด ๆ ถ้า send บังเอิญ return บางอย่างที่ทำให้ assert.equal(emailer.send.mock.callCount(), 1) ยัง fail อยู่ — ซึ่งในกรณีนั้น bug อย่างน้อยก็ถูกจับได้ แค่ทำให้งงเท่านั้น ทางแก้: mock binding ที่ import มาจริง ๆ (mock.method(emailerModule, 'send', ...) กับ module namespace) หรือปรับโครงสร้าง notifyUser ให้เรียก emailer.send(...) เพื่อให้เหลือจุดเดียวที่ต้อง mock
ฟีเจอร์เพิ่มเติม
หัวข้อที่มีชื่อว่า “ฟีเจอร์เพิ่มเติม”# Only run tests matching a patternnode --test --test-name-pattern "Array"
# Watch mode — re-runs on file change (stable, no warning)node --test --watch
# Code coverage — the flag is still --experimental-test-coverage in Node 24 / 26node --test --experimental-test-coverage
# Parallel test files (the default); cap the workersnode --test --test-concurrency=4