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 LevelLayerทำที่ไหนเหตุผล
Unit TestData Layershare-dataทดสอบ logic แต่ละ component
Integration Testไม่ทำที่ layer ใดเลยUnit Test ที่แต่ละ layer ครอบคลุม component interaction ภายใน layer แล้ว
API TestHTTP Layerapp-{name}1 endpoint ตาม business scenario, Real DB
Feature TestHTTP Layerapp-{name}หลาย Action ใน 1 BC ตาม scenario, Real DB
System TestHTTP Layerapp-{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
Overrideit() นั้นต้องการ state ต่างจาก describe-levelmockResolvedValue(left(new OutOfStockFailure()))
Spread overrideต้องการ base จาก describe-level แต่เปลี่ยนบาง field{ ...orderFixtures.pending, STATUS: 'CANCELLED' }
Inlineit() เดียว ไม่ต้องการ data / error pathmockRejectedValue(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

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ไม่มี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.tsType definitions มีโครงสร้างซ้ำกันตามธรรมชาติ (extends BaseFailure, constructor pattern เหมือนกัน)
**/__tests__/**/*.test.tsTest 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-basedpending, 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

FixtureSeed Data
คืออะไรTypeScript object ใน codeSQL / script inject เข้า DB จริง
ใช้เมื่อUnit TestAPI 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 แยก

Supawut Thomas

Supawut Thomas

Software Developer

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