Testing QA DevOps

Software Testing — ทดสอบยังไงให้มั่นใจก่อน Deploy 🧪Software Testing — How to Be Sure Before You Deploy 🧪

By Anirach Mingkhwan DevOps & Vibe Coding 2026
Software Testing — ทดสอบยังไงให้มั่นใจก่อน Deploy
ในบทความนี้
  1. Testing Pyramid — ทดสอบกี่ระดับ?
  2. ⚡ Unit Testing — ทดสอบทีละชิ้น
  3. 🔗 Integration Testing — ทดสอบร่วมกัน
  4. 🖥️ E2E Testing — ทดสอบเหมือน User จริง
  5. 📊 Code Coverage — ทดสอบครอบคลุมแค่ไหน?
  6. 🔄 TDD — Test-Driven Development
  7. 🚀 Testing ใน CI/CD Pipeline
  8. 🛠️ Testing Tools — เลือกใช้ตัวไหน?
  9. 📋 Testing Best Practices
  10. สรุป
In this post
  1. Testing Pyramid — How Many Levels of Testing?
  2. ⚡ Unit Testing — One Piece at a Time
  3. 🔗 Integration Testing — The Pieces Working Together
  4. 🖥️ E2E Testing — Testing Like a Real User
  5. 📊 Code Coverage — How Much Is Actually Covered?
  6. 🔄 TDD — Test-Driven Development
  7. 🚀 Testing in the CI/CD Pipeline
  8. 🛠️ Testing Tools — Which One Should You Pick?
  9. 📋 Testing Best Practices
  10. Summary

จากโพสต์ที่แล้วเราเรียนรู้ 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:

🖥️ E2E Tests
น้อยที่สุด — ช้า แต่ทดสอบทั้งระบบ
🔗 Integration Tests
ปานกลาง — ทดสอบหลาย components ร่วมกัน
⚡ Unit Tests
มากที่สุด — เร็ว ทดสอบทีละ function
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) ก่อน:

🔴 Red
เขียน test → fail
→
🟢 Green
เขียน code ให้ pass
→
🔵 Refactor
ปรับ code ให้สวย
→
🔁 Repeat
ทำซ้ำ
// 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 / IntegrationE2E
JavaScriptJest, Vitest, MochaPlaywright, Cypress
Pythonpytest, unittestPlaywright, Selenium
JavaJUnit, TestNGSelenium, Playwright
Gotesting (built-in)Playwright
API TestingSupertest, httpxPostman, k6
Load Testing—k6, Locust, Artillery

📋 Testing Best Practices

เขียน test ที่ดีไม่ใช่แค่ "มี test" — ต้องอ่านง่าย, maintain ง่าย, รันเร็ว, และเชื่อถือได้ (ไม่ flaky) Best practices ที่ควรทำตาม:

  1. Test Pyramid: Unit เยอะ → Integration กลาง → E2E น้อย
  2. AAA Pattern: Arrange (เตรียม) → Act (ทำ) → Assert (ตรวจ)
  3. Test ชื่อดี: "returns 404 when user not found" ไม่ใช่ "test1"
  4. Independent: แต่ละ test ไม่พึ่งพากัน — รันสลับลำดับได้
  5. Fast: Unit tests ต้องเร็ว (< 1 sec ทั้งหมด)
  6. Mock external: อย่าเรียก API/DB จริงใน unit test
  7. CI/CD: ทุก push ต้องรัน tests อัตโนมัติ
  8. 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 อัตโนมัติ 🧪🐕

In the previous post we learned about DevSecOps — security embedded in every step.

But before anything can go to production... how do we know the code is actually correct? You fix a bug here — does something break over there? 🤔

The answer is Software Testing — writing code that tests your code, the way a scientist repeats an experiment before drawing any conclusion 🧪🐕


Testing Pyramid — How Many Levels of Testing?

The Testing Pyramid is the idea that tells you which kinds of test to write and in what proportion. The bottom layer (Unit Tests) should be the largest, because those tests are fast and easy. The higher you climb (Integration → E2E), the fewer you write, because they are slow, expensive and hard to maintain. Write too many E2E tests and the pipeline crawls; write only unit tests and you never find out whether the components really work together — you have to find the balance:

🖥️ E2E Tests
Fewest — slow, but tests the whole system
🔗 Integration Tests
In between — several components working together
⚡ Unit Tests
Most — fast, one function at a time
LevelWhat it testsSpeedHow many
UnitOne function/method at a time⚡ Very fast (ms)The most (70-80%)
IntegrationSeveral components together🔄 Moderate (s)Moderate (15-20%)
E2EThe whole system, simulating a user🐢 Slow (min)The fewest (5-10%)
💡 The golden rule: many unit tests (fast, cheap), few E2E tests (slow, expensive) — like a pyramid, wide at the base and narrow at the top

⚡ Unit Testing — One Piece at a Time

A unit test exercises one function or one class in isolation — no database, no API, no external services. The goal is to confirm that "given this input, you must get that output". When a unit test fails you know immediately which function holds the bug, with no hunting through the whole system. They are extremely fast (milliseconds), so you can run them on every save.

JavaScript (Jest)

Jest is the most popular testing framework for JavaScript/TypeScript — test runner, assertions and mocking are all built in. The example below shows what good unit tests look like, covering the happy path, the edge cases and 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);
  });
});
# Run the 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 is a powerful testing framework for Python — its standout features are fixtures (reusable setup/teardown) and parametrize (many cases from a single decorator). Tests come out far shorter than the unittest equivalent:

# 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)
# Run the 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 — Standing In for Dependencies

Mocking means building a stand-in for a real dependency — a database call, an HTTP request, an email service. It keeps unit tests free of external services (faster, more stable) and lets you test the error cases too (pretending the DB is down, for instance):

// userService.test.js
const { getUserName } = require('./userService');
const db = require('./database');

// Mock database module
jest.mock('./database');

test('returns user name from database', async () => {
  // pretend the DB returns this
  db.findUser.mockResolvedValue({ id: 1, name: 'Somchai' });

  const name = await getUserName(1);
  expect(name).toBe('Somchai');
  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');
});
💡 Why mock at all? A unit test has to be fast and isolated — no real DB, no real API, no real filesystem

🔗 Integration Testing — The Pieces Working Together

An integration test checks that several components really work together — API endpoint → business logic → database → response. Unlike a unit test it uses the real things (database, Redis, external APIs), which is how it catches the bugs unit tests cannot: a wrong 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();        // use a real test database
  await db.seed();            // load the test data
});

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: 'Somying', email: '[email protected]' })
      .expect(201);

    expect(res.body.name).toBe('Somying');
    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 for Integration Tests

The problem with integration tests is that they need a database, Redis and the rest actually running. Docker Compose solves this perfectly — every service you need comes up with a single command, it runs in CI too, and everyone gets the identical 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
# Run the integration tests in Docker
docker compose -f docker-compose.test.yml run --rm app npm test

🖥️ E2E Testing — Testing Like a Real User

An E2E (End-to-End) test imitates a real user — it opens a browser, clicks buttons, fills forms and checks that the screen shows the right thing. It exercises everything from frontend → API → database → response, and it catches bugs that unit and integration tests miss (a button left disabled, a broken responsive layout, a JS error that only appears in a real browser).

⚠️ But E2E tests are slow (minutes, not seconds) and flaky (they depend on timing, the network, the browser version). Write them only for the critical paths — login, checkout, registration — not for every feature.

Playwright (recommended!)

Playwright is the best E2E framework available today — faster than Cypress, multi-browser (Chrome, Firefox, Safari), with genuinely smart auto-waiting, automatic screenshots and video when a test fails, and a codegen tool that records what you do in the browser and writes the test for you:

// 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');

    // fill in the form
    await page.fill('[data-testid="email"]', '[email protected]');
    await page.fill('[data-testid="password"]', 'P@ssw0rd');
    await page.click('[data-testid="login-btn"]');

    // check that it redirects to the 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"]');

    // an error message must appear
    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');

    // pick a product
    await page.click('[data-testid="product-1"]');
    await page.click('[data-testid="add-to-cart"]');

    // go to checkout
    await page.click('[data-testid="cart-icon"]');
    await page.click('[data-testid="checkout-btn"]');

    // fill in the details
    await page.fill('#address', '123 Bangkok');
    await page.click('[data-testid="confirm-order"]');

    // check that it succeeded
    await expect(page.locator('.order-success')).toBeVisible();
  });
});
# Install Playwright
npm init playwright@latest

# Run the tests
npx playwright test

# Run with the browser visible
npx playwright test --headed

# View the HTML report
npx playwright show-report

Cypress (the other popular choice)

Cypress used to be the most popular E2E framework of all — its strengths are time-travel debugging (a snapshot at every step) and an excellent developer experience. But it supports only Chromium-based browsers and is slower than Playwright. Playwright is overtaking it now, though Cypress remains a good option:

// 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 — How Much Is Actually Covered?

Code coverage measures what percentage of your code the tests actually "pass through" — there are four kinds: line coverage (how many lines were reached), branch coverage (was every if/else taken?), function coverage (was every function called?), and statement coverage. The usual recommendation is 80% or above, but 100% does not mean bug-free — coverage only tells you the code was reached, not that the test checked the output correctly:

# 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
# → open htmlcov/index.html for the visual report
Coverage %RatingAdvice
80-100%ExcellentA good target — you don't need 100%
60-80%AcceptableRoom to improve — focus on the critical paths
< 60%LowAdd tests! You are risking production bugs
🐕 Careful! 100% coverage ≠ 100% bug-free — coverage measures whether the code was "run", not whether it is "correct". You still have to write good assertions!

🔄 TDD — Test-Driven Development

TDD (Test-Driven Development) is writing code "upside down" — write the test first (it fails, because the code does not exist yet) → write code until the test passes → refactor until it is clean, then repeat. The cycle is called Red-Green-Refactor. The payoff is that every line has test coverage from the very beginning, and the design comes out better because you are forced to think from the caller's side (the test) first:

🔴 Red
write a test → it fails
→
🟢 Green
write code until it passes
→
🔵 Refactor
tidy the code up
→
🔁 Repeat
go round again
// Step 1: 🔴 Red — write the test first (it will 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 — write the simplest code that passes
function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// ✅ All tests pass!

// Step 3: 🔵 Refactor — improve it (if it needs it)
// → the code is already fine, on to the next feature!

🚀 Testing in the CI/CD Pipeline

Once the tests are written they have to run automatically on every PR — not lean on a developer's "diligence"! GitHub Actions runs the whole suite automatically on every push and every PR. If a test fails → the PR cannot be merged, and the bug never reaches production:

GitHub Actions

Here is a pipeline that runs all three levels — unit tests first (the fastest), then integration tests (which need a PostgreSQL container), then E2E tests (which need a browser). Fastest to slowest, so that it fails 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 — Which One Should You Pick?

Every language has several testing tools — the table below sums up the popular choices by test type, so you can pick quickly. Start with the main framework (Jest/pytest), then add E2E and coverage later:

LanguageUnit / IntegrationE2E
JavaScriptJest, Vitest, MochaPlaywright, Cypress
Pythonpytest, unittestPlaywright, Selenium
JavaJUnit, TestNGSelenium, Playwright
Gotesting (built-in)Playwright
API TestingSupertest, httpxPostman, k6
Load Testing—k6, Locust, Artillery

📋 Testing Best Practices

Writing good tests is more than merely "having tests" — they have to be easy to read, easy to maintain, fast to run and trustworthy (not flaky). The practices worth following:

  1. Test Pyramid: many Unit → some Integration → few E2E
  2. AAA Pattern: Arrange (set up) → Act (do it) → Assert (check it)
  3. Good test names: "returns 404 when user not found", not "test1"
  4. Independent: no test depends on another — they can run in any order
  5. Fast: unit tests must be quick (< 1 sec for the whole suite)
  6. Mock external: never call a real API or DB from a unit test
  7. CI/CD: every push must run the tests automatically
  8. Coverage > 80%: but aim at the critical paths, not the number
💡 Remember: "A test that does not run in CI is not a test" — every test belongs in the pipeline!

Summary

Software testing is not "extra work" — it is the highest-return investment in software development. Write a test today and save yourself hours of debugging later. Teams with good tests deploy faster and with more confidence:

  • Testing Pyramid = Unit (many, fast) → Integration (some) → E2E (few, slow)
  • Unit Test = one function at a time, with mocks standing in for dependencies
  • Integration Test = several components together (API + DB)
  • E2E Test = a simulated real user (Playwright / Cypress)
  • TDD = Red → Green → Refactor — the test comes before the code
  • Code Coverage = aim > 80%, but for quality, not for the number
  • CI/CD = every push runs the tests automatically 🧪🐕
บทความจากซีรีส์ DevOps & Vibe Coding 2026From the DevOps & Vibe Coding 2026 series
← Previous
DevSecOps — Security พื้นฐานที่ DevOps ต้องรู้
Next →
Docker Compose — จัดการหลาย Containers ด้วยไฟล์เดียว