จากโพสต์ที่แล้วเราเรียนรู้ DevSecOps — ฝัง security ในทุกขั้นตอน
แต่ก่อนจะ deploy ขึ้น production ได้... เรามั่นใจได้ยังไงว่า code ทำงานถูกต้อง? แก้ bug ตรงนี้ แล้วพังตรงโน้นมั้ย? 🤔
คำตอบคือ Software Testing — เขียน code ทดสอบ code อีกที เหมือนนักวิทยาศาสตร์ที่ต้องทดลองซ้ำก่อนจะสรุปผล 🧪🐕
Testing Pyramid — ทดสอบกี่ระดับ?
Testing Pyramid เป็นแนวคิดที่บอกว่าเราควรเขียน test กี่ประเภทและสัดส่วนเท่าไร ฐานล่างสุด (Unit Tests) ควรเขียนเยอะที่สุดเพราะเร็วและง่าย ยิ่งขึ้นไปด้านบน (Integration → E2E) ยิ่งเขียนน้อยลงเพราะช้า แพง และ maintain ยาก ถ้าเขียน E2E มากเกินไป pipeline จะช้ามาก ถ้าเขียน Unit อย่างเดียว จะไม่รู้ว่า components ทำงานร่วมกันได้จริงไหม — ต้องหา balance:
| Level | ทดสอบอะไร | ความเร็ว | จำนวน |
|---|---|---|---|
| Unit | แต่ละ function/method | ⚡ เร็วมาก (ms) | เยอะที่สุด (70-80%) |
| Integration | หลาย components ร่วมกัน | 🔄 ปานกลาง (s) | ปานกลาง (15-20%) |
| E2E | ทั้งระบบ จำลอง user | 🐢 ช้า (min) | น้อยที่สุด (5-10%) |
💡 กฎทอง: Unit tests เยอะๆ (เร็ว ถูก), E2E tests น้อยๆ (ช้า แพง) — เหมือนพีระมิด ฐานกว้าง ยอดแคบ
⚡ Unit Testing — ทดสอบทีละชิ้น
Unit test ทดสอบ function หรือ class ทีละตัวแบบ isolated — ไม่พึ่ง database, API หรือ external services เป้าหมายคือยืนยันว่า "ใส่ input นี้ ต้องได้ output นั้น" ถ้า unit test fail จะรู้ทันทีว่า bug อยู่ที่ function ไหน ไม่ต้องไล่หาทั้งระบบ เร็วมาก (มิลลิวินาที) รันได้ทุกครั้งที่ save
JavaScript (Jest)
Jest เป็น testing framework ยอดนิยมที่สุดสำหรับ JavaScript/TypeScript — มี test runner, assertions, mocking built-in ตัวอย่างด้านล่างแสดงการเขียน unit tests ที่ดี ครอบคลุม happy path, edge cases และ error handling:
// math.js
function add(a, b) {
return a + b;
}
function divide(a, b) {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
}
module.exports = { add, divide };
// math.test.js
const { add, divide } = require('./math');
describe('add', () => {
test('adds two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('adds negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
test('adds zero', () => {
expect(add(5, 0)).toBe(5);
});
});
describe('divide', () => {
test('divides two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
test('throws on divide by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
test('returns float result', () => {
expect(divide(7, 2)).toBeCloseTo(3.5);
});
});
# รัน tests
npx jest
# PASS ./math.test.js
# add
# ✓ adds two positive numbers (2ms)
# ✓ adds negative numbers (1ms)
# ✓ adds zero (1ms)
# divide
# ✓ divides two numbers (1ms)
# ✓ throws on divide by zero (1ms)
# ✓ returns float result (1ms)
#
# Tests: 6 passed, 6 total
Python (pytest)
pytest เป็น testing framework ของ Python ที่ทรงพลัง — จุดเด่นคือ fixtures (setup/teardown ที่ reuse ได้) และ parametrize (ทดสอบหลาย cases ด้วย decorator ตัวเดียว) เขียน test ได้สั้นกว่า unittest มาก:
# calculator.py
class Calculator:
def add(self, a, b):
return a + b
def divide(self, a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# test_calculator.py
import pytest
from calculator import Calculator
@pytest.fixture
def calc():
return Calculator()
def test_add(calc):
assert calc.add(2, 3) == 5
def test_add_negative(calc):
assert calc.add(-1, -2) == -3
def test_divide(calc):
assert calc.divide(10, 2) == 5.0
def test_divide_by_zero(calc):
with pytest.raises(ValueError, match="Cannot divide by zero"):
calc.divide(10, 0)
# รัน tests
pytest -v
# test_calculator.py::test_add PASSED
# test_calculator.py::test_add_negative PASSED
# test_calculator.py::test_divide PASSED
# test_calculator.py::test_divide_by_zero PASSED
#
# 4 passed in 0.02s
Mocking — จำลอง Dependencies
Mocking คือการสร้าง "ของจำลอง" แทน dependencies จริง — เช่น จำลอง database call, HTTP request หรือ email service ทำให้ unit test ไม่ต้องพึ่ง external services (เร็วขึ้น, stable ขึ้น) และสามารถทดสอบ error cases ได้ (เช่น จำลองว่า DB ล่ม):
// userService.test.js
const { getUserName } = require('./userService');
const db = require('./database');
// Mock database module
jest.mock('./database');
test('returns user name from database', async () => {
// จำลองว่า DB return ข้อมูลนี้
db.findUser.mockResolvedValue({ id: 1, name: 'สมชาย' });
const name = await getUserName(1);
expect(name).toBe('สมชาย');
expect(db.findUser).toHaveBeenCalledWith(1);
});
test('returns "Unknown" when user not found', async () => {
db.findUser.mockResolvedValue(null);
const name = await getUserName(999);
expect(name).toBe('Unknown');
});
💡 ทำไมต้อง Mock? Unit test ต้องเร็วและ isolated — ไม่ต้องเชื่อม DB จริง, API จริง, หรือ filesystem จริง
🔗 Integration Testing — ทดสอบร่วมกัน
Integration test ทดสอบว่าหลาย components ทำงานร่วมกันได้จริง — เช่น API endpoint → business logic → database → response ต่างจาก unit test ตรงที่ใช้ "ของจริง" (database, Redis, external APIs) ทำให้เจอ bugs ที่ unit test จับไม่ได้ เช่น SQL query ผิด, connection pooling issues, serialization problems:
// api.integration.test.js
const request = require('supertest');
const app = require('./app');
const db = require('./database');
beforeAll(async () => {
await db.connect(); // ใช้ test database จริง
await db.seed(); // ใส่ข้อมูลทดสอบ
});
afterAll(async () => {
await db.cleanup();
await db.disconnect();
});
describe('GET /api/users', () => {
test('returns list of users', async () => {
const res = await request(app)
.get('/api/users')
.expect(200);
expect(res.body).toHaveLength(3);
expect(res.body[0]).toHaveProperty('name');
expect(res.body[0]).toHaveProperty('email');
});
});
describe('POST /api/users', () => {
test('creates a new user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'สมหญิง', email: '[email protected]' })
.expect(201);
expect(res.body.name).toBe('สมหญิง');
expect(res.body.id).toBeDefined();
});
test('returns 400 for invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Test', email: 'invalid' })
.expect(400);
expect(res.body.error).toContain('email');
});
});
Docker Compose สำหรับ Integration Tests
ปัญหาของ integration tests คือต้องมี database, Redis, etc. รันอยู่ Docker Compose แก้ปัญหานี้สมบูรณ์แบบ — สร้าง services ที่ต้องการทั้งหมดด้วยคำสั่งเดียว รันใน CI ได้ด้วย ทุกคนได้ environment เดียวกัน:
# docker-compose.test.yml
version: '3.8'
services:
app:
build: .
environment:
- DATABASE_URL=postgres://test:test@db:5432/testdb
- NODE_ENV=test
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
- POSTGRES_DB=testdb
healthcheck:
test: pg_isready -U test
interval: 5s
retries: 5
# รัน integration tests ใน Docker
docker compose -f docker-compose.test.yml run --rm app npm test
🖥️ E2E Testing — ทดสอบเหมือน User จริง
E2E (End-to-End) test จำลอง user จริง — เปิด browser, กดปุ่ม, กรอกฟอร์ม, ตรวจว่าหน้าจอแสดงผลถูกต้อง ทดสอบทุกอย่างตั้งแต่ frontend → API → database → response เจอ bugs ที่ unit + integration test จับไม่ได้ (เช่น ปุ่ม disabled, responsive layout พัง, JS error บน browser จริง)
⚠️ แต่ E2E tests ช้า (นาที ไม่ใช่วินาที) และ flaky (ขึ้นอยู่กับ timing, network, browser version) ควรเขียนเฉพาะ critical paths เท่านั้น เช่น login, checkout, registration ไม่ใช่ทุก feature
Playwright (แนะนำ!)
Playwright เป็น E2E framework ที่ดีที่สุดในปัจจุบัน — เร็วกว่า Cypress, รองรับ multi-browser (Chrome, Firefox, Safari), มี auto-wait ที่ฉลาด, ถ่าย screenshot + video เมื่อ test fail อัตโนมัติ, และมี codegen tool ที่บันทึกการใช้งาน browser แล้วสร้าง test ให้:
// tests/login.spec.js
const { test, expect } = require('@playwright/test');
test.describe('Login Flow', () => {
test('successful login', async ({ page }) => {
await page.goto('http://localhost:3000/login');
// กรอก form
await page.fill('[data-testid="email"]', '[email protected]');
await page.fill('[data-testid="password"]', 'P@ssw0rd');
await page.click('[data-testid="login-btn"]');
// ตรวจว่า redirect ไป dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Welcome');
});
test('shows error on wrong password', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[data-testid="email"]', '[email protected]');
await page.fill('[data-testid="password"]', 'wrongpassword');
await page.click('[data-testid="login-btn"]');
// ต้องแสดง error message
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('Invalid');
});
test('checkout flow', async ({ page }) => {
await page.goto('http://localhost:3000');
// เลือกสินค้า
await page.click('[data-testid="product-1"]');
await page.click('[data-testid="add-to-cart"]');
// ไป checkout
await page.click('[data-testid="cart-icon"]');
await page.click('[data-testid="checkout-btn"]');
// กรอกข้อมูล
await page.fill('#address', '123 Bangkok');
await page.click('[data-testid="confirm-order"]');
// ตรวจว่าสำเร็จ
await expect(page.locator('.order-success')).toBeVisible();
});
});
# ติดตั้ง Playwright
npm init playwright@latest
# รัน tests
npx playwright test
# รัน + เปิด browser ให้ดู
npx playwright test --headed
# ดู HTML report
npx playwright show-report
Cypress (อีกตัวเลือกยอดนิยม)
Cypress เคยเป็น E2E framework ที่ได้รับความนิยมสูงสุด — จุดเด่นคือ time-travel debugging (ดู snapshot ทุก step) และ developer experience ที่ดี แต่รองรับแค่ Chromium-based browsers และช้ากว่า Playwright ปัจจุบัน Playwright กำลังแซง แต่ Cypress ยังเป็นตัวเลือกที่ดี:
// cypress/e2e/login.cy.js
describe('Login', () => {
it('logs in successfully', () => {
cy.visit('/login');
cy.get('[data-testid="email"]').type('[email protected]');
cy.get('[data-testid="password"]').type('P@ssw0rd');
cy.get('[data-testid="login-btn"]').click();
cy.url().should('include', '/dashboard');
cy.contains('h1', 'Welcome');
});
});
📊 Code Coverage — ทดสอบครอบคลุมแค่ไหน?
Code coverage วัดว่า test ของเรา "ผ่าน" code กี่เปอร์เซ็นต์ — มี 4 ประเภท: Line coverage (ผ่านกี่ line), Branch coverage (ผ่านทุก if/else ไหม), Function coverage (เรียกทุก function ไหม), Statement coverage ตัวเลขที่แนะนำคือ 80% ขึ้นไป แต่ 100% ไม่ได้แปลว่าไม่มี bugs — coverage บอกแค่ว่า code ถูก "ผ่าน" ไม่ได้บอกว่า test ตรวจ output ถูกต้อง:
# Jest + Coverage
npx jest --coverage
# Output:
# ----------|---------|----------|---------|---------|
# File | % Stmts | % Branch | % Funcs | % Lines |
# ----------|---------|----------|---------|---------|
# All files | 92.5 | 85.0 | 100 | 92.5 |
# math.js | 100 | 100 | 100 | 100 |
# user.js | 85.0 | 70.0 | 100 | 85.0 |
# ----------|---------|----------|---------|---------|
# pytest + Coverage
pytest --cov=src --cov-report=html
# → เปิด htmlcov/index.html ดู visual report
| Coverage % | ระดับ | คำแนะนำ |
|---|---|---|
| 80-100% | ดีมาก | เป้าหมายที่ดี แต่ไม่ต้อง 100% |
| 60-80% | พอใช้ | ปรับปรุงได้ เน้น critical paths |
| < 60% | ต่ำ | ต้องเพิ่ม tests! เสี่ยง production bugs |
🐕 ระวัง! 100% coverage ≠ 100% bug-free — coverage วัดว่า code ถูก "รัน" ไม่ได้วัดว่า "ถูกต้อง" ต้องเขียน assertion ที่ดีด้วย!
🔄 TDD — Test-Driven Development
TDD (Test-Driven Development) คือวิธีเขียน code แบบ "กลับหัว" — เขียน test ก่อน (จะ fail เพราะยังไม่มี code) → เขียน code ให้ test ผ่าน → refactor ให้สวย แล้ววนซ้ำ เรียกว่า Red-Green-Refactor ข้อดีคือ code ทุก line มี test ครอบคลุม 100% ตั้งแต่เริ่ม และ design ออกมาดีเพราะถูกบังคับให้คิดจาก "ผู้ใช้" (test) ก่อน:
// Step 1: 🔴 Red — เขียน test ก่อน (จะ fail)
test('validates email format', () => {
expect(isValidEmail('[email protected]')).toBe(true);
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('')).toBe(false);
expect(isValidEmail('[email protected]')).toBe(true);
});
// ❌ ReferenceError: isValidEmail is not defined
// Step 2: 🟢 Green — เขียน code ง่ายๆ ให้ pass
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// ✅ All tests pass!
// Step 3: 🔵 Refactor — ปรับปรุง (ถ้าจำเป็น)
// → code ดีอยู่แล้ว ไป feature ถัดไป!
🚀 Testing ใน CI/CD Pipeline
เขียน test แล้วต้องรันอัตโนมัติทุก PR — ไม่ใช่พึ่ง "ความขยัน" ของ developer! GitHub Actions ช่วยให้รัน test ทั้งหมดเมื่อมี push หรือ PR โดยอัตโนมัติ ถ้า test fail → PR merge ไม่ได้ ป้องกัน bug เข้า production:
GitHub Actions
ตัวอย่าง pipeline ที่รัน test ครบทั้ง 3 ระดับ — เริ่มจาก unit tests (เร็วที่สุด), integration tests (ต้องมี PostgreSQL container), E2E tests (ต้องมี browser) เรียงจากเร็วไปช้าเพื่อ fail fast:
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
integration-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-retries 5
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run test:integration
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
e2e-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
🛠️ Testing Tools — เลือกใช้ตัวไหน?
แต่ละภาษามี testing tools หลายตัว — ตารางด้านล่างสรุปตัวเลือกยอดนิยมแยกตามประเภท test ช่วยให้เลือกได้เร็ว เริ่มจาก framework หลักก่อน (Jest/pytest) แล้วเพิ่ม E2E + coverage ทีหลัง:
| ภาษา | Unit / Integration | E2E |
|---|---|---|
| JavaScript | Jest, Vitest, Mocha | Playwright, Cypress |
| Python | pytest, unittest | Playwright, Selenium |
| Java | JUnit, TestNG | Selenium, Playwright |
| Go | testing (built-in) | Playwright |
| API Testing | Supertest, httpx | Postman, k6 |
| Load Testing | — | k6, Locust, Artillery |
📋 Testing Best Practices
เขียน test ที่ดีไม่ใช่แค่ "มี test" — ต้องอ่านง่าย, maintain ง่าย, รันเร็ว, และเชื่อถือได้ (ไม่ flaky) Best practices ที่ควรทำตาม:
- Test Pyramid: Unit เยอะ → Integration กลาง → E2E น้อย
- AAA Pattern: Arrange (เตรียม) → Act (ทำ) → Assert (ตรวจ)
- Test ชื่อดี:
"returns 404 when user not found"ไม่ใช่"test1" - Independent: แต่ละ test ไม่พึ่งพากัน — รันสลับลำดับได้
- Fast: Unit tests ต้องเร็ว (< 1 sec ทั้งหมด)
- Mock external: อย่าเรียก API/DB จริงใน unit test
- CI/CD: ทุก push ต้องรัน tests อัตโนมัติ
- Coverage > 80%: แต่เน้น critical paths ไม่ใช่ตัวเลข
💡 จำไว้: "Tests ที่ไม่รันใน CI = ไม่มี tests" — ทุก test ต้องอยู่ใน pipeline!
สรุป
Software Testing ไม่ใช่ "งานเพิ่ม" — มันคือ investment ที่คุ้มค่ามากที่สุดใน software development เขียน test วันนี้ ประหยัดเวลา debug หลายชั่วโมงในอนาคต ทีมที่มี test ดีจะ deploy ได้เร็วกว่าและมั่นใจกว่า:
- Testing Pyramid = Unit (เยอะ เร็ว) → Integration (กลาง) → E2E (น้อย ช้า)
- Unit Test = ทดสอบ function เดียว, ใช้ mock แยก dependencies
- Integration Test = ทดสอบหลาย components ร่วมกัน (API + DB)
- E2E Test = จำลอง user ใช้งานจริง (Playwright / Cypress)
- TDD = Red → Green → Refactor — เขียน test ก่อน code
- Code Coverage = เป้า > 80% แต่เน้นคุณภาพ ไม่ใช่ตัวเลข
- CI/CD = ทุก push ต้องรัน tests อัตโนมัติ 🧪🐕