Test Strategy สำหรับ Data Layer: Mock, Fixture และ Describe Structure
Scale-Ready Architecture · Part 5 of 9
share-data: Test Strategy สำหรับทุก Component ใน Data Layer
Scale-Ready Architecture Series — Part นี้ต่อจาก Part 4 — share-data: Action-Based Structure โดยตรง ถ้ายังไม่ได้อ่าน แนะนำให้เริ่มจากนั้นก่อน เพราะ Part นี้จะอ้างถึง
entry.ts,task.ts,flows.ts,db.logic.tsและdata.logic.tsตลอด หลักการ Test Level ภาพรวม (Unit/Integration/E2E) อ่านได้ที่ Part 3 — Testing Overview
สิ่งที่ยังค้างอยู่หลังจาก Part 4
Part 4 สร้าง PlaceOrderV1Entry ได้ครบ — entry delegate ถูก, task จัดการ transaction, flows แยก workflow, DAF ห่อ Prisma, data.logic เป็น pure function ทุกอย่างมีที่อยู่
แต่ถ้าจะเขียน test ยังมีคำถามที่ Part 4 ไม่ได้ตอบ:
- แต่ละ component (entry, task, flows, DAF, pure function) ควร mock อะไร?
- Fixture ควรตั้งชื่อ variant ยังไง? ใช้หลักการอะไรตัดสินใจว่าเก็บ data ที่ไหน?
describeควรจัดยังไง?beforeEachวางที่ระดับไหน?dbclient.mock.tsคืออะไร ทำงานยังไง?- Jest config สำหรับ
share-dataตั้งค่าอะไรบ้าง?
Part 5 ตอบทุกคำถามนี้พร้อม code ที่ใช้ได้จริง
ทำไม share-data ทำแค่ Unit Tests
Clean Architecture กำหนดว่าแต่ละ layer ควรทดสอบตาม responsibility ของตัวเอง —
share-dataรับผิดชอบแค่ “ห่อ Prisma อย่างถูกต้อง” ไม่ใช่ “ทดสอบ business flow ทั้งหมด”
Unit Test ครอบคลุมทุกอย่างที่ share-data รับผิดชอบแล้ว — Integration Test กับ DB จริงไม่ได้ให้ความมั่นใจเพิ่มขึ้นเลย แต่ช้ากว่า 50–100 เท่า ต้องการ Docker, ต้องการ Seed Data และเมื่อ test fail ยากกว่ามากที่จะรู้ว่า fail เพราะอะไร
TypeScript + Unit Tests ครอบคลุมแล้ว:
1. Wiring errors → TypeScript จับที่ compile time
2. Logic errors → Unit test แต่ละ component ครอบคลุม
3. Real DB testing → ทำที่ app-{name} (consumer จริง) แทน
4. E2E workflow → ทำที่ app-{name} ที่มี full context
| Test Level | Layer | ทำที่ไหน | เหตุผล |
|---|---|---|---|
| Unit Test | Data Layer | share-data | ทดสอบ logic แต่ละ component |
| Integration Test | — | ไม่ทำที่ layer ใดเลย | Unit Test ที่แต่ละ layer ครอบคลุม component interaction ภายใน layer แล้ว |
| API Test | HTTP Layer | app-{name} | 1 endpoint ตาม business scenario, Real DB |
| Feature Test | HTTP Layer | app-{name} | หลาย Action ใน 1 BC ตาม scenario, Real DB |
| System Test | HTTP Layer | app-{name} | full flow ข้าม BC จนจบ, Real DB + Docker |
Test File Structure
1 Action = 1 Test File
share-data/src/
├── dbclient.ts ← PrismaClient instance
├── dbclient.mock.ts ← shared prismaMock สำหรับทุก action
├── order-api/
│ └── command/
│ └── place-order/
│ ├── internal.type.ts
│ ├── entry.ts
│ ├── placeOrder.task.ts
│ ├── checkInventory.task.ts
│ ├── flows.ts
│ ├── db.logic.ts
│ ├── data.logic.ts
│ └── __tests__/
│ ├── fixtures/
│ │ ├── order.fixture.ts ← test data ของ ORDER entity
│ │ ├── product.fixture.ts ← test data ของ PRODUCT entity
│ │ └── index.ts
│ └── placeOrder.test.ts ← 1 file ต่อ 1 action
dbclient.ts และ dbclient.mock.ts อยู่ที่ src/ root — ใช้ร่วมกันทุก action ไม่ต้องสร้าง mock ใหม่ต่อ test file
เหตุผลที่ใช้ 1 file ต่อ action: Jest spawn worker ต่อ test file ไม่ใช่ต่อ test case overhead ต่อ worker spawn ~300–500ms
5-6 test files ต่อ action → 5-6 worker spawns
1 test file ต่อ action → 1 worker spawn
dbclient.mock.ts คืออะไร
dbclient.mock.ts เป็น pattern ที่ Prisma แนะนำให้สร้างเพื่อ mock PrismaClient ให้ใช้ร่วมกันได้ทั้งโปรเจกต์ — สร้างครั้งเดียว ทุก test file import ใช้ได้เลย
// src/dbclient.ts
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()
export { PrismaClient }
// src/dbclient.mock.ts
import { mockDeep, mockReset } from 'jest-mock-extended'
import { PrismaClient } from './dbclient'
// บอก Jest ให้ใช้ mock แทน module จริงเมื่อ import './dbclient'
jest.mock('./dbclient', () => ({ __esModule: true }))
// auto-reset ทุก test — ป้องกัน mock state รั่วข้าม test
beforeEach(() => { mockReset(prismaMock) })
// singleton mock — type-safe ทุก model และ method
export const prismaMock = mockDeep<PrismaClient>()
// __tests__/placeOrder.test.ts
import { prismaMock } from '../../../../dbclient.mock' // import ครั้งเดียว
// ใช้งานได้ทันทีใน describe ทุกตัว — ไม่ต้องสร้าง mock ใหม่ต่อ describe
describe('db.logic', () => {
describe('createOrderDAF', () => {
it('returns right with data when created', async () => {
prismaMock.oRDER.create.mockResolvedValue(orderFixtures.pending)
// ...
})
})
})
ข้อดีของ pattern นี้: ไม่ต้อง let mockPrisma + beforeEach(() => mockDeep<PrismaClient>()) ซ้ำทุก describe — mockReset ใน dbclient.mock.ts จัดการ clean state ให้อัตโนมัติ
Fixtures
Fixture ใช้สำหรับ Unit Test เท่านั้น
Fixture → TypeScript object ใน __tests__/fixtures/ → Unit Test
Seed Data → SQL file inject เข้า DB → API Test / Feature Test / System Test
ตัดสินใจจาก test level ไม่ใช่จาก layer — ถ้าเป็น Unit Test ของ share-data ใช้ Fixture เสมอ
Fixture Naming — ชื่อบอก State ของ Entity
Pattern: {entityName}Fixtures — variant ชื่อบอก state ไม่ใช่ type
// __tests__/fixtures/order.fixture.ts
export const orderFixtures = {
// ─── base states ─────────────────────────────────────────────
pending: {
ID: 'order-001',
USER_ID: 'user-001',
STATUS: 'PENDING',
CREATED_AT: new Date('2025-01-15T10:00:00.000Z'),
},
completed: {
ID: 'order-002',
USER_ID: 'user-001',
STATUS: 'COMPLETED',
CREATED_AT: new Date('2025-01-16T10:00:00.000Z'),
},
// ─── edge case states — comment บอกว่าสร้างมาทำไม ────────────
// ใช้ทดสอบ validation ที่ reject เมื่อ user ถูก block
pendingWithBlockedUser: {
ID: 'order-003',
USER_ID: 'user-blocked',
STATUS: 'PENDING',
CREATED_AT: new Date('2025-01-17T10:00:00.000Z'),
},
}
// __tests__/fixtures/product.fixture.ts
export const productFixtures = {
inStock: { ID: 'prod-001', SKU: 'SKU-BOOK-001', STOCK: 10 },
lowStock: { ID: 'prod-002', SKU: 'SKU-PEN-001', STOCK: 1 },
outOfStock: { ID: 'prod-003', SKU: 'SKU-BAG-001', STOCK: 0 },
}
export const orderItemFixtures = {
validItems: [{ productId: 'prod-001', qty: 2 }],
exceedStockItems: [{ productId: 'prod-002', qty: 100 }],
}
// __tests__/fixtures/index.ts
export * from './order.fixture'
export * from './product.fixture'
comment ใน fixture จำเป็นเฉพาะ edge case variant ที่ชื่ออ่านแล้วไม่ชัดว่าสร้างมาทำไม — base states เช่น pending, completed ไม่ต้อง comment
2 เรื่องที่ต้องตัดสินใจแยกกัน
เมื่อเขียน test มีการตัดสินใจ 2 เรื่องที่ ไม่เกี่ยวกัน:
เรื่องที่ 1 — เก็บ test data ที่ไหน
ใช้มากกว่า 1 it() → Fixture file
ใช้ it() เดียว → Inline ใน it()
เรื่องที่ 2 — assign mock ที่ระดับไหน
หลาย it() ใช้ mock เดิม → const ใต้ describe() (describe-level)
it() เดียวใช้ → เขียนใน it() เลย
| เมื่อไหร่ | ตัวอย่าง | |
|---|---|---|
| Describe-level | หลาย it() ใช้ mock เดิม | const mockOrder = orderFixtures.pending |
| Override | it() นั้นต้องการ state ต่างจาก describe-level | mockResolvedValue(left(new OutOfStockFailure())) |
| Spread override | ต้องการ base จาก describe-level แต่เปลี่ยนบาง field | { ...orderFixtures.pending, STATUS: 'CANCELLED' } |
| Inline | it() เดียว ไม่ต้องการ data / error path | mockRejectedValue(new Error('DB error')) |
ตัวอย่างที่แสดงทั้งสองเรื่องพร้อมกัน:
describe('db.logic', () => {
describe('getOrderDAF', () => {
// เรื่องที่ 1: Fixture file — ใช้หลาย it()
// เรื่องที่ 2: describe-level — หลาย it() ใช้ mock เดิม
const mockOrder = orderFixtures.pending
it('returns right with data when found', async () => {
prismaMock.oRDER.findUnique.mockResolvedValue(mockOrder) // describe-level
const result = await getOrderDAF(prismaMock, mockOrder.ID)
expect(result.isRight()).toBe(true)
})
it('queries with correct where clause', async () => {
prismaMock.oRDER.findUnique.mockResolvedValue(mockOrder) // describe-level
await getOrderDAF(prismaMock, mockOrder.ID)
expect(prismaMock.oRDER.findUnique).toHaveBeenCalledWith({ where: { ID: mockOrder.ID } })
})
it('returns left with GetOrderDAFFail when Prisma rejects', async () => {
// เรื่องที่ 1+2: inline — ไม่ต้องการ data, ใช้ it() เดียว
prismaMock.oRDER.findUnique.mockRejectedValue(new Error('DB error'))
const result = await getOrderDAF(prismaMock, 'order-001')
expect(result.isLeft()).toBe(true)
})
})
})
วิธีรู้ว่า fixture variant ถูกใช้ใน it() ไหน: ใช้ IDE “Find All References” — คลิกขวาที่ orderFixtures.pending → Find All References → เห็นทุก it() ที่ใช้ทันที
Mock Level ต่อ Component
| Component | Mock อะไร | เหตุผล |
|---|---|---|
entry | task files | ทดสอบ delegation เท่านั้น |
{method}.task | flows + db.logic + data.logic* | ทดสอบ orchestration ไม่ใช่ implementation |
flows | db.logic + data.logic | ทดสอบ workflow logic |
db.logic | prismaMock จาก dbclient.mock.ts | ทดสอบ query arguments |
data.logic | ไม่มี | pure functions ทดสอบตรงได้เลย |
*data.logic mock เฉพาะเมื่อ task เรียกใช้โดยตรง — mock ตาม direct dependency จริงของ task นั้น
หมายเหตุ: Data Layer จงใจ mock own code เช่น flows และ db.logic เพราะแต่ละ describe section ทดสอบ 1 component scope — ถ้าไม่ mock จะกลายเป็น test หลาย component พร้อมกัน ซึ่งเป็น integration test ไม่ใช่ unit test
Mock Implementation Examples
Entry — Mock Task Files
import { placeOrderTask } from '../placeOrder.task'
import { checkInventoryTask } from '../checkInventory.task'
import { PlaceOrderV1Entry } from '../entry'
jest.mock('../placeOrder.task')
jest.mock('../checkInventory.task')
const mockPlaceOrderTask = placeOrderTask as jest.MockedFunction<typeof placeOrderTask>
describe('entry', () => {
// describe-level — ทุก it() ใช้ entry instance เดิม
const mockClient = {} as PrismaClient
const mockContext = { requestId: 'test-123' } as UnifiedHttpContext
let entry: PlaceOrderV1Entry
beforeEach(() => {
entry = new PlaceOrderV1Entry(mockClient)
})
it('should delegate placeOrder to placeOrderTask with correct input', async () => {
// Arrange — inline: props และ expectedOutput ใช้ it() เดียว
const props = { userId: 'user-001', items: orderItemFixtures.validItems }
const expectedOutput = right(orderFixtures.pending)
mockPlaceOrderTask.mockResolvedValue(expectedOutput)
// Act
const result = await entry.placeOrder(mockContext, props)
// Assert
expect(result).toBe(expectedOutput)
expect(mockPlaceOrderTask).toHaveBeenCalledWith({
context: mockContext,
client: mockClient,
props,
})
})
it('should return failure when task returns failure', async () => {
// Arrange — inline
mockPlaceOrderTask.mockResolvedValue(left(new BaseFailure('task failed')))
// Act
const result = await entry.placeOrder(mockContext, {
userId: 'user-001',
items: orderItemFixtures.validItems,
})
// Assert
expect(result.isLeft()).toBe(true)
})
})
Task — Mock flows + db.logic + data.logic
mock ตาม direct dependency จริงของ task — ถ้า task เรียก data.logic โดยตรงต้อง mock ด้วย
import { reserveItemsFlow } from '../flows'
import { createOrderDAF } from '../db.logic'
import { validateOrderInput, transformPlaceOrderToOutput } from '../data.logic'
import { placeOrderTask } from '../placeOrder.task'
jest.mock('../flows')
jest.mock('../db.logic')
jest.mock('../data.logic') // ← mock เพราะ task เรียก validateOrderInput โดยตรง
const mockReserveItemsFlow = reserveItemsFlow as jest.MockedFunction<typeof reserveItemsFlow>
const mockCreateOrderDAF = createOrderDAF as jest.MockedFunction<typeof createOrderDAF>
const mockValidateOrderInput = validateOrderInput as jest.MockedFunction<typeof validateOrderInput>
const mockTransformPlaceOrderToOutput = transformPlaceOrderToOutput as jest.MockedFunction<typeof transformPlaceOrderToOutput>
describe('placeOrderTask', () => {
// describe-level — ทุก it() ใช้ mockClient เดิม
const mockClient = { $transaction: jest.fn() } as unknown as PrismaClient
it('should stop and return fail when validateOrderInput fails', async () => {
// Arrange — inline
mockValidateOrderInput.mockReturnValue({ isValid: false, errors: ['userId is required'] })
// Act
const result = await placeOrderTask({
client: mockClient,
props: { userId: '', items: [] },
})
// Assert
expect(result.isLeft()).toBe(true)
expect(mockReserveItemsFlow).not.toHaveBeenCalled()
expect(mockClient.$transaction).not.toHaveBeenCalled()
})
it('should stop and return fail when reserveItemsFlow fails', async () => {
// Arrange — inline
mockValidateOrderInput.mockReturnValue({ isValid: true, errors: [] })
mockReserveItemsFlow.mockResolvedValue(left(new OutOfStockFailure()))
// Act
const result = await placeOrderTask({
client: mockClient,
props: { userId: 'user-001', items: orderItemFixtures.exceedStockItems },
})
// Assert
expect(result.isLeft()).toBe(true)
expect(mockClient.$transaction).not.toHaveBeenCalled()
})
it('should execute writes inside $transaction on success', async () => {
// Arrange — inline: แต่ละ it() setup ต่างกัน ไม่ใช้ describe-level
mockValidateOrderInput.mockReturnValue({ isValid: true, errors: [] })
mockReserveItemsFlow.mockResolvedValue(right({ items: [{ productId: 'prod-001', qty: 2, sku: 'SKU-001' }] }));
(mockClient.$transaction as jest.Mock).mockImplementation(async (fn) => fn(mockClient))
mockCreateOrderDAF.mockResolvedValue(right(orderFixtures.pending))
// Act
await placeOrderTask({
client: mockClient,
props: { userId: 'user-001', items: orderItemFixtures.validItems },
})
// Assert
expect(mockClient.$transaction).toHaveBeenCalledTimes(1)
expect(mockCreateOrderDAF).toHaveBeenCalled()
})
it('should return TransactionFailure when $transaction throws', async () => {
// Arrange — inline
mockValidateOrderInput.mockReturnValue({ isValid: true, errors: [] })
mockReserveItemsFlow.mockResolvedValue(right({ items: [] }));
(mockClient.$transaction as jest.Mock).mockRejectedValue(new Error('DB error'))
// Act
const result = await placeOrderTask({
client: mockClient,
props: { userId: 'user-001', items: orderItemFixtures.validItems },
})
// Assert
expect(result.isLeft()).toBe(true)
expect(result.value).toBeInstanceOf(TransactionFailure)
})
})
Flow — Mock db.logic + data.logic
import { getProductDAF, updateProductStockDAF } from '../db.logic'
import { calculateOrderSummary } from '../data.logic'
import { reserveItemsFlow } from '../flows'
jest.mock('../db.logic')
jest.mock('../data.logic')
const mockGetProductDAF = getProductDAF as jest.MockedFunction<typeof getProductDAF>
const mockUpdateProductStockDAF = updateProductStockDAF as jest.MockedFunction<typeof updateProductStockDAF>
describe('flows', () => {
describe('reserveItemsFlow', () => {
// describe-level — Fixture file, หลาย it() ใช้ base เดิม
const mockProduct = productFixtures.inStock
it('should return right with reserved items on success', async () => {
// Arrange — describe-level
mockGetProductDAF.mockResolvedValue(right(mockProduct))
mockUpdateProductStockDAF.mockResolvedValue(right(mockProduct))
// Act
const result = await reserveItemsFlow({
client: {} as PrismaClient,
orderId: 'order-001',
items: [{ productId: 'prod-001', qty: 2 }],
})
// Assert
expect(result.isRight()).toBe(true)
})
it('should stop and return fail when getProductDAF fails', async () => {
// Arrange — override describe-level: ต้องการ fail แทน success
mockGetProductDAF.mockResolvedValue(left(new GetProductDAFFail()))
// Act
const result = await reserveItemsFlow({
client: {} as PrismaClient,
orderId: 'order-001',
items: [{ productId: 'non-existent', qty: 1 }],
})
// Assert
expect(result.isLeft()).toBe(true)
expect(mockUpdateProductStockDAF).not.toHaveBeenCalled()
})
it('should not process remaining items when first item fails', async () => {
// Arrange — override describe-level: item แรก fail
mockGetProductDAF
.mockResolvedValueOnce(left(new GetProductDAFFail())) // prod-001: fail
.mockResolvedValueOnce(right(productFixtures.inStock)) // prod-002: ไม่ถูกเรียก
// Act
await reserveItemsFlow({
client: {} as PrismaClient,
orderId: 'order-001',
items: [
{ productId: 'prod-001', qty: 1 },
{ productId: 'prod-002', qty: 1 },
],
})
// Assert — getProductDAF ถูกเรียกแค่ครั้งเดียว
expect(mockGetProductDAF).toHaveBeenCalledTimes(1)
})
})
})
db.logic — prismaMock จาก dbclient.mock.ts
import { prismaMock } from '../../../../dbclient.mock'
import { createOrderDAF, getProductDAF } from '../db.logic'
import { CreateOrderDAFFail, GetProductDAFFail } from '../internal.type'
describe('db.logic', () => {
// prismaMock reset อัตโนมัติผ่าน beforeEach ใน dbclient.mock.ts
describe('createOrderDAF', () => {
// describe-level — Fixture file, หลาย it() ใช้ base เดิม
const mockOrder = orderFixtures.pending
it('queries with correct data fields', async () => {
prismaMock.oRDER.create.mockResolvedValue(mockOrder) // describe-level
await createOrderDAF(prismaMock, { userId: 'user-001', status: 'PENDING' })
expect(prismaMock.oRDER.create).toHaveBeenCalledWith({
data: expect.objectContaining({ USER_ID: 'user-001', STATUS: 'PENDING' }),
})
})
it('returns right with data when created', async () => {
prismaMock.oRDER.create.mockResolvedValue(mockOrder) // describe-level
const result = await createOrderDAF(prismaMock, { userId: 'user-001', status: 'PENDING' })
expect(result.isRight()).toBe(true)
})
it('returns left with CreateOrderDAFFail when Prisma rejects', async () => {
// inline — ไม่ต้องการ mockOrder
prismaMock.oRDER.create.mockRejectedValue(new Error('DB error'))
const result = await createOrderDAF(prismaMock, { userId: 'user-001', status: 'PENDING' })
expect(result.isLeft()).toBe(true)
expect(result.value).toBeInstanceOf(CreateOrderDAFFail)
})
})
describe('getProductDAF', () => {
// describe-level — Fixture file, หลาย it() ใช้ base เดิม
const mockProduct = productFixtures.inStock
it('queries with correct productId', async () => {
prismaMock.pRODUCT.findUnique.mockResolvedValue(mockProduct) // describe-level
await getProductDAF(prismaMock, mockProduct.ID)
expect(prismaMock.pRODUCT.findUnique).toHaveBeenCalledWith({
where: { ID: mockProduct.ID },
select: { ID: true, SKU: true, STOCK: true },
})
})
it('returns right with data when found', async () => {
prismaMock.pRODUCT.findUnique.mockResolvedValue(mockProduct) // describe-level
const result = await getProductDAF(prismaMock, mockProduct.ID)
expect(result.isRight()).toBe(true)
})
it('returns left with GetProductDAFFail when Prisma rejects', async () => {
// inline — ไม่ต้องการ mockProduct
prismaMock.pRODUCT.findUnique.mockRejectedValue(new Error('DB timeout'))
const result = await getProductDAF(prismaMock, 'prod-001')
expect(result.isLeft()).toBe(true)
expect(result.value).toBeInstanceOf(GetProductDAFFail)
})
})
})
data.logic — ไม่ต้อง Mock (Pure Functions)
import { validateOrderInput, transformPlaceOrderToOutput, calculateOrderSummary } from '../data.logic'
describe('data.logic', () => {
describe('validateOrderInput', () => {
// ไม่มี describe-level — แต่ละ it() ใช้ input ต่างกัน → inline ทั้งหมด
it('passes with valid input', () => {
const result = validateOrderInput({ userId: 'user-001', items: [{ productId: 'p1', qty: 1 }] })
expect(result.isValid).toBe(true)
})
it('fails when userId is missing', () => {
const result = validateOrderInput({ userId: '', items: [{ productId: 'p1', qty: 1 }] })
expect(result.isValid).toBe(false)
expect(result.errors).toContain('userId is required')
})
it('fails when items is empty', () => {
const result = validateOrderInput({ userId: 'user-001', items: [] })
expect(result.isValid).toBe(false)
expect(result.errors).toContain('items must not be empty')
})
})
describe('calculateOrderSummary', () => {
// describe-level — Fixture file, ทุก it() ใช้ base เดิม
const items = [{ qty: 2, price: 100 }, { qty: 1, price: 250 }]
it('calculates totalItems correctly', () => {
const result = calculateOrderSummary(items) // describe-level
expect(result.totalItems).toBe(3)
})
it('calculates totalAmount correctly', () => {
const result = calculateOrderSummary(items) // describe-level
expect(result.totalAmount).toBe(450)
})
it('returns zero when items is empty', () => {
// override describe-level: ต้องการ empty array แทน
const result = calculateOrderSummary([])
expect(result.totalItems).toBe(0)
expect(result.totalAmount).toBe(0)
})
})
describe('transformPlaceOrderToOutput', () => {
// describe-level — Fixture file
const raw = orderFixtures.pending
it('maps all fields correctly', () => {
const result = transformPlaceOrderToOutput(raw, [{ productId: 'p1', qty: 2, price: 100 }])
expect(result.orderId).toBe(raw.ID)
expect(result.userId).toBe(raw.USER_ID)
})
it('handles null optional fields', () => {
// spread override จาก describe-level
const result = transformPlaceOrderToOutput({ ...raw, NOTE: null }, [])
expect(result.note).toBeNull()
})
})
})
Describe Structure
หลักการ
Level 1 = source file name (entry, placeOrderTask, flows, db.logic, data.logic)
Level 2 = function name (เฉพาะ flows, db.logic, data.logic ที่มีหลาย function)
Level 3 = scenario (it/test)
beforeEach(() => jest.clearAllMocks()) วางนอก describe ทุกตัว
ครอบทุก Level 1 ในไฟล์เดียวกันได้อัตโนมัติ
// __tests__/placeOrder.test.ts
import { orderFixtures, productFixtures, orderItemFixtures } from './fixtures'
// ─── top-level — ครอบทุก describe ในไฟล์ ──────────────────────────
beforeEach(() => jest.clearAllMocks())
// ─── entry.ts ────────────────────────────────────────────────────
// Level 1: file name | mock: placeOrder.task, checkInventory.task
describe('entry', () => {
it('should delegate placeOrder to placeOrderTask with correct input')
it('should delegate checkInventory to checkInventoryTask with correct input')
it('should return failure when task returns failure')
})
// ─── placeOrder.task.ts ──────────────────────────────────────────
// Level 1: file name | mock: flows, db.logic, data.logic
describe('placeOrderTask', () => {
it('should stop and return fail when validateOrderInput fails')
it('should stop and return fail when reserveItemsFlow fails')
it('should execute writes inside $transaction on success')
it('should return TransactionFailure when $transaction throws')
it('should return transformed output on success')
})
// ─── flows.ts ────────────────────────────────────────────────────
// Level 1: filename | mock: db.logic, data.logic
describe('flows', () => {
describe('reserveItemsFlow', () => { // Level 2
it('should return right with reserved items on success') // Level 3
it('should stop and return fail when getProductDAF fails')
it('should not process remaining items when first item fails')
})
})
// ─── db.logic.ts ─────────────────────────────────────────────────
// Level 1: file name | mock: prismaMock จาก dbclient.mock.ts
describe('db.logic', () => {
describe('createOrderDAF', () => { // Level 2
it('queries with correct data fields') // Level 3
it('returns right with data when created')
it('returns left with CreateOrderDAFFail when Prisma rejects')
})
describe('getProductDAF', () => { // Level 2
it('queries with correct productId') // Level 3
it('returns right with data when found')
it('returns left with GetProductDAFFail when Prisma rejects')
})
})
// ─── data.logic.ts ───────────────────────────────────────────────
// Level 1: file name | mock: ไม่มี — pure functions
describe('data.logic', () => {
describe('validateOrderInput', () => { // Level 2
it('passes with valid input') // Level 3
it('fails when userId is missing')
it('fails when items is empty')
})
describe('calculateOrderSummary', () => { // Level 2
it('calculates totalItems correctly') // Level 3
it('calculates totalAmount correctly')
it('returns zero when items is empty')
})
})
Jest แสดง file path อยู่แล้วตอน run — ไม่จำเป็นต้องมี top-level describe ครอบ action name อีกชั้น
PASS src/.../place-order/__tests__/placeOrder.test.ts
entry
✓ should delegate placeOrder to placeOrderTask with correct input
placeOrderTask
✓ should stop and return fail when validateOrderInput fails
flows
reserveItemsFlow
✓ should return right with reserved items on success
db.logic
createOrderDAF
✓ queries with correct data fields
data.logic
validateOrderInput
✓ passes with valid input
ทำไมไม่เกิน 3 ระดับ
// ❌ 4 levels — ต้อง scroll กลับขึ้นไปดู context ตลอด
describe('db.logic', () => {
describe('getProductDAF', () => {
describe('when product exists', () => {
describe('with sufficient stock', () => {
it('returns product') // Level 4 — อยู่ลึกมาก
})
})
})
})
// ✅ 3 levels — ย้าย condition เข้าไปใน it() name แทน
describe('db.logic', () => {
describe('getProductDAF', () => {
it('returns right with data when found')
})
})
nested ลึกกว่า 3 levels มักเป็น signal ว่า function ใหญ่เกินไป — ควรแยก function ออกมาและ test แยก
Test Naming
it() บอก scenario เท่านั้น — ไม่ระบุ function name ซ้ำถ้า describe Level 2 บอกไปแล้ว
// ✅ describe บอก function แล้ว — it() บอกแค่ scenario
describe('db.logic', () => {
describe('createOrderDAF', () => {
it('queries with correct data fields')
it('returns right with data when created')
it('returns left with CreateOrderDAFFail when Prisma rejects')
})
})
// ✅ Level 1 ที่ไม่มี Level 2 (entry, task) — it() ต้องบอก scenario ชัดเจน
describe('entry', () => {
it('should delegate placeOrder to placeOrderTask with correct input')
it('should return failure when task returns failure')
})
describe('placeOrderTask', () => {
it('should stop and return fail when reserveItemsFlow fails')
it('should execute writes inside $transaction on success')
})
// ❌ ซ้ำกับ describe Level 2
describe('createOrderDAF', () => {
it('createOrderDAF — returns data when created') // ← พูดซ้ำ
})
// ❌ generic เกินไป
it('should work')
it('test error case')
AAA Pattern
it('updates status and UPDATED_AT correctly', async () => {
// Arrange
prismaMock.oRDER.update.mockResolvedValue(orderFixtures.pending)
// Act
await updateOrderStatusDAF(prismaMock, { id: 'order-001', status: 'COMPLETED' })
// Assert
expect(prismaMock.oRDER.update).toHaveBeenCalledWith({
where: { ID: 'order-001' },
data: { STATUS: 'COMPLETED', UPDATED_AT: expect.any(Date) },
})
})
Jest Configuration
Data Layer ใช้ jest config เดียว — ไม่แยก unit/integration/system เพราะทำแค่ unit tests:
// jest.config.ts
import type { Config } from 'jest'
const config: Config = {
displayName: 'share-data',
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/test-setup/jest.setup.ts'],
clearMocks: true,
resetMocks: true,
restoreMocks: true,
}
export default config
// test-setup/jest.setup.ts
// Global mock สำหรับ external services ที่ใช้ทุก test
jest.mock('@system/telemetry', () => ({
TelemetryMiddlewareService: jest.fn().mockImplementation(() => ({
executeFunctionWithDbSpan: jest.fn().mockImplementation(
async (_ctx, fn, input) => fn(input)
),
})),
}))
SonarQube Configuration
# sonar-project.properties
sonar.cpd.exclusions=**/*.type.ts,**/__tests__/**/*.test.ts
| Pattern | เหตุผล |
|---|---|
**/*.type.ts | Type definitions มีโครงสร้างซ้ำกันตามธรรมชาติ (extends BaseFailure, constructor pattern เหมือนกัน) |
**/__tests__/**/*.test.ts | Test code ต้องการ explicit มากกว่า DRY — Arrange ใน it() ทุกตัวมี mock setup ซ้ำกันตามธรรมชาติ การ abstract เพื่อลด duplication ทำให้อ่านยากขึ้นโดยไม่จำเป็น |
Run Commands
# Run all tests
jest
# Run tests สำหรับ action เดียว
jest --testPathPattern=placeOrder
# Run พร้อม coverage
jest --coverage
# Watch mode (dev)
jest --watch
Test Implementation Priority
Priority 1 — data.logic (Pure Functions)
เขียนก่อน: ไม่ต้อง mock, เร็วที่สุด, feedback ทันที
Priority 2 — db.logic (DAF Functions)
mock prismaMock ตรวจ query arguments + error conversion
Priority 3 — flows (Workflow Logic)
mock db.logic + data.logic ตรวจ workflow + early exit behavior
Priority 4 — task (Orchestration + Transaction)
mock flows + db.logic + data.logic ตรวจ orchestration + transaction zone
Priority 5 — entry (Delegation)
mock tasks ตรวจ delegation เท่านั้น — value ต่ำสุด เขียนสุดท้าย
สรุป
1 Action = 1 Test File — ลด Jest worker overhead
dbclient.mock.ts ที่ src/ root — singleton mock ใช้ร่วมกันทุก action, mockReset ใน beforeEach จัดการ clean state ให้อัตโนมัติ
Fixture naming แบบ state-based — pending, completed, outOfStock บอก state ของ entity ไม่ใช่ type; comment เฉพาะ edge case
2 เรื่องแยกกัน: เรื่องที่ 1 คือเก็บ data ที่ไหน (Fixture file vs inline) เรื่องที่ 2 คือ assign mock ที่ระดับไหน (describe-level vs override)
Mock level ต่อ component: entry → mock task, task → mock flows + db.logic + data.logic, flows → mock db.logic + data.logic, db.logic → prismaMock, data.logic → ไม่มี
beforeEach(() => jest.clearAllMocks()) ที่ top-level — ครอบทุก describe ครั้งเดียว
Jest config เดียว + jest.setup.ts สำหรับ global mock
บทความถัดไปใน Series
Part 6 — share-client: HTTP ServiceClient จะออกแบบ HTTP client layer ที่ implement contract จาก share-core — แทนที่จะ mock PrismaClient จะ mock axios แทน และเชื่อมกับ Remote Service แบบ type-safe
FAQ
Q: ทำไม share-data ถึงไม่ทำ E2E Test กับ DB จริงเลย?
share-data มี responsibility แคบมาก — ห่อ Prisma call และแปลง error เท่านั้น Unit Test ครอบคลุมได้ครบโดยไม่ต้องการ DB จริง
- Schema mismatch → TypeScript +
prisma generateจับที่ compile time - Logic errors → Unit Test แต่ละ component ครอบคลุมแล้ว
- E2E กับ DB จริง → ทำที่
app-{name}ในรูปแบบ API Test / Feature Test / System Test ซึ่งมี full context (auth, middleware, connection pool) ครบ
Q: ทำไม flows ถึง mock db.logic แทนที่จะ mock PrismaClient โดยตรง?
เพราะแต่ละ describe section ทดสอบ 1 component scope เท่านั้น
- เมื่อ test
flows— ต้องการทดสอบ workflow logic ของ flow เท่านั้น - ถ้า mock PrismaClient โดยตรง → กลายเป็นทดสอบ flow + DAF พร้อมกัน ซึ่งเป็น integration test ของ 2 component ไม่ใช่ unit test ของ flow
db.logicมี test ของตัวเองแยกอยู่แล้ว ไม่ต้อง test ซ้ำผ่าน flow
Q: Fixture กับ Seed Data ต่างกันอย่างไร และตัดสินใจเลือกยังไง?
ตัดสินใจจาก test level ไม่ใช่ layer
| Fixture | Seed Data | |
|---|---|---|
| คืออะไร | TypeScript object ใน code | SQL / script inject เข้า DB จริง |
| ใช้เมื่อ | Unit Test | API Test / Feature Test / System Test |
| ต้องการ DB | ไม่ต้องการ | ต้องการ (+ Docker ใน CI) |
เมื่อ test อยู่ใน share-data และเป็น Unit Test → Fixture เสมอ ไม่มีข้อยกเว้น
Q: ทำไม beforeEach(() => jest.clearAllMocks()) ถึงต้องอยู่ top-level ไม่ใช่ใน each describe?
มี 2 เหตุผล
- ครอบทุก describe ครั้งเดียว — ถ้าวางใน each describe ต้องคัดลอกซ้ำทุก describe และเสี่ยงลืมใส่ในบาง describe ทำให้ mock state รั่วข้าม test โดยไม่รู้ตัว
- prismaMock clean อยู่แล้ว —
dbclient.mock.tsมีmockReset(prismaMock)ในbeforeEachอยู่แล้วjest.clearAllMocks()ที่ top-level จึงครอบ mock อื่นๆ ที่เหลือในไฟล์
Q: ข้อผิดพลาดที่พบบ่อยเมื่อเขียน Unit Test สำหรับ Data Layer คืออะไร?
(1) Mock PrismaClient ใน flow test แทนที่จะ mock db.logic
ทำให้กลายเป็น test หลาย component พร้อมกัน ควร mock db.logic เสมอเมื่อ test flows
(2) Fixture ตั้งชื่อตาม type แทน state
rawOrder, orderOutput อ่านแล้วไม่รู้ state — ควรใช้ pending, completed, outOfStock
(3) beforeEach ซ้ำใน each describe
ควรวาง top-level ครั้งเดียว ครอบทุก describe ในไฟล์
(4) ไม่มี comment ใน edge case fixture
เมื่อกลับมาอ่านทีหลังไม่รู้ว่า pendingWithBlockedUser สร้างมาทำไม — ควร comment เฉพาะ variant ที่ชื่ออ่านแล้วไม่ชัด
(5) describe ซ้อนกันเกิน 3 ระดับ signal ว่า function ใหญ่เกินไป ควรแยก function ออกมาและ test แยก