Data Layer Test Strategy

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 TestData Layer ✅ทดสอบ logic แต่ละ component
Integration TestHTTP Layerมี full context จริง (auth, service layer)
System / E2EHTTP Layertest full workflow ใกล้ production

Testing Principles

  1. Fast Feedback: Unit tests ต้องเร็ว — mock ทุก external dependency
  2. 1 File per Action: ลด Jest worker spawn overhead
  3. Isolated Tests: แต่ละ test เป็นอิสระ ไม่ depend กัน
  4. 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
Overrideit() นั้นต้องการ state ต่างจาก describe-levelmockFindActivityDAF.mockResolvedValue(left(...))
Spread overrideต้องการ base จาก describe-level แต่เปลี่ยนบาง field{ ...raw, CURRENT_ACTOR_ID: null }
Inlineit() เดียว ไม่ต้องการ data / error pathmockRejectedValue(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 FileLevel 1Level 2Level 3
entry.tsentryscenario
{method}.task.ts{methodName}Taskscenario
flows.tsflowsfunction namescenario
db.logic.tsdb.logicfunction namescenario
data.logic.tsdata.logicfunction namescenario

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

ComponentMock อะไรเหตุผล
entrytask filesทดสอบ delegation เท่านั้น
{method}.taskflows + db.logic + data.logic*ทดสอบ orchestration ไม่ใช่ implementation
flowsdb.logic + data.logicทดสอบ workflow logic
db.logicprismaMock จาก dbclient.mock.tsทดสอบ query arguments
data.logicไม่ mockpure 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.tsType definitions มีโครงสร้างซ้ำกันตามธรรมชาติ (id: string, context: UnifiedHttpContext)
**/__tests__/**/*.test.tsTest 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 depsmock เสมอ (Prisma, Telemetry)
Own code (flows, db.logic)mock เมื่อ test component ระดับบนไม่ mock เมื่อ test component นั้นโดยตรง
Pure functions (data.logic)ทดสอบตรง ไม่ต้อง mock
Mock resetjest.clearAllMocks() ใน beforeEach top-levelreset ใน 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 LayerHTTP Layer
Unit Test✅ mock Prisma, mock own code
Integration Test❌ ไม่จำเป็น✅ Real DB
System / E2E❌ ไม่จำเป็น✅ Docker, full workflow

Key Benefits

  1. Build เร็ว — 1 test file per action ลด Jest worker spawn overhead
  2. Zero-decision — comment บอก mock level ต่อ section ทุกคนทำเหมือนกัน
  3. Maintainable — test file align กับ source file structure ของ action
  4. SonarQubesonar.cpd.exclusions=**/*.type.ts,**/__tests__/**/*.test.ts ป้องกัน false positive

Implementation Priority

  1. ทดสอบ data.logic ก่อน — pure functions เร็วและง่ายที่สุด
  2. ทดสอบ db.logic — mock PrismaClient ตรวจ query arguments
  3. ทดสอบ flows — mock db.logic + data.logic ตรวจ workflow
  4. ทดสอบ task — mock flows + db.logic ตรวจ orchestration + transaction
  5. ทดสอบ entry — mock tasks ตรวจ delegation เท่านั้น
Supawut Thomas

Supawut Thomas

Software Developer

มีประสบการณ์พัฒนา Software ระดับ Enterprise มากกว่า 10 ปี ผ่านงานจริงหลากหลายโปรเจกต์องค์กร — เชื่อว่าความรู้ที่ดีที่สุดคือความรู้ที่มาจากประสบการณ์จริง และอยากแบ่งปันสิ่งเหล่านั้นให้เพื่อน Developer ทุกคนได้นำไปพัฒนาตัวเองได้ดีขึ้นในทุกๆ วัน