โหมดมืด
บทที่ 8 — Testing: เขียนเทสต์ด้วยของที่มากับ Node
← บทที่ 7: Config, CLI + Lifecycle | สารบัญ | บทที่ 9: Database + REST API →
เทสต์ (test) คือตาข่ายที่ทำให้คุณ "กล้าแก้โค้ด" โดยไม่กลัวพังของเก่า แก้เสร็จก็รันเทสต์ ถ้าเขียวหมดแสดงว่าไม่พัง
ข่าวดี: Node มี test runner (ตัวรันเทสต์) + assert (ฟังก์ชันเช็กผลลัพธ์) ในตัวแล้ว ตั้งแต่ v18 และเสถียรตั้งแต่ v20+ ไม่ต้องลง Jest/Mocha เพิ่มสำหรับงานพื้นฐาน บทนี้ใช้ Node 22 LTS เป็นหลัก
บทนี้จะพาไปทำความรู้จัก:
node:test+node:assert— เขียนเทสต์ด้วยของ built-in- โครง AAA (Arrange = เตรียม, Act = ลงมือ, Assert = ยืนยันผล) + ทดสอบ async
- mock/spy (ของปลอม/สายลับ) — แทนของจริง (DB, API, เวลา, fetch) ตอนเทส
- ทดสอบ HTTP API ด้วย supertest (library ส่ง request เข้า Express app โดยไม่ต้อง listen port จริง)
- coverage (วัดว่าโค้ดถูกเทสต์กี่ %) + Vitest (test runner ทางเลือก) + integration test กับ DB จริง
1. เทสต์แรกด้วย node:test
สมมติมีฟังก์ชันใน math.mjs:
javascript
// math.mjs
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) throw new Error("หารด้วยศูนย์ไม่ได้");
return a / b;
}เขียนเทสต์ใน math.test.mjs (Node หาไฟล์ที่ match pattern (รูปแบบชื่อไฟล์) **/*.test.{js,mjs,cjs} ให้อัตโนมัติ — ** หมายถึง "ทุกโฟลเดอร์ย่อย" และ {js,mjs,cjs} หมายถึง "ไฟล์นามสกุลใดก็ได้ในกลุ่มนี้" หรือถ้าไฟล์อยู่ในโฟลเดอร์ชื่อ test/ ก็หาเจอเหมือนกัน — ดู docs ฉบับเต็มของเวอร์ชันที่ใช้):
javascript
// math.test.mjs
import { test, describe } from "node:test"; // test runner built-in
import assert from "node:assert/strict"; // assert แบบ strict (=== ไม่ใช่ ==)
import { add, divide } from "./math.mjs";
// describe = จัดกลุ่มเทสต์ที่เกี่ยวกัน
describe("add", () => {
test("บวกเลขบวกได้ถูก", () => {
assert.equal(add(2, 3), 5); // เทียบว่าเท่ากัน
});
test("บวกเลขลบได้ถูก", () => {
assert.equal(add(-1, -1), -2);
});
});
describe("divide", () => {
test("หารปกติได้ถูก", () => {
assert.equal(divide(10, 2), 5);
});
test("หารด้วยศูนย์ต้อง throw", () => {
// assert.throws = ยืนยันว่าฟังก์ชันนี้ throw error (จับ pattern message ได้)
assert.throws(() => divide(1, 0), /หารด้วยศูนย์/);
});
});รันด้วยคำสั่ง built-in:
bash
node --test # หาและรันไฟล์ test ทั้งหมด
node --test --watch # รันใหม่อัตโนมัติเมื่อไฟล์เปลี่ยน (TDD)
node --test --test-name-pattern="divide" # รันเฉพาะ test ที่ชื่อ match (Node 22+)
node --test --test-only # รันเฉพาะ test ที่ทำเครื่องหมาย { only: true }
node --test --test-concurrency=4 # รันขนาน 4 ไฟล์พร้อมกัน
node --test --test-reporter=spec # เปลี่ยน output format (spec/tap/junit/dot)
node --test --test-shard=1/3 # ใน CI: รันแค่ shard ที่ 1 จาก 3 (กระจาย workload)💡 Node 22.6+ มี
--experimental-strip-typesทำให้รัน.test.tsได้ตรง ๆ โดยไม่ต้อง compile/transpile ก่อน (ยังเป็น experimental — ดู doc ของเวอร์ชันที่ใช้)
ผลลัพธ์บอกชัดว่าผ่านกี่ตัว พังตรงไหน:
text
✔ add > บวกเลขบวกได้ถูก (1.2ms)
✔ divide > หารด้วยศูนย์ต้อง throw (0.8ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0💡 ตั้ง
"test": "node --test"ในpackage.jsonscripts (บทที่ 1) → รันด้วยnpm test
2. assert ที่ใช้บ่อย + โครง AAA
javascript
import assert from "node:assert/strict";
assert.equal(actual, expected); // เท่ากัน (===)
assert.deepEqual(obj1, obj2); // object/array เนื้อในเท่ากัน (ลึก)
assert.ok(value); // value เป็น truthy
assert.throws(() => fn(), /pattern/); // ฟังก์ชัน sync นี้ต้อง throw
await assert.rejects(promise, /pattern/); // Promise นี้ต้อง reject (async!)
assert.match("hello", /ell/); // string ตรงกับ regexAAA pattern (Arrange-Act-Assert = "เตรียม → ลงมือ → ยืนยัน") — เทสต์ที่ดีมี 3 ส่วนชัดเจน อ่านง่าย:
javascript
test("คำนวณยอดรวมตะกร้าถูกต้อง", () => {
// Arrange — เตรียมข้อมูล/state
const cart = [{ price: 100, qty: 2 }, { price: 50, qty: 1 }];
// Act — เรียกสิ่งที่จะทดสอบ
const total = calculateTotal(cart);
// Assert — ยืนยันผล
assert.equal(total, 250);
});ทดสอบโค้ด async
javascript
import { test } from "node:test";
import assert from "node:assert/strict";
test("fetchUser คืนข้อมูล user", async () => { // async test
const user = await fetchUser(1); // await ได้เลย
assert.equal(user.id, 1);
});
test("fetchUser โยน error เมื่อไม่เจอ", async () => {
// assert.rejects สำหรับ Promise ที่ควร reject
await assert.rejects(fetchUser(99999), /not found/);
});⚠️ Pitfall: ลืม
awaitหน้าassert.rejects→ เทสต์ "ผ่าน" ทั้งที่ยังไม่ได้เช็กอะไร (เพราะ assertion จบหลังเทสต์จบ) · เทสต์ async ต้องasync+awaitทุก assertion ที่เป็น Promise
3. Mock / Spy — แทนของจริงตอนเทส
ลองนึกภาพ: เทสฟังก์ชัน registerUser ที่พอสมัครสมาชิกเสร็จแล้วส่งอีเมลต้อนรับจริง ๆ — ถ้ารันเทสทุกครั้งแล้วอีเมลจริงบินไปหาคนจริง คงไม่ไหว (ช้า, เสียเงินค่าส่ง, ทำให้คนอื่นรำคาญ) เราจึงอยาก "แกล้งทำ" ว่าส่งอีเมลไปแล้ว โดยไม่ส่งจริง
ปัญหากว้าง ๆ คือ: ฟังก์ชันที่เรียก DB จริง / API จริง / อ่านเวลาจริง — เทสต์ยาก (ช้า, ต้องมี DB, ผลไม่แน่นอน) mock = ของปลอมที่เราคุมได้ แทนที่ของจริง node:test มี mock ในตัว:
javascript
// register.mjs — ฟังก์ชันที่จะทดสอบ (รับ sendEmail เป็น argument = DI ง่าย ๆ)
export function registerUser(user, sendEmail) {
// ...บันทึก user ลง store...
sendEmail(user.email); // ส่งอีเมลต้อนรับ
return { id: 1, ...user };
}javascript
import { test, mock } from "node:test";
import assert from "node:assert/strict";
import { registerUser } from "./register.mjs";
test("ส่งอีเมลต้อนรับเมื่อสมัครสำเร็จ", () => {
// สร้าง mock function — บันทึกว่าถูกเรียกกี่ครั้ง ด้วย argument อะไร
const sendEmail = mock.fn();
// ฉีด mock เข้าไปแทนของจริง (dependency injection)
registerUser({ email: "a@b.com" }, sendEmail);
// ยืนยันว่า mock ถูกเรียก 1 ครั้ง ด้วย argument ที่คาดไว้
assert.equal(sendEmail.mock.callCount(), 1);
assert.deepEqual(sendEmail.mock.calls[0].arguments, ["a@b.com"]);
});
test("mock method ของ object แล้วคืนค่าปลอม", () => {
const db = { getUser: () => { throw new Error("เรียก DB จริง!"); } };
// แทน db.getUser ด้วยของปลอมที่คืนค่าที่เรากำหนด
mock.method(db, "getUser", () => ({ id: 1, name: "ปลอม" }));
assert.equal(db.getUser().name, "ปลอม");
});Mock เวลา (timer) — เทสต์ที่เกี่ยวกับ setTimeout/Date
javascript
import { test, mock } from "node:test";
import assert from "node:assert/strict";
test("debounce เรียก callback หลัง 1 วินาที", () => {
mock.timers.enable({ apis: ["setTimeout"] }); // คุมเวลาเอง
const fn = mock.fn();
setTimeout(fn, 1000);
assert.equal(fn.mock.callCount(), 0); // ยังไม่ถึงเวลา
mock.timers.tick(1000); // "เลื่อนเวลา" ไป 1 วินาที (ไม่ต้องรอจริง)
assert.equal(fn.mock.callCount(), 1); // เรียกแล้ว
mock.timers.reset();
});⚠️ Pitfall: fake timer + Promise/microtask —
mock.timers.tick()ขยับ timer queue (คิวของงานที่รอ setTimeout ครบเวลา) ไปข้างหน้าเท่านั้น แต่ไม่ flush (เทงานออกให้หมดทันที) microtask queue ให้อัตโนมัติ microtask queue คือคิวงานเล็ก ๆ ที่รอทำหลัง sync code จบ เช่นงานที่ผูกกับ.then/awaitถ้าโค้ดมีawaitคั่นระหว่างsetTimeoutอาจต้องawait Promise.resolve()เพิ่ม หรือใช้ pattern ที่ tick + await ผสมกัน — เทสต์ async + fake timer ต้องลองรันจริงเสมอ อย่าคิดเอาเอง
💡 หลัง mock เสร็จควร
mock.reset()/mock.restoreAll()(หรือใช้ hookafterEach) เพื่อไม่ให้ mock รั่วไปเทสต์ตัวอื่น
Mock fetch / HTTP ภายนอก — ใช้ MockAgent (built-in)
โค้ดที่เรียก API ภายนอกด้วย fetch (ตั้งแต่ Node 18) ไม่ควรยิงเน็ตจริงตอนเทส fetch ของ Node ข้างในใช้ไลบรารีชื่อ undici เป็น HTTP client (ตัวที่ทำหน้าที่ยิง request จริง ๆ ออกไป) undici มากับ Node อยู่แล้ว ไม่ต้อง npm install เพิ่มถ้าแค่ import มาใช้ MockAgent ตามตัวอย่างนี้ — undici มี MockAgent ดักจับ request ได้:
javascript
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { MockAgent, setGlobalDispatcher } from "undici";
let mockAgent;
before(() => {
mockAgent = new MockAgent();
mockAgent.disableNetConnect(); // ห้ามต่อเน็ตจริงเด็ดขาด
setGlobalDispatcher(mockAgent); // ฉีด mock เข้า global fetch
});
after(async () => {
await mockAgent.close(); // ปิด agent + ตรวจว่า mock ถูกใช้ครบ
});
test("ดึง user จาก API", async () => {
mockAgent.get("https://api.example.com")
.intercept({ path: "/users/1", method: "GET" })
.reply(200, { id: 1, name: "Alice" });
const res = await fetch("https://api.example.com/users/1");
const user = await res.json();
assert.equal(user.name, "Alice");
});💡 อีกทางเลือกที่นิยมข้าม backend/frontend คือ MSW (Mock Service Worker) ซึ่ง mock ทั้ง browser และ Node ด้วย API เดียวกัน เหมาะถ้าใช้ใน fullstack repo
4. ทดสอบ HTTP API ด้วย supertest
ทดสอบ endpoint ทั้งเส้น (request → response) โดยไม่ต้องเปิด server จริงบน port — ใช้ supertest:
bash
npm install -D supertestแยก app (Express app) ออกจาก server.listen() ก่อน เพื่อให้เทสต์ import app มาทดสอบได้:
javascript
// app.mjs — export app ไม่ listen ตรงนี้
import express from "express";
export const app = express();
app.use(express.json());
app.get("/health", (req, res) => res.json({ status: "ok" }));
app.post("/users", (req, res) => {
if (!req.body.name) return res.status(400).json({ error: "ต้องมี name" });
res.status(201).json({ id: 1, name: req.body.name });
});
// server.mjs — แยกไฟล์ที่ listen ออกมา (เอาไว้รันจริง ไม่ใช่ตอนเทส)
// import { app } from "./app.mjs"; app.listen(3000);javascript
// app.test.mjs
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { app } from "./app.mjs";
describe("API", () => {
test("GET /health ตอบ 200 + status ok", async () => {
const res = await request(app).get("/health"); // ยิง request เข้า app
assert.equal(res.status, 200);
assert.equal(res.body.status, "ok");
});
test("POST /users สำเร็จคืน 201", async () => {
const res = await request(app)
.post("/users")
.send({ name: "สมชาย" }); // ส่ง JSON body
assert.equal(res.status, 201);
assert.equal(res.body.name, "สมชาย");
});
test("POST /users ไม่มี name ต้อง 400", async () => {
const res = await request(app).post("/users").send({});
assert.equal(res.status, 400);
});
});💡 การ แยก
appออกจากlistenเป็น pattern สำคัญ — ทำให้เทสต์ได้ง่ายโดยไม่ต้องเปิด port จริงนอกจากนี้ยังเอา
appไปใช้ซ้ำใน serverless ได้ด้วย serverless คือการรันโค้ดบน cloud โดยที่เราไม่ต้องดูแลเครื่อง server เอง (ผู้ให้บริการจัดการเรื่องเปิด/ปิด/สเกลเครื่องให้หมด) เช่น AWS Lambda, Vercel — แพลตฟอร์มพวกนี้จะเรียกappของเราโดยตรงตอนมี request เข้ามา โดยไม่ผ่านlisten()แบบเซิร์ฟเวอร์ปกติ📝 Sidenote สำหรับใครเรียน NestJS: NestJS ก็แยก app ออกจาก listen แบบนี้ ผ่าน
Test.createTestingModuleที่ boot app ใน memory แล้วยิง supertest เข้า — รายละเอียดใน NestJS บทที่ 6: Testing
Integration test กับ DB จริง — Testcontainers
ถ้าอยากเทสต์ที่แตะ DB จริง (ไม่ใช่ mock) ใช้ Testcontainers spin DB ขึ้นใน Docker container แค่ตอนรันเทสต์:
bash
npm install -D testcontainers @testcontainers/postgresqljavascript
import { test, before, after, beforeEach } from "node:test";
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import pg from "pg";
let container, client;
before(async () => {
container = await new PostgreSqlContainer("postgres:16").start(); // ดึง image + start
client = new pg.Client({ connectionString: container.getConnectionUri() });
await client.connect();
await client.query("CREATE TABLE users (id serial, email text)");
});
after(async () => {
// ⚠️ teardown สำคัญมาก — ไม่ปิดจะมี container ค้าง + connection leak
await client.end();
await container.stop();
});
beforeEach(() => client.query("TRUNCATE users")); // เคลียร์ทุกเทสต์ — กัน "leaky test DB"
test("insert + select user", async () => {
await client.query("INSERT INTO users (email) VALUES ($1)", ["a@b.com"]);
const { rows } = await client.query("SELECT email FROM users");
assert.equal(rows[0].email, "a@b.com");
});⚠️ ต้องมี Docker ติดตั้งและรันอยู่ ก่อนรันเทสต์ — ทั้ง local และ CI ถ้า image ยังไม่ถูก pull การ start ครั้งแรกอาจกินเวลา 30-60 วินาที (ใน CI ควรเพิ่ม timeout หรือ pre-pull image ใน step แยก)
⚠️ Leaky test DB warning: ถ้าหลายเทสต์ใช้ table เดียวกันโดยไม่ truncate/cleanup → ข้อมูลจาก test ตัวก่อนหน้า "รั่ว" เข้า test ตัวถัดไป → ผลเทสต์เพี้ยน reproducer ยาก ใช้
beforeEachcleanup เสมอ หรือใช้ transaction + rollback
5. Coverage + Vitest
Coverage — โค้ดถูกเทสต์ครอบคลุมแค่ไหน
Node วัด coverage ได้ในตัว:
bash
# Node 20: ยัง experimental ต้องเปิด flag
node --test --experimental-test-coverage
# Node 22+: ฟีเจอร์ coverage เสถียรขึ้นมากแล้ว แต่ชื่อ flag ยังคงเดิม
# (ชื่อ flag ที่มีคำว่า experimental ไม่ได้แปลว่าฟีเจอร์ยังไม่เสถียรเสมอไป — เช็ก docs ของเวอร์ชันที่ใช้จริงอีกที)
node --test --experimental-test-coverage # ยังใช้ได้
# หรือใช้ reporter ใหม่ใน Node 22+
node --test --test-reporter=lcov --test-reporter-destination=coverage.lcov💡 ใน Node 22 LTS coverage stable แล้ว — ตรวจ flag/option ของเวอร์ชันที่ใช้จาก docs เพราะ flag เปลี่ยนระหว่างเวอร์ชัน
ได้ตารางบอกว่าแต่ละไฟล์ถูกเทสต์กี่ % (line/branch) — แต่ อย่าหลงตัวเลข: coverage 100% ไม่ได้แปลว่าไม่มีบั๊ก มันบอกแค่ "โค้ดถูกรันตอนเทส" ไม่ได้บอกว่า "assert ครบทุกกรณีสำคัญ" — เน้นเทสต์ logic สำคัญ + edge case มากกว่าไล่ % ให้เต็ม
Vitest — ทางเลือกยอดนิยม
node:test ดีและไม่ต้องลงอะไร แต่หลายโปรเจกต์ (โดยเฉพาะที่ใช้ Vite/TypeScript/React) ใช้ Vitest เพราะเร็ว, watch mode ดี, รองรับ TS ออกมาเลย, API คล้าย Jest:
javascript
// math.test.mjs (Vitest) — API คล้าย ๆ กัน แต่ใช้ expect แทน assert
import { describe, it, expect, vi } from "vitest";
import { add } from "./math.mjs";
describe("add", () => {
it("บวกได้ถูก", () => {
expect(add(2, 3)).toBe(5); // expect().toBe() สไตล์ Jest
});
});node:test | Vitest | |
|---|---|---|
| ต้องติดตั้ง | ❌ (มากับ Node) | ✅ npm i -D vitest |
| TypeScript ตรง ๆ | ต้อง config | ✅ ออกมาเลย |
| ความเร็ว/watch | ดี | เร็วมาก ⭐ |
| API | assert | expect (เหมือน Jest) |
| เหมาะกับ | backend ล้วน, ไม่อยากมี dep | โปรเจกต์ Vite/TS/fullstack |
💡 เริ่มต้น/backend ล้วน → ใช้
node:test(ไม่ต้องลงอะไร) · โปรเจกต์ TypeScript/fullstack → Vitest · NestJS ใช้ Jest เป็น default (คล้าย Vitest มาก)
🛠️ Checkpoint 8 — ลงมือก่อนไปบทถัดไป
- เขียน
math.mjs(add, divide ที่ throw เมื่อหารศูนย์) +math.test.mjsแล้วรันnode --testให้เขียวครบ - เพิ่มเทสต์ที่ใช้
assert.throwsเช็กว่าdivide(1, 0)throw จริง และassert.deepEqualเทียบ object - เขียนฟังก์ชัน async
fetchUser(id)(mock ด้วยข้อมูลในตัวแปร) แล้วเทสต์ทั้งกรณีเจอ (await) และไม่เจอ (assert.rejects) - ใช้
mock.fn()ทดสอบว่าฟังก์ชันregisterเรียกsendEmail1 ครั้งด้วย email ที่ถูก - ทำ Express app เล็ก ๆ (แยก
appจากlisten) แล้วใช้ supertest เทสต์GET /healthและPOSTที่ validate - รัน
--experimental-test-coverageดู % แล้วเพิ่มเทสต์ให้ครอบ branch ที่ยังไม่โดน (เช่น path error)
เฉลยข้อ 3 (โครง):
javascriptconst users = { 1: { id: 1, name: "A" } }; async function fetchUser(id) { const u = users[id]; if (!u) throw new Error("not found"); return u; } // test: await assert.rejects(fetchUser(999), /not found/);
สรุปบทที่ 8
- Node มี
node:test+node:assertbuilt-in —node --test(+--watch,--experimental-test-coverage) ไม่ต้องลง framework - โครงเทสต์:
describeจัดกลุ่ม +test+ assert · เขียนแบบ AAA (Arrange-Act-Assert) - async: เทสต์เป็น
async+await assert.rejects(...)(อย่าลืม await ไม่งั้นผ่านปลอม) - mock (
mock.fn,mock.method,mock.timers) แทนของจริง (DB/API/เวลา) — reset หลังใช้ - ทดสอบ HTTP ด้วย supertest — แยก
appออกจากlistenก่อน - coverage เป็นไกด์ ไม่ใช่เป้า — เน้น logic + edge case · Vitest = ทางเลือกที่เร็ว+รองรับ TS
บทต่อไป: ประกอบทุกอย่างเป็นของจริง — REST API ต่อ PostgreSQL พร้อม connection pool, validation และ error format มาตรฐาน