ข้ามไปยังเนื้อหา

Backend tests

apps/api/src/common/slug.service.spec.ts — unit test แบบ @nestjs/testing ของ SlugService.generateUnique จาก Slugs & validation ขับเคลื่อนด้วย fake exists predicate เพื่อพิสูจน์ loop การเติม suffix -2/-3 apps/api/src/posts/posts.service.spec.ts — unit test ของ PostsService.findPage จาก Refactoring pass โดย mock Mongoose model ออกไปผ่าน getModelToken จากนั้นมี helper เล็ก ๆ apps/api/src/testing/mongo-memory.setup.ts ที่ห่อ mongodb-memory-server ไว้ และ apps/api/src/posts/posts.integration.spec.ts — integration test ที่บูต mongod จริงแบบใช้แล้วทิ้ง แล้ว assert flow draft→publish ทั้งหมด: create draft ยืนยันว่าโพสต์นั้นหายไปจาก findPage({ status: PUBLISHED }) ฝั่งสาธารณะ แล้ว publish จากนั้นยืนยันว่าปรากฏแล้ว

testing pyramid เป็นรูปทรง ไม่ใช่แค่คำขวัญ: unit test เร็ว ๆ จำนวนมากอยู่ฐานล่าง integration test จำนวนน้อยลงอยู่ตรงกลาง และ end-to-end test จำนวนน้อยที่สุดอยู่บนสุด

/\
/e2e\ few — whole app, real network, slowest, highest confidence
/------\
/integr. \ some — one real boundary (a real Mongo), one flow at a time
/----------\
/ unit \ many — one class, every dependency mocked, milliseconds each
/--------------\

แต่ละชั้นได้ที่ยืนของตัวเองด้วยการแลกความเร็วกับความแม่นยำ (fidelity) SlugService.generateUnique ไม่มี Mongoose อยู่ข้างในเลย เป็น loop ล้วน ๆ รอบ predicate ที่ผู้เรียกส่งมา unit test ที่ป้อน fake exists function เข้าไปแล้ว assert ลำดับ suffix จึงรันจบในเสี้ยววินาที และถ้าพังก็พังด้วยเหตุผลเดียวที่ชัดเจน ส่วน PostsService.findPage ก็มีตรรกะแตกแขนงจริงที่คุ้มจะเทสต์เดี่ยว ๆ เหมือนกัน (default เป็น PUBLISHED, ห้ามขอ DRAFT แบบไม่ระบุตัวตน, จำกัดให้ non-admin ที่ขอ DRAFT เห็นแค่โพสต์ตัวเอง) การ mock postModel.find/countDocuments ตัดตรรกะชุดนี้ออกจาก Mongo ทำให้ test พิสูจน์แค่ filter object ที่ findPage สร้างขึ้น ไม่ได้พิสูจน์ว่า Mongo จะยอมรับ filter นั้นจริงหรือไม่

จุดนี้เองที่การ mock model เริ่มไม่พอ การ mock Model พิสูจน์ได้แค่ว่า PostsService เรียก find ด้วย argument ตามที่ test คาดไว้ จับบั๊กระดับ schema จริงไม่ได้เลย (ลืม unique: true บน slug, ค่า enum ผิด, $ne ทำงานต่างจากที่คิด) และพิสูจน์ไม่ได้ว่า ข้อมูล ที่ได้กลับมาตรงกับที่เขียนลงไป เพราะไม่มี document store จริงอยู่เบื้องหลัง mongodb-memory-server ปิดช่องว่างนี้ ด้วยการดาวน์โหลดและบูต process mongod จริงแบบ ephemeral ในการรันครั้งแรก จากนั้น test ก็เชื่อม MongooseModule.forRoot() เข้ากับ URI ของ instance นั้น เหมือนกับที่ apps/api เชื่อมกับ deployment จริงเป๊ะ repository-style test — test ที่ประเด็นทั้งหมดคือ “สิ่งที่บันทึกไปกลับมาตรงกับที่คาดไว้ไหม” — ได้ความมั่นใจจริงจากตรงนี้: create() เขียน document จริงด้วย casting และ validator จริงของ Mongoose findPage({ status: PUBLISHED }) กรองผ่าน query engine จริง และ test นี้จะจับการเปลี่ยน enum ของ Post.status หรือ index ที่พังได้เหมือนกับที่ Mongo จริงในโปรดักชันจะจับ

Mock Model ผ่าน getModelToken (unit) เทียบกับ mongodb-memory-server (integration) mock model ไม่มีต้นทุนในการบูตเลย — ไม่ต้องดาวน์โหลด binary ไม่ต้อง start process — ทำให้ suite ที่สร้างจาก mock ล้วน ๆ ยังอยู่ในระดับหลักสิบมิลลิวินาทีแม้จะมีเป็นร้อย test และเมื่อพังก็ isolate ไปที่คลาสเดียวที่กำลังเทสต์เสมอ เพราะทุก collaborator เป็น jest.fn() ที่ test เองควบคุมพฤติกรรม ต้นทุนที่ต้องจ่ายคือช่องว่างด้าน fidelity ข้างบนพอดี — mock อาจ drift ไปจากสิ่งที่ Mongoose ทำจริงแบบเงียบ ๆ และไม่มีอะไรจับ drift นั้นได้จนกว่าจะไปเจอ environment จริง mongodb-memory-server จ่ายต้นทุนจริงต่อไฟล์ test — ตั้งแต่หลักร้อยมิลลิวินาทีถึงสองสามวินาทีในการ start mongod ครั้งแรก — แลกกับการใช้งาน persistence path จริง: schema validation จริง, การบังคับ unique index จริง, การกรอง query จริง การแบ่งที่ถูกต้องไม่ใช่ “เลือกอย่างใดอย่างหนึ่ง” — แต่คือให้ suite ส่วนใหญ่ยังเป็น unit (เร็ว, isolate, ตรรกะทีละสาขา) โดยเก็บ integration test จำนวนหนึ่งไว้สำหรับ flow ที่ “สิ่งนี้ persist ถูกต้องจริงไหม” คือคำถามทั้งหมด อย่างที่ draft→publish เป็นตรงนี้

ติดตั้ง mongodb-memory-server เป็น dev dependency ใน devblog API project:

Terminal window
cd apps/api
npm install --save-dev mongodb-memory-server

ตอนนี้ devDependencies ใน package.json มีแพ็กเกจนี้อยู่ควบคู่กับสิ่งที่ nest new scaffold ไว้ให้แล้ว:

{
"devDependencies": {
"@nestjs/testing": "^10.0.0",
"jest": "^29.5.0",
"mongodb-memory-server": "^9.1.6",
"ts-jest": "^29.1.0"
}
}

สร้าง apps/api/src/common/slug.service.spec.ts:

import { Test } from '@nestjs/testing';
import { SlugService } from './slug.service';
describe('SlugService', () => {
let slugService: SlugService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [SlugService],
}).compile();
slugService = moduleRef.get(SlugService);
});
it('returns the plain slug when nothing collides', async () => {
const exists = jest.fn().mockResolvedValue(false);
const slug = await slugService.generateUnique('Hello, DevBlog', exists);
expect(slug).toBe('hello-devblog');
expect(exists).toHaveBeenCalledTimes(1);
expect(exists).toHaveBeenCalledWith('hello-devblog');
});
it('appends -2 when the base slug is already taken once', async () => {
const exists = jest
.fn()
.mockResolvedValueOnce(true) // 'hello-devblog' is taken
.mockResolvedValueOnce(false); // 'hello-devblog-2' is free
const slug = await slugService.generateUnique('Hello, DevBlog', exists);
expect(slug).toBe('hello-devblog-2');
expect(exists).toHaveBeenCalledTimes(2);
});
it('keeps incrementing the suffix until a free candidate is found', async () => {
const exists = jest
.fn()
.mockResolvedValueOnce(true) // 'hello-devblog'
.mockResolvedValueOnce(true) // 'hello-devblog-2'
.mockResolvedValueOnce(false); // 'hello-devblog-3' is free
const slug = await slugService.generateUnique('Hello, DevBlog', exists);
expect(slug).toBe('hello-devblog-3');
expect(exists).toHaveBeenCalledTimes(3);
});
});

สร้าง apps/api/src/posts/posts.service.spec.ts:

import { Test } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { ForbiddenException } from '@nestjs/common';
import { PostsService } from './posts.service';
import { Post } from './schemas/post.schema';
import { PostStatus } from './enums/post-status.enum';
import { SlugService } from '../common/slug.service';
import { ExcerptService } from '../common/excerpt.service';
describe('PostsService.findPage', () => {
let postsService: PostsService;
let postModel: { find: jest.Mock; countDocuments: jest.Mock };
const fakeItems = [{ id: '1', title: 'A post' }];
beforeEach(async () => {
const findChain = {
sort: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(fakeItems),
};
postModel = {
find: jest.fn().mockReturnValue(findChain),
countDocuments: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValue(fakeItems.length),
}),
};
const moduleRef = await Test.createTestingModule({
providers: [
PostsService,
{ provide: getModelToken(Post.name), useValue: postModel },
{ provide: SlugService, useValue: { generateUnique: jest.fn() } },
{ provide: ExcerptService, useValue: { derive: jest.fn() } },
],
}).compile();
postsService = moduleRef.get(PostsService);
});
it('defaults to status: PUBLISHED when no options are given', async () => {
const result = await postsService.findPage();
expect(postModel.find).toHaveBeenCalledWith({ status: PostStatus.PUBLISHED });
expect(result.items).toEqual(fakeItems);
expect(result.total).toBe(1);
});
it('throws ForbiddenException for status: DRAFT with no requesting user', async () => {
await expect(postsService.findPage({ status: PostStatus.DRAFT })).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(postModel.find).not.toHaveBeenCalled();
});
it("scopes status: DRAFT to the caller's own posts when the requester is not an admin", async () => {
await postsService.findPage({
status: PostStatus.DRAFT,
requestingUser: { userId: 'author-1', email: 'a@example.com', role: 'author' },
});
expect(postModel.find).toHaveBeenCalledWith({
status: PostStatus.DRAFT,
author: 'author-1',
});
});
});
  • getModelToken(Post.name) + useValue คือ pattern มาตรฐานของ @nestjs/testing สำหรับแทนที่ @InjectModel(Post.name) — constructor ของ PostsService ไม่รู้เลยว่าออบเจกต์ธรรมดานี้กับ Mongoose Model จริงต่างกันตรงไหน
  • SlugService/ExcerptService ก็ถูก mock ด้วย แม้ findPage จะไม่ได้เรียกใช้เลย constructor ของ PostsService ต้องการให้ collaborator ครบทั้งสามตัว resolve ได้ก่อน Test.createTestingModule(...).compile() จึงจะสำเร็จ แค่ { generateUnique: jest.fn() } ธรรมดาก็พอให้ DI container ผ่าน โดยไม่ต้องลากตรรกะจริงของ SlugService เข้ามา
  • สาม test นี้สะท้อนสามสาขาจริงของ findPage จาก Refactoring pass: ไม่ระบุ status จะ default เป็น PUBLISHED; DRAFT โดยไม่มี requestingUser จะ throw ก่อนแตะ postModel เลย; DRAFT จาก non-admin จะเติม author เข้าไปใน filter สาขาที่สี่ — admin ที่ขอ DRAFT เห็น draft ของทุกคน — ปล่อยไว้เป็นแบบฝึกหัดให้ผู้อ่าน ตามรูปแบบเดียวกับ test ที่สาม แค่เปลี่ยนเป็น role: 'admin' และไม่คาดหวัง key author ใน filter

สร้าง apps/api/src/testing/mongo-memory.setup.ts — helper สำหรับ setup ที่ใช้ร่วมกัน ซึ่ง integration test ด้านล่าง import ไปใช้:

import { MongoMemoryServer } from 'mongodb-memory-server';
let mongod: MongoMemoryServer | null = null;
export async function startInMemoryMongo(): Promise<string> {
mongod = await MongoMemoryServer.create();
return mongod.getUri();
}
export async function stopInMemoryMongo(): Promise<void> {
if (mongod) {
await mongod.stop();
mongod = null;
}
}

สร้าง apps/api/src/posts/posts.integration.spec.ts:

import { Test, TestingModule } from '@nestjs/testing';
import { MongooseModule, getModelToken } from '@nestjs/mongoose';
import { Model, Types } from 'mongoose';
import { PostsService } from './posts.service';
import { Post, PostDocument, PostSchema } from './schemas/post.schema';
import { PostStatus } from './enums/post-status.enum';
import { SlugService } from '../common/slug.service';
import { ExcerptService } from '../common/excerpt.service';
import { startInMemoryMongo, stopInMemoryMongo } from '../testing/mongo-memory.setup';
describe('PostsService (integration, mongodb-memory-server)', () => {
let moduleRef: TestingModule;
let postsService: PostsService;
beforeAll(async () => {
const uri = await startInMemoryMongo();
moduleRef = await Test.createTestingModule({
imports: [
MongooseModule.forRoot(uri),
MongooseModule.forFeature([{ name: Post.name, schema: PostSchema }]),
],
providers: [PostsService, SlugService, ExcerptService],
}).compile();
postsService = moduleRef.get(PostsService);
});
afterEach(async () => {
const postModel = moduleRef.get<Model<PostDocument>>(getModelToken(Post.name));
await postModel.deleteMany({});
});
afterAll(async () => {
await moduleRef.close();
await stopInMemoryMongo();
});
it('keeps a new draft out of the public findPage({ status: PUBLISHED }) result', async () => {
const authorId = new Types.ObjectId().toString();
const draft = await postsService.create(authorId, {
title: 'Hello, DevBlog',
body: 'This is the first post.',
});
expect(draft.status).toBe(PostStatus.DRAFT);
const beforePublish = await postsService.findPage({ status: PostStatus.PUBLISHED });
expect(beforePublish.total).toBe(0);
expect(beforePublish.items).toHaveLength(0);
});
it('surfaces the post in findPage({ status: PUBLISHED }) once it has been published', async () => {
const authorId = new Types.ObjectId().toString();
const draft = await postsService.create(authorId, {
title: 'Hello, DevBlog',
body: 'This is the first post.',
});
await postsService.publish(draft.id);
const afterPublish = await postsService.findPage({ status: PostStatus.PUBLISHED });
expect(afterPublish.total).toBe(1);
expect(afterPublish.items[0].slug).toBe('hello-devblog');
expect(afterPublish.items[0].publishedAt).toBeInstanceOf(Date);
});
});
  • MongooseModule.forRoot(uri) ไม่ใช่ mongoose.connect() มือเปล่า — การชี้ Mongoose integration ของ Nest เองไปที่ URI ของ memory server ทำให้ MongooseModule.forFeature ทำงานเหมือนกับตอนอยู่กับ deployment จริงเป๊ะ และ moduleRef.close() ด้านล่างก็รื้อ connection ผ่าน lifecycle hook เดียวกับที่ bootstrap จริงของ apps/api พึ่งพา
  • SlugService/ExcerptService เป็น instance จริงตรงนี้ ไม่ใช่ mock ต่างจาก unit test ข้างบน ประเด็นทั้งหมดของ test นี้คือพิสูจน์พฤติกรรมจริงของ create() ตั้งแต่ต้นจนจบ รวมถึง slug ที่สร้างออกมาจริง (hello-devblog ตรงกับผลลัพธ์ Verify ของ Slugs & validation)
  • afterEach ล้าง postModel, afterAll รื้อ module กับ server ทิ้ง — แต่ละ test เริ่มจาก collection ว่างโดยไม่ต้องจ่ายค่าบูต mongod ใหม่ทุก test และ moduleRef.close() รันก่อน stopInMemoryMongo() เพื่อให้ Mongoose connection ปิดเรียบร้อยก่อนที่ process เบื้องหลังจะหยุด
  • การไม่ mock SlugService/ExcerptService ตรงนี้หมายความว่า test นี้จะพังด้วยถ้าการเปลี่ยนแปลงในอนาคตทำ collision loop หรือการตัด excerpt จริงพัง ความต่างจาก unit test ข้างบนที่ควรสังเกตคือ assertion ของ integration test เล็กกว่าและเลือกมาอย่างจงใจ (แค่การพลิกการมองเห็นตอน draft→publish) เพราะทุกอย่างที่เหลือเป็นของจริงและมี unit test ของตัวเองคลุมอยู่แล้ว
Terminal window
cd apps/api
npm run test
PASS src/common/slug.service.spec.ts
PASS src/posts/posts.service.spec.ts
PASS src/posts/posts.integration.spec.ts
Test Suites: 3 passed, 3 total
Tests: 8 passed, 8 total
Snapshots: 0 total
Time: 4.821 s
Ran all test suites.

posts.integration.spec.ts เป็น suite เดียวตรงนี้ที่ใช้เวลานานกว่าอีกสอง suite อย่างเห็นได้ชัด — นั่นคือ mongodb-memory-server กำลัง start process mongod จริงใน beforeAll ต้นทุนจริงที่ Pros & cons ได้เอ่ยชื่อไว้ ถ้าเพื่อนร่วมทีมเปลี่ยนตรรกะ draft-guard ของ PostsService.findPage โดยไม่อัปเดต spec file ไหนเลย unit test จะพังทันทีและระบุสาขาที่พังชัดเจน แต่ถ้าเขาเปลี่ยน enum ของ Mongoose บน Post.status จนการกรองพัง มีแต่ integration test เท่านั้นที่จะจับได้ เพราะ find ที่ mock ไว้ใน unit test ไม่แตะ schema จริงเลย

SlugService.generateUnique ได้ unit test ที่เร็วและ isolate เต็มที่ ขับเคลื่อนด้วย fake exists predicate พิสูจน์ loop suffix -2/-3 โดยไม่มี Mongoose เข้ามาเกี่ยวข้องเลย PostsService.findPage ได้ unit test ที่ mock Mongoose model ผ่าน getModelToken พิสูจน์สามสาขาที่ Refactoring pass สร้างไว้: default-เป็น-PUBLISHED, ห้าม-DRAFT-แบบไม่ระบุตัวตน, และ DRAFT-จำกัดตาม author posts.integration.spec.ts บูต Mongo จริงแบบใช้แล้วทิ้งผ่าน mongodb-memory-server และพิสูจน์สิ่งที่ mock พิสูจน์ไม่ได้: ว่า create() persist draft ที่มองไม่เห็นจาก listing สาธารณะได้จริง และ publish() พลิกให้โพสต์นั้นมองเห็นได้จริง เป็นการแลกแบบเดียวกับ testing pyramid (ความเร็วเทียบกับความแม่นยำ) ที่ประยุกต์ใช้กับ flow draft→publish ของคอร์สนี้เองจาก Draft → published

ถัดไป: Frontend tests →