Test Strategy: ทำที่ Layer ไหน ทำแค่ไหนพอ
Scale-Ready Architecture · Part 3 of 9
Test Strategy ใน Modular Monolith — ทำที่ Layer ไหน ทำแค่ไหนพอ
ก่อนที่แต่ละ Part จะลงลึกเรื่อง testing ของ layer ตัวเอง บทความนี้วาง mental model ที่ทั้ง series ใช้ร่วมกัน
Scale-Ready Architecture Series — Part นี้ต้องการความเข้าใจเรื่อง Dependency Graph จาก Part 1 และ Abstraction Layer จาก Part 2 ถ้ายังไม่ได้อ่าน แนะนำให้เริ่มจากที่นั่นก่อน
ปัญหาที่ทุกทีม Dev เคยเจอ
เขียน test มาสักพัก แล้วมีคำถามแว่บขึ้นมาแบบนี้ไหม?
“ฟังก์ชันนี้ควร test ที่ layer ไหน — Data Layer หรือ Service Layer?” “ต้อง mock PrismaClient ที่ทุก layer เลยไหม หรือแค่ที่ Data Layer พอ?” “Integration Test กับ Unit Test ต่างกันยังไงในแง่ปฏิบัติ — ใช้ Docker ทุก test เลยได้ไหม?” “Fixture กับ Seed Data ต่างกันยังไง ใช้ผิดจะเกิดอะไรขึ้น?”
ถ้าเคย — แสดงว่าคุณยังขาด test boundary ที่ชัดเจน
ปัญหาที่เห็นบ่อยในทีมที่ไม่มี test strategy คือ test ซ้อนทับกันข้าม layer, Unit Test ที่แอบ spin DB ทำให้ build ช้าโดยไม่รู้ตัว หรือ coverage สูงแต่ตาม bug ไม่เจอเพราะ test ผิดที่
บทความนี้จะตอบทุกคำถามข้างต้น ด้วย mental model เดียวที่ใช้ได้ตลอดทั้ง series
ทำไมแต่ละ Layer ถึง Test ต่างกัน
Clean Architecture แบ่ง concern ของแต่ละ layer ให้ชัดเจน หลักการเดียวกันนี้กำหนดว่าแต่ละ layer “รู้” อะไร และ “ไม่รู้” อะไร — และนั่นแหละคือสิ่งที่ test ควรตรวจสอบ ไม่ใช่ตรวจสอบในสิ่งที่ layer นั้นไม่ได้รับผิดชอบ
เหตุผลที่ต้อง test ต่างกันมาจาก สิ่งที่แต่ละ layer รู้ ไม่เท่ากัน:
share-data รู้แค่ว่า PrismaClient คืออะไร และ SQL Query ควรออกมาแบบไหน — ไม่รู้เรื่อง HTTP, Business Rule หรือ Authentication
share-service รู้ Business Rule และลำดับ Use Case — ไม่รู้เรื่อง Database Query หรือ HTTP Transport
app-{name} รู้การ wire ทุกอย่างเข้าด้วยกัน — รู้เรื่อง Framework, Middleware, Auth, Connection Pool
เมื่อ concern ต่างกัน สิ่งที่ test ต้องตรวจก็ต่างกันตามไปด้วย การเอา E2E Test ที่ใช้ DB จริงไปรัน ณ share-data layer ไม่ได้ให้ความมั่นใจมากกว่า Unit Test แต่เสียเวลา setup Docker เพิ่มขึ้นหลายเท่า
หลักคิดง่ายๆ คือ:
Test ตรวจสิ่งที่ layer นั้น "รู้" — ไม่ตรวจสิ่งที่ layer อื่นรับผิดชอบ
Test Naming Convention
| Test Name | Level | Scope | Approach |
|---|---|---|---|
| Unit Test | Unit | 1 function / component | mock boundary ของ layer |
| Integration Test | Integration | หลาย function ทำงานร่วมกันใน layer เดียว | mock infrastructure เช่น mock DB |
| API Test | E2E | 1 endpoint ตาม business scenario | ไม่ mock, Real DB |
| Feature Test | E2E | หลาย Action ใน 1 BC ตาม scenario | ไม่ mock, Real DB |
| System Test | E2E | full flow ข้าม BC จนจบ | ไม่ mock, Real DB + Docker |
หมายเหตุ: “Test Name” ในตารางนี้คือชื่อที่ series นี้ใช้เพื่อสื่อสารและทำความเข้าใจร่วมกัน ชื่อบางตัวอาจตรงหรือซ้ำกับ Test Level หรือ Test Type ตาม theory ทั่วไป เช่น Unit Test และ Integration Test — ผู้อ่านที่คุ้นกับ testing theory จะสังเกตว่า API Test, Feature Test และ System Test ในที่นี้นับเป็น E2E Level ทั้งหมด ต่างกันแค่ scope ของ scenario
Test Matrix
| Test Name | Level | share-core | share-data | share-client | share-service | app-{name} |
|---|---|---|---|---|---|---|
| Unit Test | Unit | — | ✅ | ✅ | ✅ | ❌ |
| Integration Test | Integration | — | ❌ | ❌ | ❌ | ❌ |
| API Test | E2E | — | ❌ | ❌ | ❌ | ✅ |
| Feature Test | E2E | — | ❌ | ❌ | ❌ | ✅ |
| System Test | E2E | — | ❌ | ❌ | ❌ | ✅ |
share-core ไม่มี test เพราะเป็น pure type definitions และ interfaces — TypeScript จับ contract errors ที่ compile time อยู่แล้ว ไม่มี runtime logic ที่ต้อง test
share-data, share-client, share-service ทำแค่ Unit Test — mock boundary ของตัวเอง ไม่ใช้ของจริง
Integration Test ไม่ทำที่ layer ใดเลย — Unit Test ที่แต่ละ layer ครอบคลุม component interaction ภายใน layer แล้ว การเพิ่ม Integration Test จะได้ confidence เพิ่มน้อยมากเมื่อเทียบกับ cost ของการ setup
app-{name} ทำ API Test, Feature Test และ System Test เพราะเป็น layer เดียวที่มี full context (auth, service layer, real DB connection)
ใครทำ และเมื่อไหร่ที่ไม่ต้องทำ
| Test Name | ใครทำ | จำเป็น? | ถ้าไม่ทำ มีอะไร cover |
|---|---|---|---|
| Unit Test | Developer | ✅ ต้องทำ | — |
| Integration Test | — | ❌ ไม่ทำ | Unit Test แต่ละ layer ครอบคลุมแล้ว |
| API Test | Developer | ✅ ต้องทำ | — |
| Feature Test | QA / Business Analyst | ✅ ต้องทำ | — |
| System Test | QA / DevOps | ⚠️ ทำเมื่อแตก Microservice | Contract Test cover แทน |
API Test — Developer เพราะ scope เล็ก ใกล้ code และให้ feedback เร็ว
Feature Test — QA / Business Analyst เพราะ test ตาม business scenario ที่ต้องการความเข้าใจ business rule ครบ ไม่ใช่แค่ technical correctness
System Test — QA / DevOps เพราะต้องการ Docker และ infrastructure setup ที่ซับซ้อน feedback ช้ากว่า และมักต้องยุ่งกับ External Service — เมื่อแตกเป็น Microservice ให้ใช้ Contract Test แทน เพราะ test contract ระหว่าง service ได้โดยไม่ต้อง spin ทุก service พร้อมกัน
Test Scope Boundary — แต่ละ Layer Test อะไร ไม่ Test อะไร
share-core
| Test อะไร | ไม่ Test อะไร |
|---|---|
| — | ทุกอย่าง (pure types) |
share-core ไม่มีโค้ดที่ run ได้ — เป็นแค่ contract ที่ TypeScript enforce ให้
share-data
Test อะไร:
Entry— delegate ไปยัง task ถูกต้องไหมTask— orchestrate flows และ DAF ถูกลำดับไหม, จัดการ transaction boundary ถูกไหมflows.ts— workflow logic และ error handling ของแต่ละ flowdb.logic.ts(DAF) — query arguments ถูกต้องไหม, จัดการ error จาก Prisma ถูกไหมdata.logic.ts— transform และ validate ถูกต้องไหม (pure functions)
ไม่ Test อะไร:
- ❌ ไม่ test ว่า SQL Query ทำงานกับ DB จริงได้ไหม
- ❌ ไม่ test HTTP layer หรือ Business Rule
- ❌ ไม่ test schema migration หรือ DB connection
ทำไม: TypeScript + Unit Test ครอบคลุมสิ่งที่ Data Layer รับผิดชอบแล้ว Wiring errors จับที่ compile time, Logic errors จับที่ unit test — Integration Test กับ Real DB ให้ confidence เพิ่มน้อยมากเมื่อเทียบกับ cost ของการ setup Docker
share-client
Test อะไร:
- HTTP Request สร้างถูกต้องไหม (method, path, headers, body)
- Response mapping ถูกต้องไหม
- Error handling เมื่อ HTTP status ต่างๆ ทำงานถูกไหม
ไม่ Test อะไร:
- ❌ ไม่ test ว่า server จริงตอบกลับถูกไหม
- ❌ ไม่ test Business Rule ของ provider
- ❌ ไม่ test network latency หรือ retry policy
ทำไม: share-client รู้แค่ contract ของ provider (จาก share-core) และ HTTP transport — ไม่ต้องรู้เรื่อง Business Logic ของ provider นั้น
share-service
Test อะไร:
business.logic.ts— Constraint, Calculation, Transform, Business Rule ทำงานถูกไหมrouteSteps.logic.ts— step functions แต่ละตัวทำงานถูกต้องไหม (logic ของแต่ละ step ไม่ใช่ลำดับ)endpoint.config.ts(Orchestrator) — เรียก step จากrouteSteps.logic.tsถูกลำดับไหม และ wire deps ถูกต้องไหม
ไม่ Test อะไร:
- ❌ ไม่ test DB query โดยตรง
- ❌ ไม่ test HTTP transport
- ❌ ไม่ test Framework middleware
ทำไม:
- Constraint, Calculation, Transform — pure function เสมอ test ตรงๆ ได้เลยโดยไม่ต้อง mock ใด
- Business Rule ที่มี side-effect เช่น
assertUsernameUnique— mock เฉพาะ Repository ที่เป็น boundary ของ layer ไม่ต้อง spin DB จริง
app-{name}
Test อะไร:
- API Test — 1 endpoint ตาม business scenario, Real DB
- เช่น
POST /ordersส่ง request ถูกต้องแล้วได้ response และ DB state ถูกต้อง
- เช่น
- Feature Test — หลาย Action ใน 1 BC ตาม business scenario, Real DB
- เช่น สร้าง user → assign role → ตรวจสิทธิ์ ทั้งหมดอยู่ใน
user-mngBC เดียวกัน
- เช่น สร้าง user → assign role → ตรวจสิทธิ์ ทั้งหมดอยู่ใน
- System Test — full flow ข้าม BC จนจบ, Real DB + Docker
- เช่น place order → reserve inventory → charge payment ข้าม 3 BC
- Seed Data setup ก่อน run ทั้ง API Test, Feature Test และ System Test
ไม่ Test อะไร:
- ❌ ไม่ test unit-level logic ซ้ำ (มี layer อื่น test แล้ว)
ทำไม: app-{name} เป็นจุดเดียวที่มี full context — API Test, Feature Test และ System Test ที่นี่ให้ความมั่นใจสูงสุดว่าทุกอย่าง wire กันถูกต้อง, BC ทำงานร่วมกันได้จริง และ Business Rule ยังถูกต้องเมื่อ flow ข้าม BC
Mock Strategy Overview
หลักการของ series นี้คือ mock เฉพาะ boundary ของ layer ตัวเอง — ไม่ mock สิ่งที่อยู่ภายใน layer และไม่ข้ามไป mock layer อื่น
| Layer | Mock Concept | Tool ใน Series นี้ | Boundary คือ | ทำไม |
|---|---|---|---|---|
share-data | db-client | PrismaClient | Database | Data Layer รู้แค่ query — ไม่รู้เรื่อง HTTP หรือ Business Rule |
share-client | http-client | axios (หรือ msw) | HTTP transport | Client Layer รู้แค่ HTTP contract — ไม่รู้เรื่อง Business Logic ของ provider |
share-service | Repository interface + ServiceClient interface | — | Data Layer + Remote Service | Service Layer รู้แค่ contract — ไม่รู้ implementation ของ Data หรือ HTTP |
app-{name} | ไม่ mock | — | — | Composition Root ใช้ของจริงทั้งหมด — mock ที่นี่ = test mock ไม่ใช่ test ระบบ |
ทำไมต้อง Mock เฉพาะ Boundary?
ถ้า mock มากกว่า boundary ของตัวเอง จะ test ได้แค่ว่า mock ทำงานถูกต้อง — ไม่ได้ test logic จริงของ layer นั้นเลย
// ❌ share-service mock ทุกอย่าง รวม business.logic.ts ด้วย
// → test ได้แค่ว่า mock ถูก call — ไม่รู้ว่า Business Rule ถูกหรือเปล่า
jest.mock('../logic/business.logic')
// ✅ share-service mock เฉพาะ boundary (Repository + ServiceClient)
// → Business Rule ทำงานจริง test ตรวจค่าจริง
const mockRepo = { findById: jest.fn(), save: jest.fn() }
const mockInventoryClient = { checkStock: jest.fn() }
ตัวอย่าง Mock แต่ละ Layer
share-data — mock PrismaClient
// dbclient.mock.ts — shared mock สำหรับทุก action ใน share-data
import { PrismaClient } from '@prisma/client'
import { mockDeep, DeepMockProxy } from 'jest-mock-extended'
export type MockPrismaClient = DeepMockProxy<PrismaClient>
jest.mock('./dbclient', () => ({
__esModule: true,
default: mockDeep<PrismaClient>(),
}))
// ใช้ใน test file
import prisma from '../dbclient'
import { MockPrismaClient } from '../dbclient.mock'
const prismaMock = prisma as unknown as MockPrismaClient
describe('db.logic', () => {
describe('getApplicationDAF', () => {
it('queries with correct where clause', async () => {
// mock PrismaClient response
prismaMock.aPPLICATION.findUnique.mockResolvedValue(applicationFixtures.pending)
await getApplicationDAF(prismaMock, 'app-001')
// ตรวจ query arguments — ไม่ใช่ตรวจ data จาก DB จริง
expect(prismaMock.aPPLICATION.findUnique).toHaveBeenCalledWith({
where: { ID: 'app-001' },
})
})
})
})
share-client — mock axios
import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe('InventoryRemoteClient', () => {
describe('checkStock', () => {
it('sends GET request with correct path and params', async () => {
// mock axios response — ไม่ต้องมี server จริง
mockedAxios.get.mockResolvedValue({
data: { available: true, quantity: 100 },
status: 200,
})
const client = new InventoryRemoteClient('http://inventory-service')
const result = await client.checkStock({ productId: 'prod-001', qty: 5 })
// ตรวจว่า HTTP request ถูกสร้างถูกต้อง
expect(mockedAxios.get).toHaveBeenCalledWith(
'http://inventory-service/inventory/stock',
{ params: { productId: 'prod-001', qty: 5 } }
)
expect(result.available).toBe(true)
})
it('throws ServiceError when HTTP 503', async () => {
mockedAxios.get.mockRejectedValue({ response: { status: 503 } })
const client = new InventoryRemoteClient('http://inventory-service')
await expect(client.checkStock({ productId: 'prod-001', qty: 5 }))
.rejects.toThrow(ServiceUnavailableError)
})
})
})
share-service — mock Repository + ServiceClient
// mock เฉพาะ interface boundary — Business Rule ทำงานจริง
const mockOrderRepo: jest.Mocked<OrderRepository> = {
findById: jest.fn(),
save: jest.fn(),
findByCustomerId: jest.fn(),
}
const mockInventoryClient: jest.Mocked<Pick<InventoryServiceClient, 'checkStock' | 'reserve'>> = {
checkStock: jest.fn(),
reserve: jest.fn(),
}
describe('placeOrderUseCase', () => {
it('throws DomainError when quantity is 0', async () => {
// ✅ Business Rule (assertPositiveQuantity) ทำงานจริง — ไม่ถูก mock
await expect(
placeOrderUseCase({ mockOrderRepo, mockInventoryClient }, {
productId: 'prod-001',
quantity: 0, // ← trigger Business Constraint
customerId: 'cust-001',
})
).rejects.toThrow(DomainError)
// ✅ ตรวจว่าไม่ได้เรียก inventory ถ้า validation ล้มเหลว
expect(mockInventoryClient.checkStock).not.toHaveBeenCalled()
})
it('calls repo.save after successful inventory reservation', async () => {
mockInventoryClient.checkStock.mockResolvedValue({ available: true, quantity: 100 })
mockInventoryClient.reserve.mockResolvedValue({ reserved: true })
mockOrderRepo.save.mockResolvedValue(undefined)
await placeOrderUseCase({ mockOrderRepo, mockInventoryClient }, {
productId: 'prod-001',
quantity: 5,
customerId: 'cust-001',
})
expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
})
})
app-{name} — ไม่ mock ใดเลย
// E2E Test ที่ app-{name} — ใช้ของจริงทั้งหมด (Feature Test ตัวอย่าง)
describe('POST /orders (Integration)', () => {
let app: Express
let prisma: PrismaClient
beforeAll(async () => {
// ใช้ Real DB (Docker ใน CI)
prisma = new PrismaClient({ datasourceUrl: process.env.TEST_DATABASE_URL })
app = createApp({ prisma }) // ← Composition Root จริง ไม่ mock
// Seed Data เข้า DB จริงก่อน test
await seedTestDatabase(prisma)
})
afterAll(async () => {
await prisma.$disconnect()
})
it('creates order and returns 201 with order id', async () => {
const response = await request(app)
.post('/orders')
.set('Authorization', `Bearer ${testUserToken}`)
.send({ productId: 'prod-seed-001', quantity: 3 })
expect(response.status).toBe(201)
expect(response.body.orderId).toBeDefined()
// ตรวจ DB state จริง
const savedOrder = await prisma.order.findUnique({
where: { id: response.body.orderId },
})
expect(savedOrder?.status).toBe('PENDING')
})
})
Fixture vs Seed Data vs Test Data
คำสามคำนี้หมายถึงต่างกัน และเลือกผิดทำให้ test ช้าหรือ setup ซับซ้อนโดยไม่จำเป็น
นิยาม
| คำ | คืออะไร | อยู่ที่ไหน | ใช้เมื่อไหร่ |
|---|---|---|---|
| Test Data | superset — ข้อมูลทดสอบทุกประเภท | ขึ้นกับ type | ทั้งหมด |
| Fixture | static test data ที่เก็บใน code | __tests__/fixtures/ | Unit Test เท่านั้น |
| Seed Data | ข้อมูลที่ inject เข้า DB จริงก่อน run test | seeds/ หรือ script | Integration Test / E2E เท่านั้น |
Fixture — Static Test Data ใน Code
Fixture คือ TypeScript object ที่เก็บ test data ไว้ใช้ซ้ำใน Unit Test ไม่ต้องการ DB จริง เหมาะกับ Unit Test ที่ mock external dependency อยู่แล้ว
// __tests__/fixtures/application.fixture.ts
export const applicationFixtures = {
// ─── base states — ชื่อบอก status ของ entity ────────────────
pending: {
ID: 'test-app-001',
STATUS: 'PENDING',
CURRENT_ACTOR_ID: 'user-001',
CREATED_AT: new Date('2025-01-01'),
},
approved: {
ID: 'test-app-002',
STATUS: 'APPROVED',
CURRENT_ACTOR_ID: 'user-001',
CREATED_AT: new Date('2025-01-01'),
},
completed: {
ID: 'test-app-003',
STATUS: 'COMPLETED',
CURRENT_ACTOR_ID: null,
CREATED_AT: new Date('2025-01-01'),
},
// ─── edge case — comment บอกว่าสร้างมาทำไม ─────────────────
// ใช้ทดสอบ validation ที่ reject เมื่อไม่มี actor
pendingWithNoActor: {
ID: 'test-app-004',
STATUS: 'PENDING',
CURRENT_ACTOR_ID: null,
CREATED_AT: new Date('2025-01-01'),
},
}
// ✅ ใช้ Fixture ใน Unit Test
import { applicationFixtures } from './fixtures'
describe('db.logic', () => {
describe('getApplicationDAF', () => {
it('returns right with data when found', async () => {
// mock PrismaClient ให้ return fixture แทน query DB จริง
prismaMock.aPPLICATION.findUnique.mockResolvedValue(applicationFixtures.pending)
const result = await getApplicationDAF(prismaMock, 'test-app-001')
expect(result.isRight()).toBe(true)
})
})
})
Seed Data — Inject เข้า DB จริง
Seed Data คือสคริปต์ที่ insert ข้อมูลจริงเข้า Database ก่อน run E2E Test ต้องการ DB จริง และมักทำงานร่วมกับ Docker ใน CI
// seeds/test-orders.seed.ts — ใช้ที่ app-{name} ก่อน Integration Test
export const seedTestDatabase = async (prisma: PrismaClient): Promise<void> => {
// Seed product
await prisma.product.upsert({
where: { id: 'prod-seed-001' },
create: {
id: 'prod-seed-001',
name: 'Test Product A',
price: 100,
stockQuantity: 50,
},
update: {},
})
// Seed customer
await prisma.customer.upsert({
where: { id: 'cust-seed-001' },
create: {
id: 'cust-seed-001',
name: 'Test Customer',
email: 'test@example.com',
},
update: {},
})
}
หลักการตัดสินใจ — ดูจาก Test Level ไม่ใช่ Layer
ถาม: ต้องการ Fixture หรือ Seed Data?
↓
test ระดับไหน?
├── Unit Test → Fixture เสมอ (ไม่มี DB)
├── API Test → Seed Data เสมอ (ต้องการ DB จริง)
├── Feature Test → Seed Data เสมอ (ต้องการ DB จริง)
└── System Test → Seed Data เสมอ (ต้องการ DB จริง)
ข้อผิดพลาดที่เห็นบ่อย: เอา Fixture ไปใช้ใน Integration Test โดยหวังว่า mock จะแทน DB ได้ — ผลคือ test ผ่านในเครื่อง dev แต่ fail ใน CI เพราะ schema ไม่ตรง
// ❌ ผิด — Unit Test ไม่ควรต้องการ DB จริง
describe('getApplicationDAF', () => {
beforeAll(async () => {
// การ connect DB ใน Unit Test ทำให้ test ช้าและ fragile
await prisma.$connect()
await prisma.application.create({ data: { ... } })
})
})
// ✅ ถูก — Unit Test ใช้ Fixture + mock PrismaClient
describe('getApplicationDAF', () => {
it('returns right with data when found', async () => {
prismaMock.aPPLICATION.findUnique.mockResolvedValue(applicationFixtures.pending)
const result = await getApplicationDAF(prismaMock, 'test-app-001')
expect(result.isRight()).toBe(true)
})
})
Tools & Setup
section นี้อธิบาย tool ที่ใช้ทั้ง series — แต่ละ Part จะไม่อธิบายซ้ำ
Jest + ts-jest
Jest เป็น test runner หลักของ series นี้ ใช้คู่กับ ts-jest เพื่อ run TypeScript โดยตรงโดยไม่ต้อง compile ก่อน
npm install --save-dev jest ts-jest @types/jest jest-mock-extended
// jest.config.ts — base config ที่แต่ละ package ใช้
import type { Config } from 'jest'
const config: Config = {
displayName: 'share-data', // ← ชื่อ package แต่ละตัว override ตรงนี้
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/test-setup/jest.setup.ts'],
clearMocks: true, // ล้าง mock.calls หลังทุก test
resetMocks: true, // reset mock implementation หลังทุก test
restoreMocks: true, // restore jest.spyOn หลังทุก test
}
export default config
ทำไม clearMocks + resetMocks + restoreMocks พร้อมกัน?
แต่ละ option ล้างคนละชั้น:
| Option | ล้างอะไร | ไม่ล้างอะไร |
|---|---|---|
clearMocks | .mock.calls, .mock.instances, .mock.results | implementation ยังอยู่ |
resetMocks | ทุกอย่างของ clearMocks + ล้าง implementation | jest.spyOn ยังไม่ restore |
restoreMocks | ทุกอย่างของ resetMocks + restore jest.spyOn กลับเป็น original | — |
// สมมติ setup นี้
const fn = jest.fn().mockReturnValue('hello')
fn()
// หลัง clearMocks
fn.mock.calls // [] ← ล้างแล้ว
fn() // 'hello' ← implementation ยังอยู่
// หลัง resetMocks
fn.mock.calls // [] ← ล้างแล้ว
fn() // undefined ← implementation หายไปด้วย
// สมมติ spyOn
jest.spyOn(console, 'log').mockImplementation(() => {})
// หลัง resetMocks — spy ยังอยู่ แค่ implementation หาย
console.log('hi') // ไม่มี output แต่ยังเป็น mock อยู่
// หลัง restoreMocks — spy ถูก restore กลับเป็น original
console.log('hi') // 'hi' ← กลับเป็น console.log จริง
ใช้แค่ clearMocks อย่างเดียวไม่พอ เพราะ implementation ยังติดอยู่ข้าม test ได้ถ้าลืม re-assign — ใช้ทั้งสามพร้อมกันเพื่อให้แต่ละ test เริ่มต้นด้วย mock ที่สะอาดสมบูรณ์
// ถ้าไม่ clear/reset mock — test ปนกัน
describe('task', () => {
it('test A — mock returns success', async () => {
mockFlow.mockResolvedValue(right({ id: '001' }))
// ...
})
it('test B — ถ้าไม่ reset จะได้ mock ของ test A มาด้วย', async () => {
// ❌ mockFlow ยังเป็น success จาก test A อยู่
// test อาจผ่านโดยบังเอิญ ไม่ใช่เพราะ logic ถูก
})
})
// ✅ clearMocks: true ใน jest.config.ts จัดการให้อัตโนมัติ
// ไม่ต้องเขียน beforeEach(() => jest.clearAllMocks()) ใน test file
Test Runner แยก Config ตาม Layer
// share-data/jest.config.ts
import type { Config } from 'jest'
const config: Config = {
displayName: 'share-data',
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
// Unit Test เท่านั้น — ไม่มี integration/e2e config
}
export default config
// app-{name}/jest.config.ts — มี config แยกสำหรับ integration
import type { Config } from 'jest'
const config: Config = {
displayName: 'app-main',
projects: [
{
displayName: 'unit',
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/unit/**/*.test.ts'],
},
{
displayName: 'integration',
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/integration/**/*.test.ts'],
// Integration Test ต้องการ globalSetup สำหรับ DB
globalSetup: '<rootDir>/test-setup/integration.setup.ts',
globalTeardown: '<rootDir>/test-setup/integration.teardown.ts',
},
],
}
export default config
SonarQube CPD — อธิบายครั้งเดียวที่นี่
SonarQube CPD (Copy-Paste Detection) จะ flag code ที่ซ้ำกัน ซึ่งเป็นปัญหาสำหรับไฟล์ 2 ประเภทใน series นี้:
# sonar-project.properties — ทุก package ใช้ pattern นี้
sonar.cpd.exclusions=**/*.type.ts,**/__tests__/**/*.test.ts
| Pattern | เหตุผลที่ต้อง exclude |
|---|---|
**/*.type.ts | Type definition มีโครงสร้างซ้ำกันตามธรรมชาติ เช่น id: string, createdAt: Date — ไม่ใช่ code smell |
**/__tests__/**/*.test.ts | Test code ต้องการ explicit มากกว่า DRY — Arrange ใน it() แต่ละตัวมี mock setup ซ้ำกันตามธรรมชาติ การ abstract เพื่อลด duplication ทำให้อ่านยากขึ้น |
ทำไม test code ต้องการ explicit มากกว่า DRY?
// ❌ DRY เกินไป — ต้องไล่หา context กลับไปที่ helper
const setupSuccessFlow = () => {
mockFindActivity.mockResolvedValue(right(activityFixtures.lv40Activity))
mockFindMany.mockResolvedValue(right(activityFixtures.processActivities))
}
it('returns mapped result', async () => {
setupSuccessFlow() // ← ต้องไปอ่าน helper ก่อนเข้าใจ test นี้
// ...
})
// ✅ Explicit — อ่าน it() เดียวรู้เลยว่า setup อะไร
it('returns mapped result', async () => {
mockFindActivity.mockResolvedValue(right(activityFixtures.lv40Activity))
mockFindMany.mockResolvedValue(right(activityFixtures.processActivities))
// → ชัดเจน ไม่ต้องดูที่อื่น
const result = await getAllActivityFlow({ ... })
expect(result.isRight()).toBe(true)
})
Run Commands
Using npx
# Run ทุก test ใน package
npx nx test share-data
# Run test สำหรับ action เดียว (เร็วกว่ามาก)
npx nx test share-data --testPathPattern=returnRequestLv40
# Run พร้อม coverage report
npx nx test share-data --coverage
# Run ทุก package ที่ได้รับผลกระทบจากการแก้ไขล่าสุด
npx nx affected --target=test
# Watch mode สำหรับ dev
npx nx test share-data --watch
Using pnpm (แนะนำ)
# Run ทุก test ใน package
pnpm nx test share-data
# Run test สำหรับ action เดียว (เร็วกว่ามาก)
pnpm nx test share-data --testPathPattern=returnRequestLv40
# Run พร้อม coverage report
pnpm nx test share-data --coverage
# Run ทุก package ที่ได้รับผลกระทบจากการแก้ไขล่าสุด
pnpm nx affected --target=test
# Watch mode สำหรับ dev
pnpm nx test share-data --watch
Preview — แต่ละ Part จะลงลึกเรื่อง Testing อะไร
Series นี้จะกลับมาพูดเรื่อง testing ใน 5 Part ที่เหลือ โดยแต่ละ Part มี scope ที่ต่างกัน:
| Part | Layer | Testing Focus | สิ่งใหม่ที่จะได้เรียน |
|---|---|---|---|
| Part 4 | share-data | Unit Test — Data Layer | Fixture pattern, 1-file-per-action convention, mock Prisma |
| Part 5 | share-data | Test Strategy เชิงลึก | Mock level per component, describe structure, AAA pattern |
| Part 7 | share-service | Testing Pure Functions | Business Logic test โดยไม่ต้อง mock |
| Part 8 | share-service | Testing UseCase (Orchestrator) | mock Repository + ServiceClient, test step sequence |
| Part 9 | app-{name} | Feature Test + System Test | Feature Test, System Test, Seed Data strategy, Docker setup |
สรุป
หลักการ testing ของ series นี้มีแค่ 3 ข้อ:
1. Test เฉพาะสิ่งที่ layer ตัวเองรับผิดชอบ แต่ละ layer รู้ concern ของตัวเอง — test ก็ตรวจแค่ concern นั้น ไม่ข้ามขอบ
2. Mock เฉพาะ boundary share-data mock Prisma, share-client mock axios, share-service mock Repository + Client — ไม่ mock internal logic ของ layer ตัวเอง
3. เลือก Fixture หรือ Seed Data จาก test level ไม่ใช่ layer Unit Test ใช้ Fixture เสมอ, Feature Test และ System Test ใช้ Seed Data เสมอ — ไม่มีข้อยกเว้น
เมื่อเข้าใจ 3 ข้อนี้แล้ว บทความถัดไปใน series จะเข้าใจง่ายขึ้นมาก เพราะทุก Part ใช้ mental model ชุดเดียวกัน
บทความถัดไปใน Series
Part 4 — share-data: Data Layer และ Action-Based Structure จะลงลึก implementation จริงของ Data Layer ตั้งแต่ Entry, Task, flows, DAF, ไปจนถึงการเขียน Unit Test สำหรับแต่ละ component
FAQ
Q: ทำไม share-data ถึงไม่ต้องทำ Integration Test หรือ E2E Test?
Integration Test (mock DB, test หลาย component ร่วมกัน) ไม่ทำเพราะ Unit Test ที่แยก component ครอบคลุมแล้ว การเพิ่ม Integration Test ได้ confidence เพิ่มน้อยมากเมื่อเทียบกับ cost ส่วน E2E Test (Real DB) ไม่ทำที่ share-data เพราะ share-data ไม่มี full context — ไม่รู้เรื่อง auth, middleware หรือ connection pool E2E Test ที่ให้ความมั่นใจจริงๆ ควรทำที่ app-{name} ซึ่งมี full context ครบ
Q: Fixture กับ Seed Data ต่างกันอย่างไร ใช้ผิดกันมีผลอะไร?
Fixture คือ TypeScript object ใน code ใช้แทน DB ใน Unit Test — เร็ว ไม่ต้องการ infrastructure ส่วน Seed Data คือสคริปต์ที่ insert ข้อมูลจริงเข้า DB ก่อน run E2E Test ถ้าใช้ผิดทาง เช่น เอา Seed Data ไปรัน Unit Test จะทำให้ build ช้าโดยไม่จำเป็น และ test fragile เพราะขึ้นกับ DB state ส่วน Fixture ที่เอาไปใช้แทน Seed Data ใน E2E Test จะทำให้ test ผ่านในเครื่อง dev แต่ fail ใน CI เพราะ schema หรือ constraint จริงไม่ตรง
Q: ทำไม app-{name} ถึงไม่ mock อะไรเลย?
app-{name} เป็น Composition Root ที่ wire ทุกอย่างเข้าด้วยกัน ถ้า mock deps ที่นี่ สิ่งที่ test จะกลายเป็น “mock ทำงานถูกต้อง” ไม่ใช่ “ระบบทำงานถูกต้อง” Feature Test และ System Test ที่ app-{name} มีคุณค่าสูงสุดก็เพราะใช้ของจริงทั้งหมด — ตรวจได้ว่า wiring ถูก, DB schema ตรง, auth flow ทำงาน, และ Business Rule ยังถูกต้องเมื่อ flow ข้าม BC
Q: Mock Strategy ใน series นี้เกี่ยวกับ Hexagonal Architecture อย่างไร?
Hexagonal Architecture กำหนดว่า core domain ไม่ควรรู้จัก infrastructure โดยตรง — ต้องผ่าน Port (interface) เสมอ Mock Strategy ของ series นี้ reflect หลักการเดียวกัน: share-service test Business Rule โดย mock เฉพาะ Repository interface และ ServiceClient interface (ซึ่งเป็น Port) ไม่ใช่ Prisma หรือ axios โดยตรง ทำให้ Business Rule test ได้โดยไม่ขึ้นกับ infrastructure ใด ๆ
Q: ข้อผิดพลาดที่พบบ่อยในการเขียน test ใน Layered Architecture คืออะไร?
ข้อผิดพลาดที่พบบ่อยที่สุดมี 3 อย่าง: (1) mock มากกว่า boundary เช่น share-service mock business.logic ด้วย ทำให้ test ไม่ได้ตรวจ Business Rule จริง (2) test ผิด layer เช่น เอา E2E Test ไปรันที่ share-data แทนที่จะรันที่ app-{name} ทำให้ setup ซับซ้อนโดยไม่ได้ confidence เพิ่ม (3) ปนกันระหว่าง Fixture และ Seed Data ทำให้ Unit Test ต้องการ DB หรือ E2E Test ไม่ test ด้วย schema จริง