Skip to content

บทที่ 8 — Testing: เขียนเทสต์ด้วยของที่มากับ Node

← บทที่ 7: Config, CLI + Lifecycle | สารบัญ | บทที่ 9: Database + REST API

เทสต์ (test) คือตาข่ายที่ทำให้คุณ "กล้าแก้โค้ด" โดยไม่กลัวพังของเก่า แก้เสร็จก็รันเทสต์ ถ้าเขียวหมดแสดงว่าไม่พัง

ข่าวดี: Node มี test runner (ตัวรันเทสต์) + assert (ฟังก์ชันเช็กผลลัพธ์) ในตัวแล้ว ตั้งแต่ v18 และเสถียรตั้งแต่ v20+ ไม่ต้องลง Jest/Mocha เพิ่มสำหรับงานพื้นฐาน บทนี้ใช้ Node 22 LTS เป็นหลัก

บทนี้จะพาไปทำความรู้จัก:

  1. node:test + node:assert — เขียนเทสต์ด้วยของ built-in
  2. โครง AAA (Arrange = เตรียม, Act = ลงมือ, Assert = ยืนยันผล) + ทดสอบ async
  3. mock/spy (ของปลอม/สายลับ) — แทนของจริง (DB, API, เวลา, fetch) ตอนเทส
  4. ทดสอบ HTTP API ด้วย supertest (library ส่ง request เข้า Express app โดยไม่ต้อง listen port จริง)
  5. 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.json scripts (บทที่ 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 ตรงกับ regex

AAA 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/microtaskmock.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() (หรือใช้ hook afterEach) เพื่อไม่ให้ 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/postgresql
javascript
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 ยาก ใช้ beforeEach cleanup เสมอ หรือใช้ 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:testVitest
ต้องติดตั้ง❌ (มากับ Node)npm i -D vitest
TypeScript ตรง ๆต้อง config✅ ออกมาเลย
ความเร็ว/watchดีเร็วมาก ⭐
APIassertexpect (เหมือน Jest)
เหมาะกับbackend ล้วน, ไม่อยากมี depโปรเจกต์ Vite/TS/fullstack

💡 เริ่มต้น/backend ล้วน → ใช้ node:test (ไม่ต้องลงอะไร) · โปรเจกต์ TypeScript/fullstack → Vitest · NestJS ใช้ Jest เป็น default (คล้าย Vitest มาก)


🛠️ Checkpoint 8 — ลงมือก่อนไปบทถัดไป

  1. เขียน math.mjs (add, divide ที่ throw เมื่อหารศูนย์) + math.test.mjs แล้วรัน node --test ให้เขียวครบ
  2. เพิ่มเทสต์ที่ใช้ assert.throws เช็กว่า divide(1, 0) throw จริง และ assert.deepEqual เทียบ object
  3. เขียนฟังก์ชัน async fetchUser(id) (mock ด้วยข้อมูลในตัวแปร) แล้วเทสต์ทั้งกรณีเจอ (await) และไม่เจอ (assert.rejects)
  4. ใช้ mock.fn() ทดสอบว่าฟังก์ชัน register เรียก sendEmail 1 ครั้งด้วย email ที่ถูก
  5. ทำ Express app เล็ก ๆ (แยก app จาก listen) แล้วใช้ supertest เทสต์ GET /health และ POST ที่ validate
  6. รัน --experimental-test-coverage ดู % แล้วเพิ่มเทสต์ให้ครอบ branch ที่ยังไม่โดน (เช่น path error)

เฉลยข้อ 3 (โครง):

javascript
const 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:assert built-innode --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 มาตรฐาน

→ บทที่ 9: Database + REST API จริง