โหมดมืด
บทที่ 6 — Testing (unit + integration + e2e)
หลายโปรเจกต์ Nest มีโค้ดทดสอบเขียนได้ "พอ ๆ กับโค้ด production" — ไม่ใช่เพราะ test ยากเย็น แต่เพราะ Nest ให้ DI ที่ทำให้ swap implementation (สลับตัวจริงเป็นตัวปลอม) ตอน test ง่ายมาก
ตัวอย่างที่ง่ายที่สุด: ใน production UsersService ใช้ PrismaService (ต่อ DB จริง) แต่ตอน test เราป้อน object ปลอมที่มี method findUnique คืนค่าที่กำหนดเอง — โค้ด UsersService ไม่ต้องแก้แม้แต่บรรทัดเดียว เพราะมัน inject ผ่าน constructor
ศัพท์ที่จะเจอบ่อยในบทนี้: unit test (ทดสอบโค้ดทีละชิ้นเล็ก ๆ แยกเดี่ยว — เร็ว, mock dependency), integration test (ทดสอบหลายชิ้นทำงานร่วมกัน + ของจริงอย่าง DB), e2e (end-to-end = ทดสอบทั้งระบบจากต้นจนจบเหมือนผู้ใช้จริง ผ่าน HTTP), mock (ของปลอมที่เราสร้างมาแทนของจริงตอนเทส), AAA (Arrange-Act-Assert = "เตรียม → ลงมือ → ยืนยัน")
1. Test pyramid
text
/\
/e2e\ น้อยที่สุด — ช้า, ครอบคลุม flow จริง
/------\
/ integ. \ กลาง — module + DB จริง (Testcontainers)
/----------\
/ unit \ เยอะที่สุด — pure logic, mock dep
/--------------\ลำดับสิ่งที่ควรทำ:
- Unit ของ business logic ใน service
- Integration ของ controller + service + DB
- E2E flow สำคัญ (login → action → logout)
2. ตัวเลือก Test Runner
Nest CLI ใช้ Jest เป็น default (template nest new มากับ Jest config พร้อมรัน) แต่ที่นิยมเพิ่มขึ้นเรื่อย ๆ คือ Vitest (เร็วกว่า, ESM native, API คล้าย Jest — describe/it/expect แทบเหมือนกัน) ในโปรเจกต์ใหม่ ๆ ที่ใช้ TypeScript + ESM ล้วน Vitest มักเร็วและตั้งค่าง่ายกว่า เพราะ Jest ยังมีปัญหา performance/config กับ ESM อยู่บ้าง (เช่น ต้องตั้ง ts-jest หรือ transform config เพิ่มเพื่อให้ import แบบ ESM ทำงานได้ ซึ่ง Vitest ทำได้เลยโดยไม่ต้องตั้งอะไรเพิ่ม) — อย่างไรก็ตาม NestJS CLI (nest new) ยัง scaffold เป็น Jest เท่านั้น การใช้ Vitest กับ Nest ต้องตั้งค่าเองเพิ่ม (ไม่ใช่ first-class ในตัว CLI) ตรวจสอบสถานะล่าสุดในเอกสารทางการก่อนใช้
💡 ESM native หมายถึง Vitest รองรับ ECMAScript Modules (รูปแบบ
import x from "..."ที่เป็นมาตรฐานของ JS สมัยใหม่ — ดูบท Node.js บทที่ 1: Module System) โดยตรง โดยไม่ต้องแปลงโค้ดก่อน Jest ต้องผ่านts-jestหรือbabel-jestซึ่งช้ากว่าและ debug ยากกว่า
ทั้งคู่ใช้ได้กับ Nest — บทนี้ใช้ Jest เพราะเป็น default แต่ pattern ทั้งหมด port ไป Vitest ได้แทบโดยตรง
3. Unit Test — Service
typescript
// users/users.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { UsersService } from "./users.service";
import { PrismaService } from "../prisma/prisma.service";
describe("UsersService", () => {
let service: UsersService;
let prismaMock: { user: any };
beforeEach(async () => {
// Arrange (เตรียม): สร้าง mock + boot test module ใหม่ทุกเทสต์
prismaMock = {
user: {
findUnique: jest.fn(),
create: jest.fn(),
findMany: jest.fn(),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: PrismaService, useValue: prismaMock },
],
}).compile();
service = module.get(UsersService);
});
it("findOne — return user when found", async () => {
prismaMock.user.findUnique.mockResolvedValue({ id: "1", name: "Alice" });
const result = await service.findOne("1");
expect(result).toEqual({ id: "1", name: "Alice" });
expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ where: { id: "1" } });
});
it("findOne — throw NotFoundException when missing", async () => {
prismaMock.user.findUnique.mockResolvedValue(null);
await expect(service.findOne("missing")).rejects.toThrow(NotFoundException);
});
});Test.createTestingModule คือ DI container ของ test — ใส่ provider mock แทน prod
Lifecycle ของ TestingModule ที่ต้องเข้าใจ:
Test.createTestingModule({...})— ประกาศ providers/imports เหมือน@Module.compile()— resolve DI graph (ทุก dependency ต้องมีคนป้อน ไม่งั้น error)module.get(Token)— ดึง instance มาใช้ใน test- ถ้ามี
createNestApplication()ตามด้วยapp.init()→ ต้องawait app.close()ตอนจบ ไม่งั้น handle รั่ว (ทรัพยากรระบบ เช่น การเชื่อมต่อหรือ socket ที่เปิดค้างไว้ไม่ได้ปิด), port ค้าง, Jest hang
💡 ใช้
useValueสำหรับ object ที่ mock ตรง ๆ;useClassสำหรับ swap ทั้ง class (เช่นใช้InMemoryUsersRepoแทนPrismaUsersRepo);useFactoryถ้าต้อง config dynamic (เช่นอ่าน env)
4. Test Doubles ("ตัวแสดงแทน" ตอนเทส) — Stub vs Mock vs Spy
typescript
// stub — function ที่ return value คงที่
const findUser = jest.fn().mockReturnValue({ id: "1" });
// mock — เปลี่ยน behavior แบบมีเงื่อนไข
findUser.mockImplementation((id) => id === "1" ? { id } : null);
// spy — สังเกตการเรียก function จริง โดยไม่เปลี่ยน behavior
const spy = jest.spyOn(console, "log");
// ...
expect(spy).toHaveBeenCalledWith("hello");หลักการ: mock เฉพาะ I/O (DB, HTTP, file system, เวลา เช่น Date.now/setTimeout) — อย่า mock pure function ของตัวเอง (ทำให้ test ผูกกับ implementation มากเกินไป refactor นิดเดียว test พังหมด)
Mock เวลา (Date / setTimeout)
โค้ดที่อ่าน new Date() หรือ Date.now() ทำให้ test ผลไม่แน่นอน — pattern ที่ดีคือใส่ TimeProvider (หรือชื่ออื่น) เป็น dependency:
typescript
// time/time.provider.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class TimeProvider {
now(): Date { return new Date(); }
}
// orders.service.ts — inject TimeProvider แทนเรียก new Date() ตรง ๆ
constructor(private readonly time: TimeProvider) {}
createOrder() {
return { createdAt: this.time.now() }; // testable
}ตอน test ก็ swap ด้วย mock ที่คืนเวลาที่ fix ไว้:
typescript
const fixed = new Date("2026-01-01T00:00:00Z");
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: TimeProvider, useValue: { now: () => fixed } },
],
}).compile();💡 อีกทางเลือกคือใช้
sinon.useFakeTimers()หรือjest.useFakeTimers()ที่ override globalDate/setTimeout— สะดวกสำหรับโค้ดเก่าที่เรียกnew Date()ตรง ๆแต่ระวัง: fake timer กับ Promise (microtask) จะ interleave (สลับกันทำงานคนละจังหวะ) แบบซับซ้อน อาจต้องเรียก
await Promise.resolve()แทรกระหว่างadvanceTimersByTime()เพื่อให้ microtask queue ไหลตามทัน — เรื่องนี้ควรลองรันจริงเพื่อดูผล อย่าคิดเอาเองว่าจะทำงานตามสัญชาตญาณ
5. Test Controller
Controller มักจะบาง — แต่ทดสอบดีมีประโยชน์เพื่อตรวจว่า:
- decorator ถูก (route, status code)
- DTO validation ทำงาน
- map service → response ถูก
typescript
describe("UsersController", () => {
let controller: UsersController;
let service: jest.Mocked<UsersService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [UsersController],
providers: [{
provide: UsersService,
useValue: { findAll: jest.fn(), findOne: jest.fn() },
}],
}).compile();
controller = module.get(UsersController);
service = module.get(UsersService);
});
it("GET /users → call service.findAll", async () => {
service.findAll.mockResolvedValue([{ id: "1", name: "A" }]);
expect(await controller.findAll()).toEqual([{ id: "1", name: "A" }]);
});
});6. Integration Test — รวม DB จริง
ใช้ Testcontainers — library ที่ spin Docker container (เช่น PostgreSQL) ขึ้นเฉพาะตอนรัน test แล้วลบทิ้งเมื่อจบ:
⚠️ ต้องมี Docker ติดตั้งและรันอยู่ บนเครื่องที่รัน test (ทั้ง local และ CI) — ถ้าไม่มี container จะ start ไม่ขึ้นและ test fail ตั้งแต่
beforeAll
bash
npm i -D @testcontainers/postgresql testcontainerstypescript
// test/users.integration.spec.ts
import { Test } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { spawnSync } from "node:child_process";
import { AppModule } from "../src/app.module";
import { PrismaService } from "../src/prisma/prisma.service";
import { UsersService } from "../src/users/users.service";
describe("UsersService (integration)", () => {
let container: StartedPostgreSqlContainer;
let app: INestApplication;
let prisma: PrismaService;
beforeAll(async () => {
container = await new PostgreSqlContainer("postgres:16").start();
const databaseUrl = container.getConnectionUri();
// รัน migration: ส่ง env ผ่าน argument ของ spawnSync (ไม่แก้ process.env global = ปลอดภัยกว่า execSync)
const result = spawnSync("npx", ["prisma", "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL: databaseUrl },
stdio: "inherit",
shell: true,
});
if (result.status !== 0) throw new Error("prisma migrate deploy failed");
// PrismaService ในบทที่ 4 เป็น `class PrismaService extends PrismaClient` แบบ no-arg constructor
// (ไม่รับ config object ทาง constructor) — วิธีที่ตรงกับโค้ดจริงคือ set env ก่อนสร้าง instance
process.env.DATABASE_URL = databaseUrl;
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PrismaService)
.useValue(new PrismaService())
.compile();
app = module.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
}, 120_000); // timeout 120s — เผื่อ CI cold-pull image ครั้งแรก (local รัน 10-30s)
afterAll(async () => {
// ⚠️ teardown สำคัญ: ต้องปิด app + container ไม่งั้น Jest hang + port/container ค้าง
await app?.close();
await container?.stop();
});
beforeEach(async () => {
await prisma.user.deleteMany(); // กัน "leaky test DB" — ข้อมูลจากเทสต์ก่อนหน้ารั่วเข้าเทสต์ถัดไป
});
it("create + find round-trip", async () => {
// UsersService.create() ที่ hash password ก่อนบันทึก — ดูตัวอย่างเต็มใน [บทที่ 4: Database](04-database-typeorm-prisma.md#module--service)
const users = app.get(UsersService);
const created = await users.create({ name: "Alice", email: "a@b.com", password: "secret123" });
const found = await users.findOne(created.id);
expect(found?.email).toBe("a@b.com");
});
});ข้อดี:
- ใช้ DB จริง = test แม่นยำ
- run แยก container ทุก test suite = ไม่กระทบกัน
- ทำงานบน CI ได้ (CI ที่มี docker)
ข้อเสีย:
- ช้ากว่า mock (~10-30s startup local, 60-120s ใน CI cold-pull)
- ต้องใช้กับ jest
--runInBand(= สั่ง Jest รันไฟล์ test ทีละไฟล์เรียงกัน ไม่ขนาน) เพื่อกัน port conflict ของ container ใน CI ที่มี resource จำกัด (ปกติ Jest รันหลายไฟล์ test พร้อมกันเพื่อความเร็ว แต่ถ้าแต่ละไฟล์เปิด container Postgres ของตัวเอง อาจชน port กัน ต้องสั่งให้รันทีละไฟล์แทน)
💡 Tip CI: pre-pull image (เช่น
docker pull postgres:16) ใน step ก่อนหน้า หรือ cache layer ของ Docker ไว้ — ไม่งั้น timeout 60s ในbeforeAllอาจไม่พอตอน cold-pull
7. E2E Test ผ่าน HTTP
Nest ทำงานร่วมกับ supertest ยิง request เข้า app ที่ boot จริง (รวม guard, pipe, middleware) แล้วตรวจ response ทำให้มั่นใจว่าทุกชั้นต่อกันถูก คล้าย WebApplicationFactory ใน ASP.NET (boot app ใน memory แล้วยิง HTTP request แต่ไม่ต้องเปิด port จริง):
bash
npm i -D supertest @types/supertest💡 Pattern ที่ดี: เรียก bootstrap function เดียวกับ prod — global pipe/filter/interceptor ที่ตั้งใน
main.tsต้องเหมือนกับใน test ไม่งั้น prod กับ test diverge เงียบ ๆ ทางที่ดีคือ extract function เช่นapplyGlobals(app)ที่ทั้งmain.tsและ test เรียก
typescript
// main.ts
export function applyGlobals(app: INestApplication) {
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
// ...interceptors, filters, etc.
}
// test/auth.e2e-spec.ts
import request from "supertest"; // esModuleInterop: true (default ใน NestJS) — ใช้ default import
import { Test } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import { AppModule } from "../src/app.module";
import { applyGlobals } from "../src/main";
describe("Auth (e2e)", () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = module.createNestApplication();
applyGlobals(app); // ใช้ global เดียวกับ prod — ไม่ drift
await app.init();
});
afterAll(async () => {
// ⚠️ ต้อง close ไม่งั้น Jest hang + handle รั่ว
await app?.close();
});
it("POST /auth/login — wrong password = 401", () =>
request(app.getHttpServer())
.post("/auth/login")
.send({ email: "a@b.com", password: "wrong" })
.expect(401)
);
it("full flow: register → login → me", async () => {
await request(app.getHttpServer())
.post("/auth/register")
.send({ email: "x@y.com", password: "secret123", name: "X" })
.expect(201);
// หมายเหตุ: POST default = 201 ใน Nest (ยกเว้นใส่ @HttpCode ระบุเอง)
// AuthController.login() ในบทที่ 5 ไม่มี @HttpCode(200) จึงคืน 201 ตาม default — ถ้าต้องการ 200 ให้เพิ่ม @HttpCode(200) ที่ handler นั้นเอง แล้วแก้ .expect(201) ด้านล่างเป็น .expect(200)
const loginRes = await request(app.getHttpServer())
.post("/auth/login")
.send({ email: "x@y.com", password: "secret123" })
.expect(201);
const token = loginRes.body.accessToken;
await request(app.getHttpServer())
.get("/auth/me")
.set("authorization", `Bearer ${token}`)
.expect(200)
.expect((res) => expect(res.body.email).toBe("x@y.com"));
});
});app.getHttpServer() คืน Express/Fastify instance — supertest ใช้กับ Express ตรง ๆ
💡
import request from "supertest"vsimport * as request— เนื่องจาก NestJS template ตั้งesModuleInterop: trueในtsconfig.jsonใช้ default import ได้เลย ตัวอย่างเก่าหลายที่ใช้import * as request from "supertest"ซึ่งทำงานได้ แต่ type ของ namespace อาจแปลก ๆ — แนะนำ default import
8. Override Providers ใน Test
บางครั้งอยาก swap แค่บางตัว ไม่ใช่ทั้ง module:
typescript
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(EmailService)
.useValue({ send: jest.fn().mockResolvedValue(true) })
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true }) // bypass auth ใน test
.compile();ใช้ overrideGuard(AuthGuard).useValue({ canActivate: () => true }) เพื่อทำ test endpoint ที่ต้องการ auth โดยไม่ต้อง login จริงทุก test
9. Mock HTTP Calls (External API)
test ไม่ควรยิง API ภายนอกจริง (ช้า เปลือง ไม่เสถียร) — ใช้ library "ดักจับ" HTTP request แล้วตอบ response ปลอมที่เรากำหนด:
- nock — library mock HTTP request เก่าแก่ของ Node ใช้กับ
http/axiosตรง ๆ - MSW (Mock Service Worker) — mock ทั้ง browser และ Node ด้วย API เดียวกัน เหมาะกับ fullstack repo
MockAgentจาก undici (built-in ตั้งแต่ Node 18) — mockfetchโดยตรง ไม่ต้องลง dep เพิ่ม (undici คือไลบรารี HTTP client ที่ Node ใช้ทำfetchแบบ built-in อยู่เบื้องหลัง การ mock ผ่าน undici จึงเท่ากับ mockfetchทั้งหมด; API ของ undici อาจเปลี่ยนแปลงได้ระหว่างเวอร์ชัน Node — ตรวจสอบ docs undici ก่อนใช้จริงกับ Node version ที่ deploy)
ตัวอย่าง nock (ใช้กับ axios):
typescript
import nock from "nock";
it("calls payment gateway", async () => {
nock("https://api.stripe.com")
.post("/v1/charges")
.reply(200, { id: "ch_123", status: "succeeded" });
const result = await paymentService.charge(100);
expect(result.status).toBe("succeeded");
});ตัวอย่าง MockAgent (ใช้กับ fetch global ของ Node):
typescript
import { MockAgent, setGlobalDispatcher } from "undici";
let mockAgent: MockAgent;
beforeAll(() => {
mockAgent = new MockAgent();
mockAgent.disableNetConnect(); // ห้ามต่อเน็ตจริง
setGlobalDispatcher(mockAgent);
});
afterAll(() => mockAgent.close());
it("fetches user from external API", async () => {
mockAgent.get("https://api.example.com")
.intercept({ path: "/users/1" })
.reply(200, { id: 1, name: "Alice" });
const res = await fetch("https://api.example.com/users/1");
expect((await res.json()).name).toBe("Alice");
});10. Snapshot Testing — เหมาะกับ response shape
snapshot testing บันทึก "รูปร่าง" ของ response ไว้ครั้งแรก แล้วเทียบกับครั้งถัดไปว่าเปลี่ยนไหม — เหมาะกับการตรวจว่า API response shape ไม่ถูกแก้โดยไม่ตั้งใจ:
💡 ใช้
expect.any()ข้าม field ที่เปลี่ยนทุกครั้งอย่างidหรือtimestamp
typescript
it("GET /users/:id matches snapshot", async () => {
const res = await request(app.getHttpServer()).get(`/users/${id}`);
expect(res.body).toMatchSnapshot({
createdAt: expect.any(String), // ignore field ที่เปลี่ยน
id: expect.any(String),
});
});⚠️ Snapshot pitfalls ที่เจอบ่อย — นอกจาก
id/timestampที่เปลี่ยนทุกครั้ง ยังมี: UUID (random ทุก insert), hashed values (เช่น token, bcrypt output), ลำดับ key/array ที่ DB คืนมาไม่แน่นอน (เช่นSELECT *ไม่ใส่ORDER BY) → ใช้expect.any()หรือ sort manual ก่อนเทียบ และอย่าใช้ snapshot กับ response ที่ shape เปลี่ยนบ่อย — snapshot จะโตและ noise มาก
11. Coverage
bash
npm run test:covNest CLI สร้าง coverage report ใน coverage/ อ่าน lcov-report/index.html ใน browser
Target:
- Service / business logic: 80-90%+
- Controller: 60-80% (เพราะส่วนใหญ่ delegate)
- ไม่ต้อง 100% — coverage สูงแต่ test แย่ก็ไร้ค่า
Coverage gate ใน CI — ตั้ง threshold ใน jest.config.ts ให้ CI fail ถ้า coverage หล่นต่ำกว่าเกณฑ์:
typescript
// jest.config.ts
export default {
coverageThreshold: {
global: { branches: 70, functions: 80, lines: 80, statements: 80 },
},
};12. CI Setup
GitHub Actions = ระบบ CI/CD ในตัวของ GitHub — รัน workflow (build, test, deploy) ทุกครั้งที่ push หรือเปิด PR โดยกำหนดเป็นไฟล์ YAML ใต้ .github/workflows/
.github/workflows/test.yml:
yaml
name: test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: pass
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready"
--health-interval 5s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22' # Node 22 LTS (current LTS ปี 2026)
cache: 'npm'
- run: npm ci
- run: npx prisma migrate deploy
env:
DATABASE_URL: 'postgresql://postgres:pass@localhost:5432/postgres' # ใส่ quote — URL มี : และ @
- run: npm test
- run: npm run test:e2e💡 YAML tip: ค่าที่มี
:หรือ@(เช่น URL ของ DB) ต้องใส่ quote ไม่งั้น parser อาจมองเป็น mapping พัง — ใช้'...'ปลอดภัยที่สุด
13. Common Anti-Patterns
- Test ผูก implementation มาก —
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(1)แล้ว refactor นิดเดียวก็พัง → test behavior อย่า test implementation - Mock ทุกอย่าง — test ผ่านแต่ production ระเบิด เพราะ mock ไม่ตรงจริง → ใช้ integration test สำหรับ critical path
- Test ที่ depend ลำดับ — test 2 ต้อง run หลัง test 1 → แต่ละ test ต้อง independent (cleanup ใน beforeEach)
- Shared state ระหว่าง test — ใช้ global variable → ใช้
beforeEachreset - Test ขนาด integration ใส่ใน unit folder — slow (ช้า) + flaky (ผลไม่แน่นอน — บางทีผ่านบางทีไม่ผ่านทั้งที่โค้ดไม่เปลี่ยน) → แยก folder
*.spec.ts(unit) กับ*.e2e-spec.ts(e2e/integration) - ไม่ลบ data หลัง test → DB เต็ม → cleanup ใน
afterEach
🛠️ Checkpoint 6 — ลงมือทำ
- เขียน unit test สำหรับ
UsersService.create()ที่ test:- hash password ก่อนบันทึก (verify ด้วย argon2.verify)
- throw
ConflictExceptionถ้า email ซ้ำ
- เขียน integration test ที่ใช้ Testcontainers สำหรับ flow
create user → find by email - เขียน e2e test สำหรับ auth flow ทั้งสาย: register → login → me → refresh → logout
- ทำให้ test ทำงานบน GitHub Actions ผ่าน
สรุปบทที่ 6
- Unit (เร็ว, mock dep) → Integration (DB จริง, Testcontainers) → E2E (HTTP, supertest)
- ใช้
Test.createTestingModule+overrideProvider/Guardเพื่อ swap implementation - Mock เฉพาะ I/O — อย่า mock pure function
- coverage แค่ตัวเลข — focus ที่ behavior ที่ test ครอบคลุม
- CI ต้องมี DB service เพื่อ integration test