Coverage 87% không có nghĩa là code của bạn đúng. Mình từng có bug sống sót qua coverage 95% vì test đo sai thứ — test verify rằng function được gọi, không verify rằng function trả về kết quả đúng. Hai tuần sau bug lên production, khách hàng báo số tiền tính sai.

Test là investment, không phải ritual. Và như mọi investment, câu hỏi không phải “có nên test không?” mà là “test thứ gì, bao nhiêu, để được gì?”


Test pyramid trong thực tế trông khác hẳn trong sách

Test pyramid truyền thống nói: nhiều unit test ở đáy, ít integration test ở giữa, ít E2E test ở đỉnh. Lý do: unit test nhanh và rẻ, E2E chậm và đắt.

Vấn đề là pyramid này được thiết kế cho codebase ổn định với business logic rõ ràng và ít thay đổi. Trong startup hoặc product ở giai đoạn find product-market fit, business logic thay đổi mỗi sprint. Unit test bạn viết tuần trước trở thành rào cản tuần sau vì bạn phải update cả test lẫn implementation mỗi khi logic thay đổi.

Mình đã thấy team spend 40% thời gian sprint chỉ để update unit test sau mỗi lần refactor business logic — không phải vì test chạy fail, mà vì cấu trúc test bám quá chặt vào implementation detail thay vì behavior.

Câu hỏi thực tế không phải là “bao nhiêu phần trăm unit test vs integration test?” mà là: test này có bắt được bug quan trọng không, và chi phí maintain nó qua thời gian có xứng không?


Khi nào VIẾT test — 4 trường hợp có ROI rõ ràng

Pure function với nhiều edge case

Pure function (không side effect, output chỉ phụ thuộc input) là ứng viên hoàn hảo cho unit test. Business logic quan trọng nhất trong hệ thống thường là pure function: tính toán giá, parse format phức tạp, validation rule.

Ví dụ thực tế — function tính phí giao dịch:

// Hàm tính phí — nhiều edge case, logic thay đổi theo tier và loại giao dịch
function calculateTransactionFee(amount, userTier, transactionType) {
  // Free tier: phí cố định 2000 VND cho mọi giao dịch
  if (userTier === "free") {
    return { fee: 2000, rate: null };
  }

  // Pro tier: theo phần trăm với cap
  if (userTier === "pro") {
    const rate = transactionType === "international" ? 0.018 : 0.009;
    const fee = Math.round(amount * rate);
    // Cap: tối đa 50,000 VND cho domestic, 150,000 VND cho international
    const cap = transactionType === "international" ? 150_000 : 50_000;
    return { fee: Math.min(fee, cap), rate };
  }

  // Enterprise: không có phí
  return { fee: 0, rate: 0 };
}

Test cho function này trả về ROI cao vì:

  • Logic phức tạp với nhiều nhánh — dễ sai
  • Sẽ được maintain lâu dài và ít thay đổi structure
  • Bug ở đây gây mất tiền trực tiếp
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");

describe("calculateTransactionFee", () => {
  it("free tier luôn trả về phí cố định 2000 bất kể amount", () => {
    assert.deepEqual(calculateTransactionFee(10_000_000, "free", "domestic"), {
      fee: 2000,
      rate: null,
    });
    // Test edge case: amount nhỏ hơn phí
    assert.deepEqual(calculateTransactionFee(500, "free", "domestic"), {
      fee: 2000,
      rate: null,
    });
  });

  it("pro tier domestic không vượt cap 50,000", () => {
    // 10 triệu × 0.9% = 90,000 → capped tại 50,000
    assert.deepEqual(calculateTransactionFee(10_000_000, "pro", "domestic"), {
      fee: 50_000,
      rate: 0.009,
    });
  });

  it("pro tier domestic dưới cap tính đúng theo rate", () => {
    // 1 triệu × 0.9% = 9,000 → không bị cap
    assert.deepEqual(calculateTransactionFee(1_000_000, "pro", "domestic"), {
      fee: 9_000,
      rate: 0.009,
    });
  });

  it("pro tier international dùng rate 1.8% với cap 150,000", () => {
    // 5 triệu × 1.8% = 90,000 → không bị cap
    assert.deepEqual(
      calculateTransactionFee(5_000_000, "pro", "international"),
      { fee: 90_000, rate: 0.018 }
    );
  });
});

Bug fix — test reproduce bug trước, fix sau

Đây là quy tắc mình hiếm khi vi phạm: khi fix bug, viết test reproduce bug trước. Lý do: bạn cần chắc chắn test fail trước khi fix (để biết test đang test đúng thứ), rồi mới fix code, rồi confirm test pass.

Nếu bạn fix code rồi mới viết test, bạn có nguy cơ viết test pass với implementation hiện tại nhưng không thực sự bắt được regression sau này.

// Bug report: phí âm khi amount = 0 với pro tier domestic
// 0 × 0.009 = 0 → Math.min(0, 50000) = 0 → OK, nhưng
// sau đó có edge case: refund transaction với amount âm → phí âm

it("không tính phí âm cho refund transaction (amount < 0)", () => {
  // Test này fail trước khi fix — đây là điều bạn muốn
  const result = calculateTransactionFee(-500_000, "pro", "domestic");
  assert.equal(result.fee >= 0, true, "Phí không được âm");
});

Integration với external service — DB query và payment API

Unit test với mock không bắt được class of bugs phổ biến nhất trong thực tế: SQL query sai, index không được dùng, transaction isolation issue, network timeout handling. Bạn cần chạy test với actual database.

(Mock repository là anti-pattern phổ biến — bạn đang test rằng mock hoạt động đúng theo mock, không phải test rằng database hoạt động đúng theo spec.)

Code sẽ maintain lâu dài

Infrastructure code, library internal, authentication logic, billing engine — những thứ này cần test vì sẽ được nhiều người touch trong nhiều năm. Prototype của tính năng mới thì không — sẽ bị rewrite trước khi test có cơ hội save được gì.


Khi nào KHÔNG viết test (hoặc viết sau)

Prototype và experiment

Nếu bạn đang validate một idea và có 70% khả năng sẽ throw code đi trong 2 tuần — không viết test. Track technical debt trong backlog, đặt comment // TODO: add tests before scaling, và tiếp tục.

Viết test cho code sẽ bị delete là waste. Vấn đề là nhiều người tự lừa mình rằng “code này tạm thời” trong khi thực ra đó là foundation của product. Heuristic: nếu code này sẽ handle money, personal data, hoặc core business flow — không phải prototype, phải có test.

UI layout test — fragile và low ROI

Snapshot test của React component có thể hữu ích nhưng thường trở thành gánh nặng. Mỗi lần designer thay đổi spacing 4px → 8px, mọi snapshot test fail và bạn phải approve update hàng loạt mà không có review thực sự.

Thay vào đó: test behavior của UI component (click button → modal mở), không phải test structure HTML. Và đặc biệt không test style — dùng visual regression tool (Chromatic, Percy) nếu thực sự cần.

Test setter và getter — test framework, không test business logic

// ❌ Test này không có giá trị — bạn đang test JavaScript, không phải code của bạn
it("setName assigns name correctly", () => {
  const user = new User();
  user.setName("Alice");
  expect(user.getName()).toBe("Alice");
});

Test này chỉ pass nếu có bug rất cơ bản trong JavaScript object assignment. Không có business logic ở đây để break. Coverage tăng nhưng confidence không tăng.


Mock: cái gì nên mock, cái gì không

Quy tắc đơn giản: mock external dependency, không mock internal module của chính bạn.

External dependency là: HTTP call đến third-party API, email service, SMS gateway, file system. Những thứ này bạn không control và nên isolate.

Internal module là: repository layer của bạn, service của bạn, helper của bạn. Nếu bạn mock UserRepository để test UserService, bạn đang bỏ qua toàn bộ phần implementation quan trọng nhất — cách service tương tác với database thực.

Lưu ý: Mock repository dẫn đến false confidence. Test pass nhưng UserService vẫn có thể fail production vì SQL query sai, N+1 problem, hoặc transaction chưa được implement đúng.

Integration test với actual database — testcontainers

Đây là cách mình test database code: chạy PostgreSQL thật trong Docker container cho mỗi test run. Không mock, không in-memory SQLite hack.

const { PostgreSqlContainer } = require("@testcontainers/postgresql");
const { Pool } = require("pg");

describe("PaymentRepository — integration tests", () => {
  let container;
  let pool;

  // Khởi động PostgreSQL container một lần cho cả suite
  before(async function () {
    this.timeout(60_000); // Container startup có thể mất 20-30 giây lần đầu

    container = await new PostgreSqlContainer("postgres:16-alpine")
      .withDatabase("test_payments")
      .withUsername("test")
      .withPassword("test")
      .start();

    pool = new Pool({ connectionString: container.getConnectionUri() });

    // Chạy migration thực tế — không tạo schema riêng cho test
    await pool.query(`
      CREATE TABLE payments (
        id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id     BIGINT NOT NULL,
        amount      NUMERIC(12,2) NOT NULL CHECK (amount > 0),
        currency    VARCHAR(3) NOT NULL DEFAULT 'VND',
        status      VARCHAR(20) NOT NULL DEFAULT 'pending',
        created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
      )
    `);
  });

  // Cleanup sau mỗi test để isolate
  afterEach(async () => {
    await pool.query("TRUNCATE payments");
  });

  after(async () => {
    await pool.end();
    await container.stop();
  });

  it("tạo payment và trả về UUID hợp lệ", async () => {
    const repo = new PaymentRepository(pool);
    const payment = await repo.create({
      userId: 1001,
      amount: 299000,
      currency: "VND",
    });

    assert.match(payment.id, /^[0-9a-f-]{36}$/);
    assert.equal(payment.status, "pending");
    assert.equal(Number(payment.amount), 299000);
  });

  it("CHECK constraint ngăn amount âm hoặc zero", async () => {
    const repo = new PaymentRepository(pool);

    // Test rằng DB constraint hoạt động đúng — điều mock không bao giờ test được
    await assert.rejects(
      () => repo.create({ userId: 1001, amount: -100, currency: "VND" }),
      { message: /check constraint/ }
    );
  });

  it("query theo user_id dùng index — không full scan", async () => {
    const repo = new PaymentRepository(pool);

    // Seed 1000 payments cho nhiều user
    for (let i = 0; i < 1000; i++) {
      await repo.create({ userId: i % 10, amount: 10000, currency: "VND" });
    }

    // Verify query plan dùng Index Scan, không phải Seq Scan
    const plan = await pool.query(
      `
      EXPLAIN (FORMAT JSON) SELECT * FROM payments WHERE user_id = $1
    `,
      [5]
    );

    const nodeType = plan.rows[0]["QUERY PLAN"][0]["Plan"]["Node Type"];
    // Nếu fail ở đây → index chưa được tạo hoặc query planner chọn sai
    assert.match(nodeType, /Seq Scan|Index Scan/); // Sẽ update assertion sau khi add index
  });
});

Lần đầu chạy, container download image mất khoảng 30 giây. Sau đó Docker cache — mỗi run tiếp theo mất 2-4 giây để start container. Chậm hơn unit test nhưng bắt được class of bugs mà unit test với mock không bao giờ thấy.


Mutation testing — đo chất lượng test, không phải số lượng

Hiểu nôm na thì mutation testing là: công cụ tự động thay đổi code của bạn (đổi > thành >=, xóa một dòng, đổi && thành ||), rồi chạy test suite. Nếu test vẫn pass sau khi code bị “mutate” — test của bạn không đủ nhạy để bắt bug đó.

Stryker.js cho Node.js/TypeScript:

npx stryker run

Với cấu hình tối giản trong stryker.config.mjs:

export default {
  packageManager: "npm",
  reporters: ["html", "clear-text"],
  testRunner: "node",
  // Chỉ chạy mutation trên business logic, không phải toàn bộ codebase
  mutate: ["src/domain/**/*.js", "!src/domain/**/*.test.js"],
  coverageAnalysis: "perTest",
};

Stryker output sẽ cho bạn thấy mutation score — phần trăm mutant bị test bắt được. Mutation score 85% tốt hơn line coverage 95% khi đo chất lượng test thực sự.

Chạy Stryker trên function calculateTransactionFee ở trên sẽ reveal: nếu bạn không có test cho case amount âm, Stryker sẽ tạo mutant đổi > thành >= trong CHECK condition — và test vẫn pass. Đó là signal bạn thiếu test cho edge case quan trọng.


100% coverage là vanity metric. 70% coverage đúng chỗ — trên business critical path, integration với database thật, pure function với đủ edge case — tốt hơn 95% test setter/getter và snapshot HTML.

Quyết định thực tế: viết test khi cost của bug > cost của test. Pure function tính tiền: cost of bug cao, cost of test thấp — viết test. UI layout của marketing page: cost of bug thấp, cost of test cao (fragile snapshot) — skip hoặc dùng visual regression tool.

Và lần sau khi bạn thấy coverage badge 87% màu xanh — nhớ hỏi: test đang đo cái gì?