Test Strategy: ทำที่ Layer ไหน ทำแค่ไหนพอ

Scale-Ready Architecture · Part 3 of 9

Test Strategy ใน Modular Monolith — ทำที่ Layer ไหน ทำแค่ไหนพอ

ก่อนที่แต่ละ Part จะลงลึกเรื่อง testing ของ layer ตัวเอง บทความนี้วาง mental model ที่ทั้ง series ใช้ร่วมกัน


Scale-Ready Architecture Series — Part นี้ต้องการความเข้าใจเรื่อง Dependency Graph จาก Part 1 และ Abstraction Layer จาก Part 2 ถ้ายังไม่ได้อ่าน แนะนำให้เริ่มจากที่นั่นก่อน


ปัญหาที่ทุกทีม Dev เคยเจอ

เขียน test มาสักพัก แล้วมีคำถามแว่บขึ้นมาแบบนี้ไหม?

“ฟังก์ชันนี้ควร test ที่ layer ไหน — Data Layer หรือ Service Layer?” “ต้อง mock PrismaClient ที่ทุก layer เลยไหม หรือแค่ที่ Data Layer พอ?” “Integration Test กับ Unit Test ต่างกันยังไงในแง่ปฏิบัติ — ใช้ Docker ทุก test เลยได้ไหม?” “Fixture กับ Seed Data ต่างกันยังไง ใช้ผิดจะเกิดอะไรขึ้น?”

ถ้าเคย — แสดงว่าคุณยังขาด test boundary ที่ชัดเจน

ปัญหาที่เห็นบ่อยในทีมที่ไม่มี test strategy คือ test ซ้อนทับกันข้าม layer, Unit Test ที่แอบ spin DB ทำให้ build ช้าโดยไม่รู้ตัว หรือ coverage สูงแต่ตาม bug ไม่เจอเพราะ test ผิดที่

บทความนี้จะตอบทุกคำถามข้างต้น ด้วย mental model เดียวที่ใช้ได้ตลอดทั้ง series


ทำไมแต่ละ Layer ถึง Test ต่างกัน

Clean Architecture แบ่ง concern ของแต่ละ layer ให้ชัดเจน หลักการเดียวกันนี้กำหนดว่าแต่ละ layer “รู้” อะไร และ “ไม่รู้” อะไร — และนั่นแหละคือสิ่งที่ test ควรตรวจสอบ ไม่ใช่ตรวจสอบในสิ่งที่ layer นั้นไม่ได้รับผิดชอบ

เหตุผลที่ต้อง test ต่างกันมาจาก สิ่งที่แต่ละ layer รู้ ไม่เท่ากัน:

share-data รู้แค่ว่า PrismaClient คืออะไร และ SQL Query ควรออกมาแบบไหน — ไม่รู้เรื่อง HTTP, Business Rule หรือ Authentication

share-service รู้ Business Rule และลำดับ Use Case — ไม่รู้เรื่อง Database Query หรือ HTTP Transport

app-{name} รู้การ wire ทุกอย่างเข้าด้วยกัน — รู้เรื่อง Framework, Middleware, Auth, Connection Pool

เมื่อ concern ต่างกัน สิ่งที่ test ต้องตรวจก็ต่างกันตามไปด้วย การเอา E2E Test ที่ใช้ DB จริงไปรัน ณ share-data layer ไม่ได้ให้ความมั่นใจมากกว่า Unit Test แต่เสียเวลา setup Docker เพิ่มขึ้นหลายเท่า

หลักคิดง่ายๆ คือ:

Test ตรวจสิ่งที่ layer นั้น "รู้" — ไม่ตรวจสิ่งที่ layer อื่นรับผิดชอบ

Test Naming Convention

Test NameLevelScopeApproach
Unit TestUnit1 function / componentmock boundary ของ layer
Integration TestIntegrationหลาย function ทำงานร่วมกันใน layer เดียวmock infrastructure เช่น mock DB
API TestE2E1 endpoint ตาม business scenarioไม่ mock, Real DB
Feature TestE2Eหลาย Action ใน 1 BC ตาม scenarioไม่ mock, Real DB
System TestE2Efull flow ข้าม BC จนจบไม่ mock, Real DB + Docker

หมายเหตุ: “Test Name” ในตารางนี้คือชื่อที่ series นี้ใช้เพื่อสื่อสารและทำความเข้าใจร่วมกัน ชื่อบางตัวอาจตรงหรือซ้ำกับ Test Level หรือ Test Type ตาม theory ทั่วไป เช่น Unit Test และ Integration Test — ผู้อ่านที่คุ้นกับ testing theory จะสังเกตว่า API Test, Feature Test และ System Test ในที่นี้นับเป็น E2E Level ทั้งหมด ต่างกันแค่ scope ของ scenario


Test Matrix

Test NameLevelshare-coreshare-datashare-clientshare-serviceapp-{name}
Unit TestUnit
Integration TestIntegration
API TestE2E
Feature TestE2E
System TestE2E

share-core ไม่มี test เพราะเป็น pure type definitions และ interfaces — TypeScript จับ contract errors ที่ compile time อยู่แล้ว ไม่มี runtime logic ที่ต้อง test

share-data, share-client, share-service ทำแค่ Unit Test — mock boundary ของตัวเอง ไม่ใช้ของจริง

Integration Test ไม่ทำที่ layer ใดเลย — Unit Test ที่แต่ละ layer ครอบคลุม component interaction ภายใน layer แล้ว การเพิ่ม Integration Test จะได้ confidence เพิ่มน้อยมากเมื่อเทียบกับ cost ของการ setup

app-{name} ทำ API Test, Feature Test และ System Test เพราะเป็น layer เดียวที่มี full context (auth, service layer, real DB connection)


ใครทำ และเมื่อไหร่ที่ไม่ต้องทำ

Test Nameใครทำจำเป็น?ถ้าไม่ทำ มีอะไร cover
Unit TestDeveloper✅ ต้องทำ
Integration Test❌ ไม่ทำUnit Test แต่ละ layer ครอบคลุมแล้ว
API TestDeveloper✅ ต้องทำ
Feature TestQA / Business Analyst✅ ต้องทำ
System TestQA / DevOps⚠️ ทำเมื่อแตก MicroserviceContract Test cover แทน

API Test — Developer เพราะ scope เล็ก ใกล้ code และให้ feedback เร็ว

Feature Test — QA / Business Analyst เพราะ test ตาม business scenario ที่ต้องการความเข้าใจ business rule ครบ ไม่ใช่แค่ technical correctness

System Test — QA / DevOps เพราะต้องการ Docker และ infrastructure setup ที่ซับซ้อน feedback ช้ากว่า และมักต้องยุ่งกับ External Service — เมื่อแตกเป็น Microservice ให้ใช้ Contract Test แทน เพราะ test contract ระหว่าง service ได้โดยไม่ต้อง spin ทุก service พร้อมกัน


Test Scope Boundary — แต่ละ Layer Test อะไร ไม่ Test อะไร

share-core

Test อะไรไม่ Test อะไร
ทุกอย่าง (pure types)

share-core ไม่มีโค้ดที่ run ได้ — เป็นแค่ contract ที่ TypeScript enforce ให้


share-data

Test อะไร:

  • Entry — delegate ไปยัง task ถูกต้องไหม
  • Task — orchestrate flows และ DAF ถูกลำดับไหม, จัดการ transaction boundary ถูกไหม
  • flows.ts — workflow logic และ error handling ของแต่ละ flow
  • db.logic.ts (DAF) — query arguments ถูกต้องไหม, จัดการ error จาก Prisma ถูกไหม
  • data.logic.ts — transform และ validate ถูกต้องไหม (pure functions)

ไม่ Test อะไร:

  • ❌ ไม่ test ว่า SQL Query ทำงานกับ DB จริงได้ไหม
  • ❌ ไม่ test HTTP layer หรือ Business Rule
  • ❌ ไม่ test schema migration หรือ DB connection

ทำไม: TypeScript + Unit Test ครอบคลุมสิ่งที่ Data Layer รับผิดชอบแล้ว Wiring errors จับที่ compile time, Logic errors จับที่ unit test — Integration Test กับ Real DB ให้ confidence เพิ่มน้อยมากเมื่อเทียบกับ cost ของการ setup Docker


share-client

Test อะไร:

  • HTTP Request สร้างถูกต้องไหม (method, path, headers, body)
  • Response mapping ถูกต้องไหม
  • Error handling เมื่อ HTTP status ต่างๆ ทำงานถูกไหม

ไม่ Test อะไร:

  • ❌ ไม่ test ว่า server จริงตอบกลับถูกไหม
  • ❌ ไม่ test Business Rule ของ provider
  • ❌ ไม่ test network latency หรือ retry policy

ทำไม: share-client รู้แค่ contract ของ provider (จาก share-core) และ HTTP transport — ไม่ต้องรู้เรื่อง Business Logic ของ provider นั้น


share-service

Test อะไร:

  • business.logic.ts — Constraint, Calculation, Transform, Business Rule ทำงานถูกไหม
  • routeSteps.logic.ts — step functions แต่ละตัวทำงานถูกต้องไหม (logic ของแต่ละ step ไม่ใช่ลำดับ)
  • endpoint.config.ts (Orchestrator) — เรียก step จาก routeSteps.logic.ts ถูกลำดับไหม และ wire deps ถูกต้องไหม

ไม่ Test อะไร:

  • ❌ ไม่ test DB query โดยตรง
  • ❌ ไม่ test HTTP transport
  • ❌ ไม่ test Framework middleware

ทำไม:

  • Constraint, Calculation, Transform — pure function เสมอ test ตรงๆ ได้เลยโดยไม่ต้อง mock ใด
  • Business Rule ที่มี side-effect เช่น assertUsernameUnique — mock เฉพาะ Repository ที่เป็น boundary ของ layer ไม่ต้อง spin DB จริง

app-{name}

Test อะไร:

  • API Test — 1 endpoint ตาม business scenario, Real DB
    • เช่น POST /orders ส่ง request ถูกต้องแล้วได้ response และ DB state ถูกต้อง
  • Feature Test — หลาย Action ใน 1 BC ตาม business scenario, Real DB
    • เช่น สร้าง user → assign role → ตรวจสิทธิ์ ทั้งหมดอยู่ใน user-mng BC เดียวกัน
  • System Test — full flow ข้าม BC จนจบ, Real DB + Docker
    • เช่น place order → reserve inventory → charge payment ข้าม 3 BC
  • Seed Data setup ก่อน run ทั้ง API Test, Feature Test และ System Test

ไม่ Test อะไร:

  • ❌ ไม่ test unit-level logic ซ้ำ (มี layer อื่น test แล้ว)

ทำไม: app-{name} เป็นจุดเดียวที่มี full context — API Test, Feature Test และ System Test ที่นี่ให้ความมั่นใจสูงสุดว่าทุกอย่าง wire กันถูกต้อง, BC ทำงานร่วมกันได้จริง และ Business Rule ยังถูกต้องเมื่อ flow ข้าม BC


Mock Strategy Overview

หลักการของ series นี้คือ mock เฉพาะ boundary ของ layer ตัวเอง — ไม่ mock สิ่งที่อยู่ภายใน layer และไม่ข้ามไป mock layer อื่น

LayerMock ConceptTool ใน Series นี้Boundary คือทำไม
share-datadb-clientPrismaClientDatabaseData Layer รู้แค่ query — ไม่รู้เรื่อง HTTP หรือ Business Rule
share-clienthttp-clientaxios (หรือ msw)HTTP transportClient Layer รู้แค่ HTTP contract — ไม่รู้เรื่อง Business Logic ของ provider
share-serviceRepository interface + ServiceClient interfaceData Layer + Remote ServiceService Layer รู้แค่ contract — ไม่รู้ implementation ของ Data หรือ HTTP
app-{name}ไม่ mockComposition Root ใช้ของจริงทั้งหมด — mock ที่นี่ = test mock ไม่ใช่ test ระบบ

ทำไมต้อง Mock เฉพาะ Boundary?

ถ้า mock มากกว่า boundary ของตัวเอง จะ test ได้แค่ว่า mock ทำงานถูกต้อง — ไม่ได้ test logic จริงของ layer นั้นเลย

// ❌ share-service mock ทุกอย่าง รวม business.logic.ts ด้วย
// → test ได้แค่ว่า mock ถูก call — ไม่รู้ว่า Business Rule ถูกหรือเปล่า
jest.mock('../logic/business.logic')

// ✅ share-service mock เฉพาะ boundary (Repository + ServiceClient)
// → Business Rule ทำงานจริง test ตรวจค่าจริง
const mockRepo = { findById: jest.fn(), save: jest.fn() }
const mockInventoryClient = { checkStock: jest.fn() }

ตัวอย่าง Mock แต่ละ Layer

share-data — mock PrismaClient

// dbclient.mock.ts — shared mock สำหรับทุก action ใน share-data
import { PrismaClient } from '@prisma/client'
import { mockDeep, DeepMockProxy } from 'jest-mock-extended'

export type MockPrismaClient = DeepMockProxy<PrismaClient>

jest.mock('./dbclient', () => ({
  __esModule: true,
  default: mockDeep<PrismaClient>(),
}))

// ใช้ใน test file
import prisma from '../dbclient'
import { MockPrismaClient } from '../dbclient.mock'

const prismaMock = prisma as unknown as MockPrismaClient

describe('db.logic', () => {
  describe('getApplicationDAF', () => {
    it('queries with correct where clause', async () => {
      // mock PrismaClient response
      prismaMock.aPPLICATION.findUnique.mockResolvedValue(applicationFixtures.pending)

      await getApplicationDAF(prismaMock, 'app-001')

      // ตรวจ query arguments — ไม่ใช่ตรวจ data จาก DB จริง
      expect(prismaMock.aPPLICATION.findUnique).toHaveBeenCalledWith({
        where: { ID: 'app-001' },
      })
    })
  })
})

share-client — mock axios

import axios from 'axios'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>

describe('InventoryRemoteClient', () => {
  describe('checkStock', () => {
    it('sends GET request with correct path and params', async () => {
      // mock axios response — ไม่ต้องมี server จริง
      mockedAxios.get.mockResolvedValue({
        data: { available: true, quantity: 100 },
        status: 200,
      })

      const client = new InventoryRemoteClient('http://inventory-service')
      const result = await client.checkStock({ productId: 'prod-001', qty: 5 })

      // ตรวจว่า HTTP request ถูกสร้างถูกต้อง
      expect(mockedAxios.get).toHaveBeenCalledWith(
        'http://inventory-service/inventory/stock',
        { params: { productId: 'prod-001', qty: 5 } }
      )
      expect(result.available).toBe(true)
    })

    it('throws ServiceError when HTTP 503', async () => {
      mockedAxios.get.mockRejectedValue({ response: { status: 503 } })

      const client = new InventoryRemoteClient('http://inventory-service')
      await expect(client.checkStock({ productId: 'prod-001', qty: 5 }))
        .rejects.toThrow(ServiceUnavailableError)
    })
  })
})

share-service — mock Repository + ServiceClient

// mock เฉพาะ interface boundary — Business Rule ทำงานจริง
const mockOrderRepo: jest.Mocked<OrderRepository> = {
  findById: jest.fn(),
  save: jest.fn(),
  findByCustomerId: jest.fn(),
}

const mockInventoryClient: jest.Mocked<Pick<InventoryServiceClient, 'checkStock' | 'reserve'>> = {
  checkStock: jest.fn(),
  reserve: jest.fn(),
}

describe('placeOrderUseCase', () => {
  it('throws DomainError when quantity is 0', async () => {
    // ✅ Business Rule (assertPositiveQuantity) ทำงานจริง — ไม่ถูก mock
    await expect(
      placeOrderUseCase({ mockOrderRepo, mockInventoryClient }, {
        productId: 'prod-001',
        quantity: 0,  // ← trigger Business Constraint
        customerId: 'cust-001',
      })
    ).rejects.toThrow(DomainError)

    // ✅ ตรวจว่าไม่ได้เรียก inventory ถ้า validation ล้มเหลว
    expect(mockInventoryClient.checkStock).not.toHaveBeenCalled()
  })

  it('calls repo.save after successful inventory reservation', async () => {
    mockInventoryClient.checkStock.mockResolvedValue({ available: true, quantity: 100 })
    mockInventoryClient.reserve.mockResolvedValue({ reserved: true })
    mockOrderRepo.save.mockResolvedValue(undefined)

    await placeOrderUseCase({ mockOrderRepo, mockInventoryClient }, {
      productId: 'prod-001',
      quantity: 5,
      customerId: 'cust-001',
    })

    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
  })
})

app-{name} — ไม่ mock ใดเลย

// E2E Test ที่ app-{name} — ใช้ของจริงทั้งหมด (Feature Test ตัวอย่าง)
describe('POST /orders (Integration)', () => {
  let app: Express
  let prisma: PrismaClient

  beforeAll(async () => {
    // ใช้ Real DB (Docker ใน CI)
    prisma = new PrismaClient({ datasourceUrl: process.env.TEST_DATABASE_URL })
    app = createApp({ prisma })  // ← Composition Root จริง ไม่ mock

    // Seed Data เข้า DB จริงก่อน test
    await seedTestDatabase(prisma)
  })

  afterAll(async () => {
    await prisma.$disconnect()
  })

  it('creates order and returns 201 with order id', async () => {
    const response = await request(app)
      .post('/orders')
      .set('Authorization', `Bearer ${testUserToken}`)
      .send({ productId: 'prod-seed-001', quantity: 3 })

    expect(response.status).toBe(201)
    expect(response.body.orderId).toBeDefined()

    // ตรวจ DB state จริง
    const savedOrder = await prisma.order.findUnique({
      where: { id: response.body.orderId },
    })
    expect(savedOrder?.status).toBe('PENDING')
  })
})

Fixture vs Seed Data vs Test Data

คำสามคำนี้หมายถึงต่างกัน และเลือกผิดทำให้ test ช้าหรือ setup ซับซ้อนโดยไม่จำเป็น

นิยาม

คำคืออะไรอยู่ที่ไหนใช้เมื่อไหร่
Test Datasuperset — ข้อมูลทดสอบทุกประเภทขึ้นกับ typeทั้งหมด
Fixturestatic test data ที่เก็บใน code__tests__/fixtures/Unit Test เท่านั้น
Seed Dataข้อมูลที่ inject เข้า DB จริงก่อน run testseeds/ หรือ scriptIntegration Test / E2E เท่านั้น

Fixture — Static Test Data ใน Code

Fixture คือ TypeScript object ที่เก็บ test data ไว้ใช้ซ้ำใน Unit Test ไม่ต้องการ DB จริง เหมาะกับ Unit Test ที่ mock external dependency อยู่แล้ว

// __tests__/fixtures/application.fixture.ts
export const applicationFixtures = {

  // ─── base states — ชื่อบอก status ของ entity ────────────────
  pending: {
    ID: 'test-app-001',
    STATUS: 'PENDING',
    CURRENT_ACTOR_ID: 'user-001',
    CREATED_AT: new Date('2025-01-01'),
  },
  approved: {
    ID: 'test-app-002',
    STATUS: 'APPROVED',
    CURRENT_ACTOR_ID: 'user-001',
    CREATED_AT: new Date('2025-01-01'),
  },
  completed: {
    ID: 'test-app-003',
    STATUS: 'COMPLETED',
    CURRENT_ACTOR_ID: null,
    CREATED_AT: new Date('2025-01-01'),
  },

  // ─── edge case — comment บอกว่าสร้างมาทำไม ─────────────────
  // ใช้ทดสอบ validation ที่ reject เมื่อไม่มี actor
  pendingWithNoActor: {
    ID: 'test-app-004',
    STATUS: 'PENDING',
    CURRENT_ACTOR_ID: null,
    CREATED_AT: new Date('2025-01-01'),
  },
}
// ✅ ใช้ Fixture ใน Unit Test
import { applicationFixtures } from './fixtures'

describe('db.logic', () => {
  describe('getApplicationDAF', () => {
    it('returns right with data when found', async () => {
      // mock PrismaClient ให้ return fixture แทน query DB จริง
      prismaMock.aPPLICATION.findUnique.mockResolvedValue(applicationFixtures.pending)
      const result = await getApplicationDAF(prismaMock, 'test-app-001')
      expect(result.isRight()).toBe(true)
    })
  })
})

Seed Data — Inject เข้า DB จริง

Seed Data คือสคริปต์ที่ insert ข้อมูลจริงเข้า Database ก่อน run E2E Test ต้องการ DB จริง และมักทำงานร่วมกับ Docker ใน CI

// seeds/test-orders.seed.ts — ใช้ที่ app-{name} ก่อน Integration Test
export const seedTestDatabase = async (prisma: PrismaClient): Promise<void> => {
  // Seed product
  await prisma.product.upsert({
    where: { id: 'prod-seed-001' },
    create: {
      id: 'prod-seed-001',
      name: 'Test Product A',
      price: 100,
      stockQuantity: 50,
    },
    update: {},
  })

  // Seed customer
  await prisma.customer.upsert({
    where: { id: 'cust-seed-001' },
    create: {
      id: 'cust-seed-001',
      name: 'Test Customer',
      email: 'test@example.com',
    },
    update: {},
  })
}

หลักการตัดสินใจ — ดูจาก Test Level ไม่ใช่ Layer

ถาม: ต้องการ Fixture หรือ Seed Data?

test ระดับไหน?
  ├── Unit Test      → Fixture เสมอ (ไม่มี DB)
  ├── API Test       → Seed Data เสมอ (ต้องการ DB จริง)
  ├── Feature Test   → Seed Data เสมอ (ต้องการ DB จริง)
  └── System Test    → Seed Data เสมอ (ต้องการ DB จริง)

ข้อผิดพลาดที่เห็นบ่อย: เอา Fixture ไปใช้ใน Integration Test โดยหวังว่า mock จะแทน DB ได้ — ผลคือ test ผ่านในเครื่อง dev แต่ fail ใน CI เพราะ schema ไม่ตรง

// ❌ ผิด — Unit Test ไม่ควรต้องการ DB จริง
describe('getApplicationDAF', () => {
  beforeAll(async () => {
    // การ connect DB ใน Unit Test ทำให้ test ช้าและ fragile
    await prisma.$connect()
    await prisma.application.create({ data: { ... } })
  })
})

// ✅ ถูก — Unit Test ใช้ Fixture + mock PrismaClient
describe('getApplicationDAF', () => {
  it('returns right with data when found', async () => {
    prismaMock.aPPLICATION.findUnique.mockResolvedValue(applicationFixtures.pending)
    const result = await getApplicationDAF(prismaMock, 'test-app-001')
    expect(result.isRight()).toBe(true)
  })
})

Tools & Setup

section นี้อธิบาย tool ที่ใช้ทั้ง series — แต่ละ Part จะไม่อธิบายซ้ำ

Jest + ts-jest

Jest เป็น test runner หลักของ series นี้ ใช้คู่กับ ts-jest เพื่อ run TypeScript โดยตรงโดยไม่ต้อง compile ก่อน

npm install --save-dev jest ts-jest @types/jest jest-mock-extended
// jest.config.ts — base config ที่แต่ละ package ใช้
import type { Config } from 'jest'

const config: Config = {
  displayName: 'share-data',        // ← ชื่อ package แต่ละตัว override ตรงนี้
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts'],
  setupFilesAfterEnv: ['<rootDir>/test-setup/jest.setup.ts'],
  clearMocks: true,    // ล้าง mock.calls หลังทุก test
  resetMocks: true,    // reset mock implementation หลังทุก test
  restoreMocks: true,  // restore jest.spyOn หลังทุก test
}

export default config

ทำไม clearMocks + resetMocks + restoreMocks พร้อมกัน?

แต่ละ option ล้างคนละชั้น:

Optionล้างอะไรไม่ล้างอะไร
clearMocks.mock.calls, .mock.instances, .mock.resultsimplementation ยังอยู่
resetMocksทุกอย่างของ clearMocks + ล้าง implementationjest.spyOn ยังไม่ restore
restoreMocksทุกอย่างของ resetMocks + restore jest.spyOn กลับเป็น original
// สมมติ setup นี้
const fn = jest.fn().mockReturnValue('hello')
fn()

// หลัง clearMocks
fn.mock.calls   // [] ← ล้างแล้ว
fn()            // 'hello' ← implementation ยังอยู่

// หลัง resetMocks
fn.mock.calls   // [] ← ล้างแล้ว
fn()            // undefined ← implementation หายไปด้วย

// สมมติ spyOn
jest.spyOn(console, 'log').mockImplementation(() => {})

// หลัง resetMocks — spy ยังอยู่ แค่ implementation หาย
console.log('hi')  // ไม่มี output แต่ยังเป็น mock อยู่

// หลัง restoreMocks — spy ถูก restore กลับเป็น original
console.log('hi')  // 'hi' ← กลับเป็น console.log จริง

ใช้แค่ clearMocks อย่างเดียวไม่พอ เพราะ implementation ยังติดอยู่ข้าม test ได้ถ้าลืม re-assign — ใช้ทั้งสามพร้อมกันเพื่อให้แต่ละ test เริ่มต้นด้วย mock ที่สะอาดสมบูรณ์

// ถ้าไม่ clear/reset mock — test ปนกัน
describe('task', () => {
  it('test A — mock returns success', async () => {
    mockFlow.mockResolvedValue(right({ id: '001' }))
    // ...
  })

  it('test B — ถ้าไม่ reset จะได้ mock ของ test A มาด้วย', async () => {
    // ❌ mockFlow ยังเป็น success จาก test A อยู่
    // test อาจผ่านโดยบังเอิญ ไม่ใช่เพราะ logic ถูก
  })
})

// ✅ clearMocks: true ใน jest.config.ts จัดการให้อัตโนมัติ
// ไม่ต้องเขียน beforeEach(() => jest.clearAllMocks()) ใน test file

Test Runner แยก Config ตาม Layer

// share-data/jest.config.ts
import type { Config } from 'jest'
const config: Config = {
  displayName: 'share-data',
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts'],
  // Unit Test เท่านั้น — ไม่มี integration/e2e config
}
export default config
// app-{name}/jest.config.ts — มี config แยกสำหรับ integration
import type { Config } from 'jest'
const config: Config = {
  displayName: 'app-main',
  projects: [
    {
      displayName: 'unit',
      preset: 'ts-jest',
      testEnvironment: 'node',
      testMatch: ['**/__tests__/unit/**/*.test.ts'],
    },
    {
      displayName: 'integration',
      preset: 'ts-jest',
      testEnvironment: 'node',
      testMatch: ['**/__tests__/integration/**/*.test.ts'],
      // Integration Test ต้องการ globalSetup สำหรับ DB
      globalSetup: '<rootDir>/test-setup/integration.setup.ts',
      globalTeardown: '<rootDir>/test-setup/integration.teardown.ts',
    },
  ],
}
export default config

SonarQube CPD — อธิบายครั้งเดียวที่นี่

SonarQube CPD (Copy-Paste Detection) จะ flag code ที่ซ้ำกัน ซึ่งเป็นปัญหาสำหรับไฟล์ 2 ประเภทใน series นี้:

# sonar-project.properties — ทุก package ใช้ pattern นี้
sonar.cpd.exclusions=**/*.type.ts,**/__tests__/**/*.test.ts
Patternเหตุผลที่ต้อง exclude
**/*.type.tsType definition มีโครงสร้างซ้ำกันตามธรรมชาติ เช่น id: string, createdAt: Date — ไม่ใช่ code smell
**/__tests__/**/*.test.tsTest code ต้องการ explicit มากกว่า DRY — Arrange ใน it() แต่ละตัวมี mock setup ซ้ำกันตามธรรมชาติ การ abstract เพื่อลด duplication ทำให้อ่านยากขึ้น

ทำไม test code ต้องการ explicit มากกว่า DRY?

// ❌ DRY เกินไป — ต้องไล่หา context กลับไปที่ helper
const setupSuccessFlow = () => {
  mockFindActivity.mockResolvedValue(right(activityFixtures.lv40Activity))
  mockFindMany.mockResolvedValue(right(activityFixtures.processActivities))
}

it('returns mapped result', async () => {
  setupSuccessFlow()  // ← ต้องไปอ่าน helper ก่อนเข้าใจ test นี้
  // ...
})

// ✅ Explicit — อ่าน it() เดียวรู้เลยว่า setup อะไร
it('returns mapped result', async () => {
  mockFindActivity.mockResolvedValue(right(activityFixtures.lv40Activity))
  mockFindMany.mockResolvedValue(right(activityFixtures.processActivities))
  // → ชัดเจน ไม่ต้องดูที่อื่น
  const result = await getAllActivityFlow({ ... })
  expect(result.isRight()).toBe(true)
})

Run Commands

Using npx

# Run ทุก test ใน package
npx nx test share-data

# Run test สำหรับ action เดียว (เร็วกว่ามาก)
npx nx test share-data --testPathPattern=returnRequestLv40

# Run พร้อม coverage report
npx nx test share-data --coverage

# Run ทุก package ที่ได้รับผลกระทบจากการแก้ไขล่าสุด
npx nx affected --target=test

# Watch mode สำหรับ dev
npx nx test share-data --watch

Using pnpm (แนะนำ)

# Run ทุก test ใน package
pnpm nx test share-data

# Run test สำหรับ action เดียว (เร็วกว่ามาก)
pnpm nx test share-data --testPathPattern=returnRequestLv40

# Run พร้อม coverage report
pnpm nx test share-data --coverage

# Run ทุก package ที่ได้รับผลกระทบจากการแก้ไขล่าสุด
pnpm nx affected --target=test

# Watch mode สำหรับ dev
pnpm nx test share-data --watch

Preview — แต่ละ Part จะลงลึกเรื่อง Testing อะไร

Series นี้จะกลับมาพูดเรื่อง testing ใน 5 Part ที่เหลือ โดยแต่ละ Part มี scope ที่ต่างกัน:

PartLayerTesting Focusสิ่งใหม่ที่จะได้เรียน
Part 4share-dataUnit Test — Data LayerFixture pattern, 1-file-per-action convention, mock Prisma
Part 5share-dataTest Strategy เชิงลึกMock level per component, describe structure, AAA pattern
Part 7share-serviceTesting Pure FunctionsBusiness Logic test โดยไม่ต้อง mock
Part 8share-serviceTesting UseCase (Orchestrator)mock Repository + ServiceClient, test step sequence
Part 9app-{name}Feature Test + System TestFeature Test, System Test, Seed Data strategy, Docker setup

สรุป

หลักการ testing ของ series นี้มีแค่ 3 ข้อ:

1. Test เฉพาะสิ่งที่ layer ตัวเองรับผิดชอบ แต่ละ layer รู้ concern ของตัวเอง — test ก็ตรวจแค่ concern นั้น ไม่ข้ามขอบ

2. Mock เฉพาะ boundary share-data mock Prisma, share-client mock axios, share-service mock Repository + Client — ไม่ mock internal logic ของ layer ตัวเอง

3. เลือก Fixture หรือ Seed Data จาก test level ไม่ใช่ layer Unit Test ใช้ Fixture เสมอ, Feature Test และ System Test ใช้ Seed Data เสมอ — ไม่มีข้อยกเว้น

เมื่อเข้าใจ 3 ข้อนี้แล้ว บทความถัดไปใน series จะเข้าใจง่ายขึ้นมาก เพราะทุก Part ใช้ mental model ชุดเดียวกัน


บทความถัดไปใน Series

Part 4 — share-data: Data Layer และ Action-Based Structure จะลงลึก implementation จริงของ Data Layer ตั้งแต่ Entry, Task, flows, DAF, ไปจนถึงการเขียน Unit Test สำหรับแต่ละ component


FAQ

Q: ทำไม share-data ถึงไม่ต้องทำ Integration Test หรือ E2E Test?

Integration Test (mock DB, test หลาย component ร่วมกัน) ไม่ทำเพราะ Unit Test ที่แยก component ครอบคลุมแล้ว การเพิ่ม Integration Test ได้ confidence เพิ่มน้อยมากเมื่อเทียบกับ cost ส่วน E2E Test (Real DB) ไม่ทำที่ share-data เพราะ share-data ไม่มี full context — ไม่รู้เรื่อง auth, middleware หรือ connection pool E2E Test ที่ให้ความมั่นใจจริงๆ ควรทำที่ app-{name} ซึ่งมี full context ครบ


Q: Fixture กับ Seed Data ต่างกันอย่างไร ใช้ผิดกันมีผลอะไร?

Fixture คือ TypeScript object ใน code ใช้แทน DB ใน Unit Test — เร็ว ไม่ต้องการ infrastructure ส่วน Seed Data คือสคริปต์ที่ insert ข้อมูลจริงเข้า DB ก่อน run E2E Test ถ้าใช้ผิดทาง เช่น เอา Seed Data ไปรัน Unit Test จะทำให้ build ช้าโดยไม่จำเป็น และ test fragile เพราะขึ้นกับ DB state ส่วน Fixture ที่เอาไปใช้แทน Seed Data ใน E2E Test จะทำให้ test ผ่านในเครื่อง dev แต่ fail ใน CI เพราะ schema หรือ constraint จริงไม่ตรง


Q: ทำไม app-{name} ถึงไม่ mock อะไรเลย?

app-{name} เป็น Composition Root ที่ wire ทุกอย่างเข้าด้วยกัน ถ้า mock deps ที่นี่ สิ่งที่ test จะกลายเป็น “mock ทำงานถูกต้อง” ไม่ใช่ “ระบบทำงานถูกต้อง” Feature Test และ System Test ที่ app-{name} มีคุณค่าสูงสุดก็เพราะใช้ของจริงทั้งหมด — ตรวจได้ว่า wiring ถูก, DB schema ตรง, auth flow ทำงาน, และ Business Rule ยังถูกต้องเมื่อ flow ข้าม BC


Q: Mock Strategy ใน series นี้เกี่ยวกับ Hexagonal Architecture อย่างไร?

Hexagonal Architecture กำหนดว่า core domain ไม่ควรรู้จัก infrastructure โดยตรง — ต้องผ่าน Port (interface) เสมอ Mock Strategy ของ series นี้ reflect หลักการเดียวกัน: share-service test Business Rule โดย mock เฉพาะ Repository interface และ ServiceClient interface (ซึ่งเป็น Port) ไม่ใช่ Prisma หรือ axios โดยตรง ทำให้ Business Rule test ได้โดยไม่ขึ้นกับ infrastructure ใด ๆ


Q: ข้อผิดพลาดที่พบบ่อยในการเขียน test ใน Layered Architecture คืออะไร?

ข้อผิดพลาดที่พบบ่อยที่สุดมี 3 อย่าง: (1) mock มากกว่า boundary เช่น share-service mock business.logic ด้วย ทำให้ test ไม่ได้ตรวจ Business Rule จริง (2) test ผิด layer เช่น เอา E2E Test ไปรันที่ share-data แทนที่จะรันที่ app-{name} ทำให้ setup ซับซ้อนโดยไม่ได้ confidence เพิ่ม (3) ปนกันระหว่าง Fixture และ Seed Data ทำให้ Unit Test ต้องการ DB หรือ E2E Test ไม่ test ด้วย schema จริง

Supawut Thomas

Supawut Thomas

Software Developer

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