Data Layer Test Strategy
Overview
กลยุทธ์การทดสอบสำหรับ Data Layer ที่ออกแบบตาม Action-Based Structure โดยใช้ Unit Tests เท่านั้น เน้น build time ที่เร็ว, convention ที่ไม่ต้องตัดสินใจ และ SonarQube compliance
Core Testing Philosophy
Data Layer Testing Focus
Data Layer มุ่งเน้นทดสอบ ความถูกต้องของแต่ละ component ภายใน action:
- Entry: delegate ไปยัง task ถูกต้อง
- Task: orchestrate flows และ DAF ถูกลำดับ, จัดการ transaction boundary
- Flows: workflow logic และ error handling ของแต่ละ flow
- db.logic: DAF queries ถูก arguments, handle not found/error ถูกต้อง
- data.logic: transform และ validate ถูกต้อง (pure functions)
ทำไม Unit Tests เท่านั้น
Data Layer ไม่ต้องการ Integration Test (Real DB) หรือ System Test เพราะ:
TypeScript + Unit Tests ครอบคลุมแล้ว:
1. Wiring errors → TypeScript จับที่ compile time
2. Logic errors → Unit test แต่ละ component ครอบคลุม
3. Real DB testing → ทำที่ HTTP Layer (consumer จริง) แทน
4. E2E workflow → ทำที่ HTTP Layer ที่มี full context
Integration/System Test ที่ Data Layer จะได้ confidence น้อยมากเมื่อเทียบกับ cost ของการ setup Docker และ real DB
| Test Level | ทำที่ไหน | เหตุผล |
|---|---|---|
| Unit Test | Data Layer ✅ | ทดสอบ logic แต่ละ component |
| Integration Test | HTTP Layer | มี full context จริง (auth, service layer) |
| System / E2E | HTTP Layer | test full workflow ใกล้ production |
Testing Principles
- Fast Feedback: Unit tests ต้องเร็ว — mock ทุก external dependency
- 1 File per Action: ลด Jest worker spawn overhead
- Isolated Tests: แต่ละ test เป็นอิสระ ไม่ depend กัน
- Zero-Decision Convention: ทุก describe section รู้ทันทีว่า mock อะไร
Test File Structure
1 Action = 1 Test File
store-prisma/src/
├── dbclient.ts ← PrismaClient instance
├── dbclient.mock.ts ← shared prismaMock สำหรับทุก action
├── document-process-api/
│ └── command/
│ └── return-request-lv40/
│ ├── internal.type.ts ← types, interfaces และ custom error classes
│ ├── entry.ts
│ ├── returnRequest.task.ts
│ ├── checkStatus.task.ts
│ ├── flows.ts
│ ├── db.logic.ts
│ ├── data.logic.ts
│ └── __tests__/
│ ├── fixtures/
│ │ ├── application.fixture.ts ← test data ของ APPLICATION entity
│ │ ├── activity.fixture.ts ← test data ของ ACTIVITY entity
│ │ ├── packsize.fixture.ts ← test data ของ PACKSIZE entity
│ │ └── index.ts ← re-export ทั้งหมด
│ └── returnRequestLv40.test.ts
เหตุผลที่ใช้ 1 file:
Jest spawn worker ต่อ test file — ไม่ใช่ต่อ test case
เดิม: 5-6 test files ต่อ action → 5-6 worker spawns
ใหม่: 1 test file ต่อ action → 1 worker spawn
overhead ต่อ worker spawn ~300-500ms — สะสมมีผลชัดเจนเมื่อมีหลาย action
Fixtures
Fixtures เก็บ static test data สำหรับ unit tests — ใช้แทน SQL seed/load data ที่ต้องการ real DB
Pattern: {entityName}.fixture.ts — 1 entity = 1 file, ชื่อ variant บอก state ของ entity
// __tests__/fixtures/application.fixture.ts
export const applicationFixtures = {
// ─── base states — ชื่อบอก status ของ entity ────────────────
pending: { ID: 'test-app-001', STATUS: 'PENDING', CURRENT_ACTOR_ID: 'user-001', ... },
approved: { ID: 'test-app-002', STATUS: 'APPROVED', CURRENT_ACTOR_ID: 'user-001', ... },
completed: { ID: 'test-app-003', STATUS: 'COMPLETED', CURRENT_ACTOR_ID: null, ... },
// ─── edge case states — comment บอกว่าสร้างมาทำไม ─────────────
// ใช้ทดสอบ validation ที่ reject เมื่อไม่มี actor
pendingWithNoActor: { ID: 'test-app-004', STATUS: 'PENDING', CURRENT_ACTOR_ID: null, ... },
}
// __tests__/fixtures/activity.fixture.ts
export const activityFixtures = {
lv40Activity: { ID: 'act-001', PROCESS_ID: 'proc-001', ACTIVITY_LEVEL: 'LV40' },
processActivities: [
{ ID: 'act-001', ACTIVITY_LEVEL: 'LV10' },
{ ID: 'act-002', ACTIVITY_LEVEL: 'LV20' },
{ ID: 'act-003', ACTIVITY_LEVEL: 'LV40' },
],
}
// __tests__/fixtures/packsize.fixture.ts
export const packsizeFixtures = {
standard: { ID: 'pack-001', PACKSIZE_ID: 'SIZE_1KG', QTY: 10 },
bulk: { ID: 'pack-002', PACKSIZE_ID: 'SIZE_5KG', QTY: 50 },
}
// __tests__/fixtures/index.ts
export * from './application.fixture';
export * from './activity.fixture';
export * from './packsize.fixture';
หลักการใช้ Fixture และ Mock Data
มี 2 เรื่องที่แยกกันคนละมิติ:
เรื่องที่ 1 — เก็บ test data ที่ไหน
ใช้มากกว่า 1 it() → Fixture file
ใช้ it() เดียว → Inline ใน it()
เรื่องที่ 2 — assign mock ที่ระดับไหน
หลาย it() ใช้ mock เดิม → const ใต้ describe() (describe-level)
it() เดียวใช้ → เขียนใน it() เลย
ตัวอย่างครบทุก Case
ตัวอย่างนี้แสดงทั้ง เรื่องที่ 1 (เก็บ data ที่ไหน) และ เรื่องที่ 2 (assign mock ที่ระดับไหน) พร้อมกัน
import { applicationFixtures, activityFixtures } from './fixtures'
describe('db.logic', () => {
describe('getApplicationDAF', () => {
// เรื่องที่ 1: Fixture file — ใช้หลาย it()
// เรื่องที่ 2: describe-level — หลาย it() ใช้ mock เดิม
const mockApp = applicationFixtures.pending
it('returns right with data when found', async () => {
prismaMock.aPPLICATION.findUnique.mockResolvedValue(mockApp) // describe-level
const result = await getApplicationDAF(prismaMock, 'app-001')
expect(result.isRight()).toBe(true)
})
it('queries with correct where clause', async () => {
prismaMock.aPPLICATION.findUnique.mockResolvedValue(mockApp) // describe-level
await getApplicationDAF(prismaMock, mockApp.ID)
expect(prismaMock.aPPLICATION.findUnique).toHaveBeenCalledWith({ where: { ID: mockApp.ID } })
})
it('returns left with DAFFail when Prisma rejects', async () => {
// เรื่องที่ 1+2: inline — ไม่ต้องการ data, ใช้ it() เดียว
prismaMock.aPPLICATION.findUnique.mockRejectedValue(new Error('DB error'))
const result = await getApplicationDAF(prismaMock, 'app-001')
expect(result.isLeft()).toBe(true)
})
})
})
describe('flows', () => {
describe('getAllActivityFlow', () => {
// เรื่องที่ 1: Fixture file
// เรื่องที่ 2: describe-level
const mockActivity = activityFixtures.lv40Activity
const mockProcessList = activityFixtures.processActivities
it('returns mapped result on success', async () => {
mockFindActivityDAF.mockResolvedValue(right(mockActivity)) // describe-level
mockFindManyDAF.mockResolvedValue(right(mockProcessList)) // describe-level
const result = await getAllActivityFlow({ ... })
expect(result.isRight()).toBe(true)
})
it('returns left when findActivityDAF fails', async () => {
// เรื่องที่ 2: override describe-level — ต้องการ fail แทน success
mockFindActivityDAF.mockResolvedValue(left(new ActivityNotFoundFailure()))
const result = await getAllActivityFlow({ ... })
expect(result.isLeft()).toBe(true)
})
it('returns left when findManyProcessActivityDAF fails', async () => {
mockFindActivityDAF.mockResolvedValue(right(mockActivity)) // describe-level
mockFindManyDAF.mockResolvedValue(left(new ProcessActivityFailure())) // override เฉพาะตัวนี้
const result = await getAllActivityFlow({ ... })
expect(result.isLeft()).toBe(true)
})
})
})
describe('data.logic', () => {
describe('transformReturnRequestToOutput', () => {
// เรื่องที่ 1: Fixture file
// เรื่องที่ 2: describe-level
const raw = applicationFixtures.pending
it('maps all fields correctly', () => {
const result = transformReturnRequestToOutput(raw) // describe-level
expect(result.id).toBe(raw.ID)
})
it('handles null optional fields', () => {
// เรื่องที่ 2: spread override จาก describe-level
const result = transformReturnRequestToOutput({ ...raw, CURRENT_ACTOR_ID: null })
expect(result.currentActorId).toBeNull()
})
})
describe('validateReturnRequestInput', () => {
// ไม่มี describe-level — แต่ละ it() ใช้ input ต่างกัน → inline ทั้งหมด
it('passes with valid input', () => {
// เรื่องที่ 1+2: inline — object เล็ก ใช้ it() เดียว
const result = validateReturnRequestInput({ id: 'app-001', reason: 'test' })
expect(result.isValid).toBe(true)
})
it('fails when id is missing', () => {
const result = validateReturnRequestInput({ id: '', reason: 'test' }) // inline
expect(result.isValid).toBe(false)
})
})
})
สรุปทั้ง 2 เรื่อง
เรื่องที่ 1 — เก็บ test data ที่ไหน:
| เมื่อไหร่ | |
|---|---|
| Fixture file | ใช้มากกว่า 1 it() |
| Inline | ใช้ it() เดียวแล้วจบ |
เรื่องที่ 2 — assign mock ที่ระดับไหน:
| เมื่อไหร่ | ตัวอย่าง | |
|---|---|---|
| Describe-level | หลาย it() ใช้ mock เดิม | const mockApp = applicationFixtures.pending |
| Override | it() นั้นต้องการ state ต่างจาก describe-level | mockFindActivityDAF.mockResolvedValue(left(...)) |
| Spread override | ต้องการ base จาก describe-level แต่เปลี่ยนบาง field | { ...raw, CURRENT_ACTOR_ID: null } |
| Inline | it() เดียว ไม่ต้องการ data / error path | mockRejectedValue(new Error('x')) |
วิธีรู้ว่า fixture variant ถูกใช้ใน it() ไหน
ใช้ IDE “Find All References” — คลิกขวาที่ applicationFixtures.pending → Find All References → เห็นทุก it() ที่ใช้ทันที
comment ใน fixture file จำเป็นเฉพาะ edge case variant ที่ชื่ออ่านแล้วไม่ชัดว่าสร้างมาทำไมเท่านั้น
Describe Structure
หลักการ
Level 1 = source file name (entry, returnRequestTask, db.logic, ...)
Level 2 = function name (เฉพาะ db.logic, data.logic, flows ที่มีหลาย function)
Level 3 = scenario (it/test)
beforeEach(() => jest.clearAllMocks()) วางนอก describe ทุกตัว
ครอบทุก Level 1 ในไฟล์เดียวกันได้อัตโนมัติ
Jest display file path อยู่แล้วตอน run — ไม่จำเป็นต้องมี top-level describe ครอบ action name อีกชั้น
PASS src/.../return-request-lv40/__tests__/returnRequestLv40.test.ts
entry
✓ should delegate returnRequest to task
returnRequestTask
✓ should stop when getAllActivityFlow fails
db.logic
getApplicationDAF
✓ queries with correct where clause
Describe Structure ต่อ Source File
| Source File | Level 1 | Level 2 | Level 3 |
|---|---|---|---|
entry.ts | entry | — | scenario |
{method}.task.ts | {methodName}Task | — | scenario |
flows.ts | flows | function name | scenario |
db.logic.ts | db.logic | function name | scenario |
data.logic.ts | data.logic | function name | scenario |
entry และ task ไม่มี Level 2 เพราะ 1 file = 1 class/function อยู่แล้ว
ทุก file ที่มีหลาย functions → filename เป็น Level 1, function name เป็น Level 2 เสมอ
// __tests__/returnRequestLv40.test.ts
import { applicationFixtures, activityFixtures } from './fixtures';
// ครอบทุก describe ในไฟล์
beforeEach(() => jest.clearAllMocks());
// ─── entry.ts ────────────────────────────────────────────────────
// Level 1: file name | mock: returnRequest.task, checkStatus.task
describe('entry', () => {
it('should delegate returnRequest to returnRequestTask with correct input')
it('should delegate checkStatus to checkStatusTask with correct input')
it('should return failure when task returns failure')
})
// ─── returnRequest.task.ts ───────────────────────────────────────
// Level 1: file name | mock: flows, db.logic, data.logic (ถ้า task เรียกตรง)
describe('returnRequestTask', () => {
it('should call getAllActivityFlow before managePackSizeFlow')
it('should stop and return fail when getAllActivityFlow fails')
it('should stop and return fail when managePackSizeFlow fails')
it('should execute writes inside $transaction')
it('should return TransactionFailure when $transaction throws')
it('should return transformed output on success')
})
// ─── checkStatus.task.ts ─────────────────────────────────────────
// Level 1: file name | mock: flows, db.logic, data.logic (ถ้า task เรียกตรง)
describe('checkStatusTask', () => {
it('should call getDocumentStatusDAF with correct id')
it('should return fail when document not found')
it('should return transformed output on success')
})
// ─── flows.ts ────────────────────────────────────────────────────
// Level 1: filename | mock: db.logic, data.logic
describe('flows', () => {
describe('getAllActivityFlow', () => { // Level 2: function name
it('should call findActivityDAF then findManyProcessActivityDAF in order') // Level 3
it('should return fail when findActivityDAF fails')
it('should not call findManyProcessActivityDAF when findActivityDAF fails')
it('should return mapped result on success')
})
describe('managePackSizeFlow', () => { // Level 2: function name
it('should return fail when getPackSizeDAF fails') // Level 3
it('should return calculated pack size on success')
})
describe('manageAttachmentFlow', () => { // Level 2: function name
it('should process attachments in correct order') // Level 3
it('should return fail when attachment operation fails')
})
})
// ─── db.logic.ts ─────────────────────────────────────────────────
// Level 1: file name | mock: prismaMock (จาก dbclient.mock.ts)
describe('db.logic', () => {
describe('getApplicationDAF', () => { // Level 2: function name
it('queries with correct where clause') // Level 3
it('returns right with data when found')
it('returns left with ReturnRequestDAFFail when Prisma rejects')
})
describe('updateStatusDAF', () => { // Level 2: function name
it('updates status and UPDATED_AT correctly') // Level 3
it('returns left with ReturnRequestDAFFail when Prisma rejects')
})
describe('findActivityDAF', () => { // Level 2: function name
it('queries with correct applicationId') // Level 3
it('returns left with ReturnRequestDAFFail when Prisma rejects')
})
describe('getDocumentStatusDAF', () => { // Level 2: function name
it('selects required fields only') // Level 3
it('returns left with ReturnRequestDAFFail when Prisma rejects')
})
describe('findManyProcessActivityDAF', () => { // Level 2: function name
it('filters by processId correctly') // Level 3
it('returns left with ReturnRequestDAFFail when Prisma rejects')
})
})
// ─── data.logic.ts ───────────────────────────────────────────────
// Level 1: file name | mock: ไม่มี — pure functions
describe('data.logic', () => {
describe('transformReturnRequestToOutput', () => { // Level 2: function name
it('maps all fields correctly') // Level 3
it('handles null optional fields')
})
describe('validateReturnRequestInput', () => { // Level 2: function name
it('passes with valid input') // Level 3
it('fails when id is missing')
it('fails when reason is missing')
})
describe('transformDocumentStatusToOutput', () => { // Level 2: function name
it('maps status and date correctly') // Level 3
})
})
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 | ไม่ mock | pure functions ทดสอบตรงได้เลย |
*data.logic mock เฉพาะเมื่อ task เรียกใช้โดยตรง — mock ตาม direct dependency จริงของ task นั้น
Mock Implementation Examples
Entry — Mock Task Files
import { returnRequestTask } from '../returnRequest.task';
import { checkStatusTask } from '../checkStatus.task';
import { ReturnRequestLv40V1Entry } from '../entry';
jest.mock('../returnRequest.task');
jest.mock('../checkStatus.task');
const mockReturnRequestTask = returnRequestTask as jest.MockedFunction<typeof returnRequestTask>;
describe('entry', () => {
// describe-level — ทุก it() ใช้ entry instance เดิม
const mockClient = {} as PrismaClient;
const mockTelemetry = { executeFunctionWithDbSpan: jest.fn() };
const mockContext = { requestId: 'test-123' } as UnifiedHttpContext;
let entry: ReturnRequestLv40V1Entry;
beforeEach(() => {
entry = new ReturnRequestLv40V1Entry(mockClient, mockTelemetry);
});
it('should delegate returnRequest to returnRequestTask with correct input', async () => {
// Arrange — inline: props ใช้ it() เดียว, expectedOutput ใช้ it() เดียว
const props = { id: 'app-001', reason: 'test reason' };
const expectedOutput = right({ id: 'app-001', status: 'RETURNED' });
mockReturnRequestTask.mockResolvedValue(expectedOutput);
// Act
const result = await entry.returnRequest(mockContext, props);
// Assert
expect(result).toBe(expectedOutput);
expect(mockReturnRequestTask).toHaveBeenCalledWith({
context: mockContext,
telemetryService: mockTelemetry,
client: mockClient,
props,
});
});
it('should return failure when task returns failure', async () => {
// Arrange — inline: failure ใช้ it() เดียว
mockReturnRequestTask.mockResolvedValue(left(new BaseFailure('task failed')));
// Act
const result = await entry.returnRequest(mockContext, { id: 'app-001', reason: 'x' });
// Assert
expect(result.isLeft()).toBe(true);
})
})
Task — Mock Flows + db.logic + data.logic
mock ตาม direct dependency จริงของ task — ถ้า task เรียก data.logic โดยตรงต้อง mock ด้วย
import { getAllActivityFlow, managePackSizeFlow } from '../flows';
import { updateStatusDAF } from '../db.logic';
import { transformReturnRequestToOutput } from '../data.logic';
import { returnRequestTask } from '../returnRequest.task';
jest.mock('../flows');
jest.mock('../db.logic');
jest.mock('../data.logic'); // ← mock เพราะ task เรียก transformReturnRequestToOutput โดยตรง
const mockGetAllActivityFlow = getAllActivityFlow as jest.MockedFunction<typeof getAllActivityFlow>;
const mockManagePackSizeFlow = managePackSizeFlow as jest.MockedFunction<typeof managePackSizeFlow>;
const mockUpdateStatusDAF = updateStatusDAF as jest.MockedFunction<typeof updateStatusDAF>;
describe('returnRequestTask', () => {
// describe-level — ทุก it() ใช้ mockClient เดิม
const mockClient = { $transaction: jest.fn() } as unknown as PrismaClient;
it('should stop and return fail when getAllActivityFlow fails', async () => {
// Arrange — inline: failure ใช้ it() เดียว
mockGetAllActivityFlow.mockResolvedValue(left(new ActivityNotFoundFailure()));
// Act
const result = await returnRequestTask({
context: mockContext, telemetryService: mockTelemetry,
client: mockClient, props: { id: 'app-001', reason: 'test' },
});
// Assert
expect(result.isLeft()).toBe(true);
expect(mockManagePackSizeFlow).not.toHaveBeenCalled();
expect(mockClient.$transaction).not.toHaveBeenCalled();
});
it('should execute writes inside $transaction on success', async () => {
// Arrange — Fixture file ผ่าน describe-level ไม่ได้เพราะแต่ละ it() setup ต่างกัน → inline
mockGetAllActivityFlow.mockResolvedValue(right(activityFixtures.lv40Activity));
mockManagePackSizeFlow.mockResolvedValue(right({ size: 10 }));
(mockClient.$transaction as jest.Mock).mockImplementation(async (fn) => fn(mockClient));
mockUpdateStatusDAF.mockResolvedValue(right(applicationFixtures.pending));
// Act
await returnRequestTask({
context: mockContext, telemetryService: mockTelemetry,
client: mockClient, props: { id: 'app-001', reason: 'test' },
});
// Assert
expect(mockClient.$transaction).toHaveBeenCalledTimes(1);
expect(mockUpdateStatusDAF).toHaveBeenCalled();
});
it('should return TransactionFailure when $transaction throws', async () => {
// Arrange — inline
mockGetAllActivityFlow.mockResolvedValue(right(activityFixtures.lv40Activity));
mockManagePackSizeFlow.mockResolvedValue(right({ size: 10 }));
(mockClient.$transaction as jest.Mock).mockRejectedValue(new Error('DB error'));
// Act
const result = await returnRequestTask({
context: mockContext, telemetryService: mockTelemetry,
client: mockClient, props: { id: 'app-001', reason: 'test' },
});
// Assert
expect(result.isLeft()).toBe(true);
expect(result.value).toBeInstanceOf(TransactionFailure);
});
})
Flow — Mock db.logic + data.logic
import { findActivityDAF, findManyProcessActivityDAF } from '../db.logic';
import { mapToAllActivityItem } from '../data.logic';
import { getAllActivityFlow } from '../flows';
jest.mock('../db.logic');
jest.mock('../data.logic');
const mockFindActivityDAF = findActivityDAF as jest.MockedFunction<typeof findActivityDAF>;
const mockFindManyDAF = findManyProcessActivityDAF as jest.MockedFunction<typeof findManyProcessActivityDAF>;
const mockMapToAll = mapToAllActivityItem as jest.MockedFunction<typeof mapToAllActivityItem>;
describe('flows', () => {
describe('getAllActivityFlow', () => {
// describe-level — Fixture file, หลาย it() ใช้ base เดิม
const mockActivity = activityFixtures.lv40Activity;
const mockProcessList = activityFixtures.processActivities;
it('should call findActivityDAF then findManyProcessActivityDAF in order', async () => {
// Arrange — describe-level
mockFindActivityDAF.mockResolvedValue(right(mockActivity));
mockFindManyDAF.mockResolvedValue(right(mockProcessList));
mockMapToAll.mockReturnValue({ allAct: mockProcessList, currentId: mockActivity.ID });
// Act
await getAllActivityFlow({ client: {} as PrismaClient, context: mockContext, applicationId: 'app-001' });
// Assert
const findOrder = mockFindActivityDAF.mock.invocationCallOrder[0];
const findManyOrder = mockFindManyDAF.mock.invocationCallOrder[0];
expect(findOrder).toBeLessThan(findManyOrder);
});
it('should not call findManyProcessActivityDAF when findActivityDAF fails', async () => {
// Arrange — override describe-level: ต้องการ fail แทน success
mockFindActivityDAF.mockResolvedValue(left(new ActivityNotFoundFailure()));
// Act
const result = await getAllActivityFlow({
client: {} as PrismaClient, context: mockContext, applicationId: 'non-existent',
});
// Assert
expect(result.isLeft()).toBe(true);
expect(mockFindManyDAF).not.toHaveBeenCalled();
});
})
})
db.logic — prismaMock จาก dbclient.mock.ts
dbclient.ts และ dbclient.mock.ts อยู่ที่ src/ ใช้ร่วมกันทุก action — ไม่ต้องสร้าง mock ใหม่ต่อ test file
// src/dbclient.mock.ts
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaClient } from './dbclient';
jest.mock('./dbclient', () => ({ __esModule: true }));
beforeEach(() => { mockReset(prismaMock); });
export const prismaMock = mockDeep<PrismaClient>();
// __tests__/returnRequestLv40.test.ts
import { prismaMock } from '../../../../../dbclient.mock';
import { getApplicationDAF, updateStatusDAF } from '../db.logic';
import { ReturnRequestDAFFail } from '../internal.type';
describe('db.logic', () => {
// prismaMock reset อัตโนมัติผ่าน beforeEach ใน dbclient.mock.ts
describe('getApplicationDAF', () => {
// describe-level — Fixture file, หลาย it() ใช้ base เดิม
const mockApp = applicationFixtures.pending;
it('queries with correct where clause', async () => {
prismaMock.aPPLICATION.findUnique.mockResolvedValue(mockApp); // describe-level
await getApplicationDAF(prismaMock, mockApp.ID);
expect(prismaMock.aPPLICATION.findUnique).toHaveBeenCalledWith({
where: { ID: mockApp.ID },
select: { ID: true, STATUS: true, CURRENT_ACTOR_ID: true },
});
});
it('returns right with data when found', async () => {
prismaMock.aPPLICATION.findUnique.mockResolvedValue(mockApp); // describe-level
const result = await getApplicationDAF(prismaMock, mockApp.ID);
expect(result.isRight()).toBe(true);
});
it('returns left with ReturnRequestDAFFail when Prisma rejects', async () => {
// inline — ไม่ต้องการ mockApp
prismaMock.aPPLICATION.findUnique.mockRejectedValue(new Error('DB error'));
const result = await getApplicationDAF(prismaMock, 'app-001');
expect(result.isLeft()).toBe(true);
expect(result.value).toBeInstanceOf(ReturnRequestDAFFail);
});
});
describe('updateStatusDAF', () => {
// describe-level — Fixture file + derived input
const mockApp = applicationFixtures.pending;
const updateInput = { id: mockApp.ID, status: 'RETURNED' };
it('updates status and UPDATED_AT correctly', async () => {
prismaMock.aPPLICATION.update.mockResolvedValue(mockApp); // describe-level
await updateStatusDAF(prismaMock, updateInput);
expect(prismaMock.aPPLICATION.update).toHaveBeenCalledWith({
where: { ID: updateInput.id },
data: { STATUS: updateInput.status, UPDATED_AT: expect.any(Date) },
});
});
it('returns left with ReturnRequestDAFFail when Prisma rejects', async () => {
// inline — ไม่ต้องการ mockApp
prismaMock.aPPLICATION.update.mockRejectedValue(new Error('DB constraint violation'));
const result = await updateStatusDAF(prismaMock, updateInput);
expect(result.isLeft()).toBe(true);
expect(result.value).toBeInstanceOf(ReturnRequestDAFFail);
});
});
})
data.logic — No Mock (Pure Functions)
import { transformReturnRequestToOutput, validateReturnRequestInput } from '../data.logic';
describe('data.logic', () => {
describe('transformReturnRequestToOutput', () => {
// describe-level — Fixture file, ทุก it() ใช้ base เดิม
const raw = applicationFixtures.pending;
it('maps all fields correctly', () => {
const result = transformReturnRequestToOutput(raw); // describe-level
expect(result).toEqual({ id: raw.ID, status: raw.STATUS, updatedAt: raw.UPDATED_AT });
});
it('handles null optional fields', () => {
// spread override จาก describe-level
const result = transformReturnRequestToOutput({ ...raw, CURRENT_ACTOR_ID: null });
expect(result.currentActorId).toBeNull();
});
});
describe('validateReturnRequestInput', () => {
// ไม่มี describe-level — แต่ละ it() ใช้ input ต่างกัน → inline ทั้งหมด
it('passes with valid input', () => {
const result = validateReturnRequestInput({ id: 'app-001', reason: 'test' });
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('fails when id is missing', () => {
const result = validateReturnRequestInput({ id: '', reason: 'test' });
expect(result.isValid).toBe(false);
expect(result.errors).toContain('id is required');
});
it('fails when reason is missing', () => {
const result = validateReturnRequestInput({ id: 'app-001', reason: '' });
expect(result.isValid).toBe(false);
expect(result.errors).toContain('reason is required');
});
});
})
Jest Configuration
Data Layer ใช้ jest config เดียว — ไม่แยก unit/integration/system:
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
displayName: 'store-prisma',
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('@feedos-frgm-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
เหตุผลที่ exclude ทั้งสองกลุ่ม:
| Pattern | เหตุผล |
|---|---|
**/*.type.ts | Type definitions มีโครงสร้างซ้ำกันตามธรรมชาติ (id: string, context: UnifiedHttpContext) |
**/__tests__/**/*.test.ts | Test code ต้องการ explicit มากกว่า DRY — Arrange ใน it() ทุกตัวมี mock setup ซ้ำกันตามธรรมชาติ และการ abstract เพื่อลด duplication ทำให้อ่านยากขึ้นโดยไม่จำเป็น |
Run Commands
# Run all tests
jest
# Run tests สำหรับ action เดียว
jest --testPathPattern=returnRequestLv40
# Run พร้อม coverage
jest --coverage
# Watch mode (dev)
jest --watch
Best Practices
Max 3 Levels — ทำไมถึงไม่เกิน
1. Readability — อ่านยากเมื่อ nested ลึก
// ❌ 4 levels — ต้อง scroll กลับขึ้นไปดู context ตลอด
describe('db.logic', () => {
describe('getApplicationDAF', () => {
describe('when application exists', () => {
describe('with valid status', () => {
it('returns application') // ← อยู่ลึกมาก บอกไม่ได้ทันทีว่าทดสอบอะไร
})
})
})
})
// ✅ 3 levels — อ่านแล้วเข้าใจทันที
describe('db.logic', () => {
describe('getApplicationDAF', () => {
it('returns application when exists with valid status')
})
})
2. Test Output — ยาวจนอ่านไม่ออก
// ❌ 4 levels — Jest output ยาวเกินไป
db.logic > getApplicationDAF > when application exists > with valid status > returns application
// ✅ 3 levels — กระชับ อ่านได้ทันที
db.logic > getApplicationDAF > returns application when exists with valid status
3. Design Signal — nested ลึก = test scope ใหญ่เกินไป
ถ้าต้องการ describe ลึกกว่า 3 levels มักหมายความว่า scenario ซับซ้อนเกินไป ควรแตก it() แทน หรือ function ที่ทดสอบใหญ่เกินไป ควรแยก function ออกมา
// ❌ nested ลึกเพราะพยายาม describe ทุก condition
describe('returnRequestTask', () => {
describe('zone 1', () => {
describe('when getAllActivityFlow succeeds', () => {
describe('and managePackSizeFlow succeeds', () => {
it('proceeds to transaction') // Level 4
})
})
})
})
// ✅ แตก scenario ออกมาเป็น it() แทน — ข้อมูลครบเหมือนกัน
describe('returnRequestTask', () => {
it('should proceed to transaction when all flows succeed')
it('should stop when getAllActivityFlow fails')
it('should stop when managePackSizeFlow fails')
})
Max 3 levels บังคับให้ it() มีชื่อ descriptive แทนที่จะ nest describe แบ่ง condition และทำให้ function มีขนาดเล็กและ focused — ถ้า test ต้องการ nest ลึกมักเป็น signal ว่า function ใหญ่เกินไป
Test Naming
it() บอก scenario เท่านั้น — ไม่ต้องระบุ function name ซ้ำถ้า describe Level 2 บอกไปแล้ว
// ✅ describe บอก function แล้ว — it() บอกแค่ scenario
describe('db.logic', () => {
describe('getApplicationDAF', () => {
it('returns right with data when found')
it('queries with correct where clause')
it('returns left with ReturnRequestDAFFail when Prisma rejects')
})
describe('updateStatusDAF', () => {
it('updates status and UPDATED_AT correctly')
it('returns left with ReturnRequestDAFFail when Prisma rejects')
})
})
// ✅ Level 1 ที่ไม่มี Level 2 (entry, task) — it() ต้องบอก scenario ชัดเจน
describe('entry', () => {
it('should delegate returnRequest to returnRequestTask with correct input')
it('should return failure when task returns failure')
})
describe('returnRequestTask', () => {
it('should stop and return fail when getAllActivityFlow fails')
it('should execute writes inside $transaction')
})
// ❌ ซ้ำกับ describe Level 2
describe('getApplicationDAF', () => {
it('getApplicationDAF — returns null when not found') // ← พูดซ้ำ
})
// ❌ generic เกินไป
it('should work')
it('test error case')
AAA Pattern
it('updates status and UPDATED_AT correctly', async () => {
// Arrange — setup data and mocks
prismaMock.aPPLICATION.update.mockResolvedValue(applicationFixtures.pending);
// Act — call the function
await updateStatusDAF(prismaMock, { id: 'app-001', status: 'RETURNED' });
// Assert — verify outcome
expect(prismaMock.aPPLICATION.update).toHaveBeenCalledWith({
where: { ID: 'app-001' },
data: { STATUS: 'RETURNED', UPDATED_AT: expect.any(Date) },
});
});
Mock Guidelines
| ทำ | ไม่ทำ | |
|---|---|---|
| External deps | mock เสมอ (Prisma, Telemetry) | |
| Own code (flows, db.logic) | mock เมื่อ test component ระดับบน | ไม่ mock เมื่อ test component นั้นโดยตรง |
| Pure functions (data.logic) | ทดสอบตรง ไม่ต้อง mock | |
| Mock reset | jest.clearAllMocks() ใน beforeEach top-level | reset ใน afterEach ซ้ำซ้อน |
หมายเหตุ: Data Layer จงใจ mock own code เช่น flows และ db.logic เพราะแต่ละ describe section ทดสอบ 1 component scope ถ้าไม่ mock จะกลายเป็น test หลาย component พร้อมกัน ซึ่งเป็น integration test ไม่ใช่ unit test
Summary
Test Strategy Matrix
| Data Layer | HTTP Layer | |
|---|---|---|
| Unit Test | ✅ mock Prisma, mock own code | ✅ |
| Integration Test | ❌ ไม่จำเป็น | ✅ Real DB |
| System / E2E | ❌ ไม่จำเป็น | ✅ Docker, full workflow |
Key Benefits
- Build เร็ว — 1 test file per action ลด Jest worker spawn overhead
- Zero-decision — comment บอก mock level ต่อ section ทุกคนทำเหมือนกัน
- Maintainable — test file align กับ source file structure ของ action
- SonarQube —
sonar.cpd.exclusions=**/*.type.ts,**/__tests__/**/*.test.tsป้องกัน false positive
Implementation Priority
- ทดสอบ
data.logicก่อน — pure functions เร็วและง่ายที่สุด - ทดสอบ
db.logic— mock PrismaClient ตรวจ query arguments - ทดสอบ
flows— mock db.logic + data.logic ตรวจ workflow - ทดสอบ
task— mock flows + db.logic ตรวจ orchestration + transaction - ทดสอบ
entry— mock tasks ตรวจ delegation เท่านั้น