·

Tiếng Việt: Hands On Complete Test Suite

Hands On Complete Test Suite

Một service billing thật sự đáng tin cậy không phải vì nó có nhiều test, mà vì mỗi loại test trong bộ suite đang trả lời đúng câu hỏi rủi ro mà nó được sinh ra để trả lời.

Bối cảnh: Subscription Billing Service

Giả sử bạn vừa được giao một spec (tài liệu đặc tả) cho Subscription Billing Service — hệ thống tính phí định kỳ cho một sản phẩm SaaS. Đây là phần cốt lõi, đụng trực tiếp đến tiền của khách hàng, nên nó sẽ là ví dụ xuyên suốt toàn bộ bài viết này. Mọi kỹ thuật test đã học ở 4 bài trước trong module — risk-based strategy, unit test, TDD, integration test, contract test (kiểm thử hợp đồng giao tiếp giữa hai service), property-based test và mutation testing (kiểm thử đột biến) — sẽ được áp dụng lần lượt lên cùng một codebase giả định này.

Spec tóm tắt như sau:

  • Plans: Basic, Pro, Enterprise — mỗi plan có giá theo tháng (monthly) và theo năm (annual, chiết khấu ~15%).
  • Upgrade/downgrade giữa chu kỳ (mid-cycle): khách có thể đổi plan bất kỳ lúc nào trong chu kỳ hiện tại. Hệ thống phải tính proration (phân bổ phí theo tỷ lệ ngày sử dụng) — trả thêm phần chênh lệch khi upgrade, hoặc ghi credit khi downgrade.
  • Billing cycle job: một cron job chạy mỗi đêm, quét các subscription đến hạn, tạo invoice và gọi Payment Provider để charge thẻ. Job này chạy trên nhiều instance (horizontal scaling) nên phải idempotent — nếu job bị retry (do timeout, deploy giữa chừng, hoặc crash) thì tuyệt đối không được charge khách hàng hai lần cho cùng một chu kỳ.
  • Payment Provider: một API bên thứ ba kiểu Stripe — mình gọi POST /charges để trừ tiền, và nhận webhook báo payment.succeeded hoặc payment.failed bất đồng bộ. Webhook có thể đến trễ, đến trùng lặp, hoặc đến trước khi request gốc kịp trả response.
  • UI hiển thị plan hiện tại và lịch sử invoice — phần này chỉ đọc dữ liệu, không có logic tính toán.

Codebase giả định có 3 thành phần chính mà chúng ta sẽ dùng xuyên suốt bài:

BillingService        // điều phối: tạo invoice, gọi PaymentProviderClient, ghi kết quả
ProrationCalculator    // thuần logic tính toán, không side-effect
PaymentProviderClient  // wrapper gọi API bên thứ ba + xử lý webhook

Cách tiếp cận trong bài này không phải "viết test cho đủ coverage %", mà là dẫn dắt toàn bộ quá trình bằng câu hỏi: rủi ro nào đáng sợ nhất, và loại test nào là công cụ đúng để chặn rủi ro đó?

Mẹo: Khi nhận một spec mới, việc đầu tiên nên làm là paste nguyên văn spec vào AI agent và hỏi "liệt kê các nghiệp vụ có khả năng gây thiệt hại tài chính hoặc pháp lý nếu sai" trước khi viết bất kỳ dòng code nào — nó buộc bạn đọc kỹ spec thay vì nhảy thẳng vào implementation.

Bước 1: Phân tích rủi ro và Test Strategy

Từ spec trên, việc đầu tiên là dựng risk matrix (ma trận rủi ro) — không phải liệt kê tính năng, mà xếp hạng theo mức thiệt hại nếu sai × khả năng xảy ra.

Prompt gửi cho AI agent (Claude Code) để nháp bản strategy đầu tiên:

Đọc spec Subscription Billing Service đính kèm (paste spec ở đây).
Hãy đóng vai QA lead, liệt kê risk matrix gồm các cột:
- Khu vực nghiệp vụ
- Kịch bản lỗi cụ thể có thể xảy ra
- Mức độ nghiêm trọng (Critical/High/Medium/Low)
- Loại test phù hợp nhất để phát hiện lỗi này SỚM NHẤT trong pipeline
Sắp xếp giảm dần theo mức độ nghiêm trọng. Không đề xuất "viết thêm test cho mọi thứ" —
chỉ định rõ test pyramid layer nào (unit/integration/contract/e2e) phù hợp cho từng rủi ro.

Kết quả (rút gọn thành excerpt cho tài liệu strategy):

Khu vực Kịch bản lỗi Mức độ Loại test chính
Billing cycle job retry Charge khách 2 lần cho 1 chu kỳ Critical Unit (idempotency logic) + Integration (chạy job thật với DB)
Proration khi đổi plan Tính sai số tiền hoàn/thu thêm High Unit (boundary cases) + Property-based (bất biến toán học)
Webhook race condition Webhook đến trước response gốc → ghi đè trạng thái sai High Integration (mô phỏng thứ tự webhook bất kỳ)
Payment Provider API đổi field Contract ngầm bị phá vỡ, code vẫn "chạy" nhưng gọi sai High Contract test (Pact)
Hiển thị plan/invoice trên UI Sai label, sai định dạng ngày Low Snapshot/manual, không cần đầu tư sâu

Từ risk matrix, ta suy ra coverage goal theo tầng rủi ro thay vì một con số coverage % chung chung cho cả service:

  • Critical (billing job, idempotency): unit test bao 100% nhánh logic dedup + ít nhất 1 integration test end-to-end với DB thật.
  • High (proration, webhook): unit test đầy đủ boundary case + property-based test cho bất biến toán học + 1-2 kịch bản race condition ở tầng integration.
  • Payment Provider integration: 1 bộ Pact contract cho mỗi loại request quan trọng (charge, refund).
  • Low (UI hiển thị): để tầng dưới — không cần đầu tư mutation testing hay property test ở đây.

Hình dạng test pyramid cho service này lệch hẳn về phía unit test cho phần tính toán (ProrationCalculator), nhưng có một lớp integration dày bất thường so với dịch vụ CRUD thông thường — vì bản chất rủi ro nằm ở tương tác giữa job scheduler, DB và Payment Provider, không chỉ nằm trong logic thuần.

Mẹo: Đừng để AI tự chọn "loại test" chung chung — ép nó gắn từng dòng risk với một tầng cụ thể trong test pyramid, nếu không bạn sẽ nhận về danh sách kiểu "cần thêm test" vô nghĩa mà không biết đặt ở đâu.

Bước 2: Unit test cho logic Pricing và Proration

ProrationCalculator là ứng viên lý tưởng cho unit test: hàm thuần (pure function), không I/O, nhưng logic ngày tháng lại dễ sai ở các trường hợp biên. Ta bắt đầu bằng cách liệt kê boundary case trước khi viết implementation.

Prompt:

Viết bộ unit test cho ProrationCalculator.calculate(oldPlan, newPlan, changeDate, cycleStart, cycleEnd)
bằng Vitest. Bao gồm các case biên sau:
1. Upgrade vào ngày đầu chu kỳ (gần như full giá plan mới)
2. Upgrade vào ngày cuối chu kỳ (gần như không phát sinh thêm)
3. Downgrade giữa chu kỳ (phát sinh credit)
4. Đổi plan đúng ngày cycle start = cycle end (chu kỳ 1 ngày, edge case hiếm)
5. Chuyển từ annual sang monthly giữa chu kỳ (khác đơn vị base price)
Đừng implement code, chỉ viết test trước — tôi sẽ dùng để làm rõ spec tính toán.

Ví dụ test cho case upgrade cuối chu kỳ — case dễ bị bỏ sót nhất vì lập trình viên hay quên chu kỳ có thể tính theo ngày lẻ (28-31 ngày):

describe('ProrationCalculator.calculate', () => {
  it('tính gần như không phát sinh phí khi upgrade vào ngày cuối chu kỳ', () => {
    const result = calculate({
      oldPlan: PLAN.BASIC,      // $10/tháng
      newPlan: PLAN.PRO,        // $30/tháng
      changeDate: '2026-07-30',
      cycleStart: '2026-07-01',
      cycleEnd: '2026-07-31',   // tháng 31 ngày
    });

    // Chỉ còn 1 ngày sử dụng plan mới trong chu kỳ 31 ngày
    expect(result.additionalCharge).toBeCloseTo((30 / 31) * 1, 2);
    expect(result.additionalCharge).toBeGreaterThan(0);
  });

  it('ghi credit khi downgrade giữa chu kỳ', () => {
    const result = calculate({
      oldPlan: PLAN.PRO,
      newPlan: PLAN.BASIC,
      changeDate: '2026-07-16',
      cycleStart: '2026-07-01',
      cycleEnd: '2026-07-31',
    });

    expect(result.creditAmount).toBeGreaterThan(0);
    expect(result.additionalCharge).toBe(0);
  });
});

Một pitfall thực tế: nhiều đội tính proration bằng "số ngày còn lại / 30" cố định thay vì độ dài chu kỳ thật — sai lệch nhỏ nhưng cộng dồn qua hàng chục nghìn subscription thì thành số tiền đáng kể trên báo cáo tài chính cuối tháng. Unit test boundary case ở bước này chính là nơi bắt lỗi đó rẻ nhất, trước khi nó lọt xuống tầng integration hay production.

Mẹo: Luôn viết case "chu kỳ tháng 28 ngày (tháng 2)" riêng biệt — đây là case ẩn phổ biến nhất khiến công thức proration hardcode /30 bị lộ sai số ngay lập tức.

Bước 3: TDD cho kiểm tra Idempotency của Billing Cycle

Đây là rủi ro Critical nhất trong risk matrix — double-charge. Ta dùng TDD (red-green-refactor) đúng nghĩa: viết test trước khi có bất kỳ dòng implementation nào cho cơ chế dedup.

Cơ chế chọn: mỗi lần billing job xử lý một subscription cho một chu kỳ cụ thể, nó sinh ra một idempotency key dạng ${subscriptionId}:${cycleStartDate}, ghi vào bảng processed_billing_cycles với unique constraint trước khi gọi Payment Provider.

Red — prompt yêu cầu AI viết test trước, cố ý cho fail:

Viết test cho BillingService.processCycle(subscriptionId, cycleStart) khẳng định:
gọi processCycle hai lần liên tiếp với cùng subscriptionId + cycleStart chỉ tạo
ĐÚNG MỘT charge tới PaymentProviderClient. Dùng Jest với mock PaymentProviderClient.
Chưa cần implementation, tôi muốn thấy test fail trước.
it('không charge lần 2 khi processCycle được gọi lại với cùng cycle (idempotent)', async () => {
  const mockProvider = { charge: vi.fn().mockResolvedValue({ status: 'succeeded' }) };
  const service = new BillingService({ provider: mockProvider, db: testDb });

  await service.processCycle('sub_123', '2026-07-01');
  await service.processCycle('sub_123', '2026-07-01'); // giả lập retry

  expect(mockProvider.charge).toHaveBeenCalledTimes(1);
});

Chạy test → fail vì processCycle chưa tồn tại (đúng như kỳ vọng của "red").

Green — implementation tối thiểu để pass:

async processCycle(subscriptionId, cycleStart) {
  const idempotencyKey = `${subscriptionId}:${cycleStart}`;
  const inserted = await this.db.insertIfNotExists('processed_billing_cycles', { idempotencyKey });
  if (!inserted) return { status: 'skipped', reason: 'already_processed' };

  const invoice = await this.buildInvoice(subscriptionId, cycleStart);
  const result = await this.provider.charge(invoice);
  await this.db.recordChargeResult(idempotencyKey, result);
  return result;
}

Refactor — sau khi xanh, ta nhận ra insertIfNotExists cần transaction thật với unique constraint ở DB (không chỉ check-then-insert ở tầng ứng dụng, vì hai instance job có thể race nhau đúng lúc). Refactor sang dùng INSERT ... ON CONFLICT DO NOTHING và kiểm tra rowCount — test ở bước Green vẫn xanh nguyên, chứng minh refactor an toàn.

Mẹo: Viết test idempotency với 2 lệnh gọi TUẦN TỰ là chưa đủ — thêm một test gọi Promise.all([processCycle(...), processCycle(...)]) để mô phỏng race condition thật giữa 2 instance job chạy song song, vì check-then-insert ở tầng code (không phải DB constraint) sẽ pass test tuần tự nhưng fail test song song.

Bước 4: Integration test cho Billing Cycle

Unit test đã chứng minh logic dedup đúng với mock, nhưng nó không chứng minh được unique constraint ở DB thật hoạt động, hay invoice thật sự được ghi đúng bảng. Integration test dùng testcontainers để bật một Postgres thật trong Docker cho từng lần chạy CI.

Prompt:

Viết integration test cho BillingService.processCycle dùng testcontainers (Postgres 15).
Setup: seed 1 subscription Pro đang active, cycleStart hôm nay.
Kịch bản cần test:
1. Chạy processCycle 1 lần -> đúng 1 invoice record được tạo, status = 'paid'
2. Chạy lại processCycle lần 2 (retry) -> vẫn chỉ có 1 invoice, không tạo thêm
3. Payment Provider trả lỗi (mock) -> invoice ghi status = 'failed', KHÔNG đánh dấu
   processed_billing_cycles để job có thể retry ở lần chạy sau
Dùng PaymentProviderClient thật nhưng point vào một fake HTTP server nội bộ (nock hoặc msw),
không mock ở tầng service.
describe('BillingService integration (real Postgres via testcontainers)', () => {
  let container, db;

  beforeAll(async () => {
    container = await new PostgreSqlContainer('postgres:15').start();
    db = await connect(container.getConnectionUri());
    await runMigrations(db);
  });

  afterAll(() => container.stop());

  it('tạo đúng 1 invoice dù processCycle bị gọi lại (retry thật)', async () => {
    await seedSubscription(db, { id: 'sub_1', plan: 'PRO', cycleStart: '2026-07-01' });
    const service = new BillingService({ db, provider: fakePaymentProvider });

    await service.processCycle('sub_1', '2026-07-01');
    await service.processCycle('sub_1', '2026-07-01');

    const invoices = await db.query(
      'SELECT * FROM invoices WHERE subscription_id = $1', ['sub_1']
    );
    expect(invoices.rows).toHaveLength(1);
    expect(invoices.rows[0].status).toBe('paid');
  });

  it('không đánh dấu processed khi Payment Provider trả lỗi, cho phép retry', async () => {
    await seedSubscription(db, { id: 'sub_2', plan: 'BASIC', cycleStart: '2026-07-01' });
    fakePaymentProvider.forceFailureFor('sub_2');
    const service = new BillingService({ db, provider: fakePaymentProvider });

    await service.processCycle('sub_2', '2026-07-01');

    const cycle = await db.query(
      'SELECT * FROM processed_billing_cycles WHERE idempotency_key = $1',
      ['sub_2:2026-07-01']
    );
    expect(cycle.rows).toHaveLength(0); // chưa đánh dấu -> job đêm sau sẽ retry
  });
});

Đây chính là tầng bắt được lỗi mà unit test không thể: nếu unique constraint thiếu index, hoặc transaction isolation level sai, test này sẽ đỏ dù logic ứng dụng "trông đúng".

Mẹo: Chạy integration suite này với --runInBand (không parallel giữa các test file) khi dùng chung 1 container Postgres — song song hóa sai có thể khiến state giữa các test leak vào nhau và tạo false positive/negative khó debug.

Bước 5: Contract test cho Payment Provider API

Payment Provider là service ngoài, team mình không kiểm soát được khi họ đổi field response. Rủi ro: code gọi đúng nhưng field trả về thay đổi (ví dụ charge_id đổi thành id) mà không ai biết cho tới khi production báo lỗi parse. Contract test bằng Pact giải quyết đúng vấn đề này — không cần gọi service thật, nhưng vẫn xác thực được cả hai phía tuân thủ cùng một "hợp đồng".

Prompt cho phía consumer (BillingService là consumer, Payment Provider là provider):

Viết Pact consumer test cho PaymentProviderClient.charge(invoice).
Interaction: POST /v1/charges với body { amount, currency, customer_id, idempotency_key }.
Response mong đợi: 200 với { charge_id, status: 'succeeded' | 'pending', created_at }.
Publish pact file lên Pact Broker sau khi test pass, để team Payment Provider chạy
provider verification trong CI của họ.
describe('PaymentProviderClient - Pact contract with Payment Provider', () => {
  const provider = new Pact({
    consumer: 'billing-service',
    provider: 'payment-provider-api',
    port: 8992,
  });

  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());

  it('gửi đúng request shape khi charge subscription', async () => {
    await provider.addInteraction({
      state: 'customer cus_1 có phương thức thanh toán hợp lệ',
      uponReceiving: 'a request to charge subscription',
      withRequest: {
        method: 'POST',
        path: '/v1/charges',
        headers: { 'Content-Type': 'application/json' },
        body: {
          amount: 3000,
          currency: 'usd',
          customer_id: 'cus_1',
          idempotency_key: 'sub_123:2026-07-01',
        },
      },
      willRespondWith: {
        status: 200,
        body: {
          charge_id: Matchers.like('ch_abc123'),
          status: Matchers.term({ generate: 'succeeded', matcher: 'succeeded|pending' }),
          created_at: Matchers.iso8601DateTime(),
        },
      },
    });

    const client = new PaymentProviderClient({ baseUrl: provider.mockService.baseUrl });
    const result = await client.charge({
      amount: 3000, currency: 'usd', customerId: 'cus_1',
      idempotencyKey: 'sub_123:2026-07-01',
    });

    expect(result.chargeId).toBe('ch_abc123');
  });
});

Contract test không thay thế integration test — nó trả lời một câu hỏi khác: "nếu team Payment Provider đổi API, ai biết trước khi production vỡ?" Provider verification (chạy pact file này trong CI của team Payment Provider) chính là mảnh ghép còn thiếu mà integration test với fake server không bao giờ phát hiện ra, vì fake server do chính team mình viết nên sẽ luôn "đồng thuận" với giả định của mình.

Mẹo: Đừng viết matcher quá chặt cho field không quan trọng (như created_at chính xác từng giây) — dùng Matchers.like/term để pact chỉ fail khi shape hoặc type đổi, không fail vì giá trị cụ thể khác nhau giữa các lần chạy.

Bước 6: Property-based test cho Proration

Unit test ở Bước 2 đã phủ boundary case cụ thể, nhưng nó không chứng minh được công thức đúng cho mọi tổ hợp ngày/plan có thể xảy ra. Property-based test bù đắp khoảng trống này bằng cách phát biểu bất biến (invariant) toán học rồi để framework tự sinh hàng nghìn input ngẫu nhiên để tìm phản ví dụ.

Ba bất biến quan trọng nhất cho proration:

  1. Tổng phí của đoạn trước + đoạn sau khi đổi plan phải bằng đúng phí đầy đủ nếu dùng plan mới trọn chu kỳ (không rò rỉ tiền, không tính dư).
  2. Số tiền proration không bao giờ âm.
  3. Additional charge phải đơn điệu tăng theo số ngày còn lại sử dụng plan mới (đổi càng sớm trong chu kỳ, charge thêm càng nhiều).

Prompt:

Viết property-based test bằng fast-check cho ProrationCalculator, kiểm tra 3 bất biến:
1. additionalCharge + creditAmount tương ứng phải khớp công thức phân bổ tuyến tính theo ngày
2. Không có kết quả âm bất kể input hợp lệ nào
3. additionalCharge đơn điệu giảm khi changeDate tiến gần cycleEnd hơn
Sinh dữ liệu: cycle 28-31 ngày, changeDate nằm trong khoảng [cycleStart, cycleEnd],
plan pairs ngẫu nhiên trong {BASIC, PRO, ENTERPRISE} x {monthly, annual}.
import fc from 'fast-check';

describe('ProrationCalculator - property-based', () => {
  it('không bao giờ trả về số tiền âm', () => {
    fc.assert(
      fc.property(cycleArbitrary(), planPairArbitrary(), (cycle, plans) => {
        const result = calculate({ ...plans, ...cycle });
        expect(result.additionalCharge).toBeGreaterThanOrEqual(0);
        expect(result.creditAmount).toBeGreaterThanOrEqual(0);
      })
    );
  });

  it('additionalCharge đơn điệu giảm khi changeDate tiến gần cycleEnd', () => {
    fc.assert(
      fc.property(cycleArbitrary(), planPairArbitrary(), (cycle, plans) => {
        const earlier = calculate({ ...plans, ...cycle, changeDate: cycle.cycleStart });
        const later = calculate({ ...plans, ...cycle, changeDate: cycle.cycleEnd });
        if (plans.newPlan.price > plans.oldPlan.price) {
          expect(earlier.additionalCharge).toBeGreaterThanOrEqual(later.additionalCharge);
        }
      })
    );
  });
});

Property test kiểu này từng lộ ra một bug thật trong nhiều team: khi changeDate === cycleEnd (đổi plan đúng ngày cuối), công thức (cycleEnd - changeDate) / totalDays cho ra 0 ngày sử dụng plan mới, nhưng code cũ tính +1 sai chỗ khiến ra số âm nhỏ do lỗi làm tròn dấu phẩy động — thứ mà unit test viết tay khó nghĩ ra vì nó không phải case "trực giác".

Mẹo: Giới hạn số ngày cycle trong generator về đúng tập giá trị thực tế (28, 29, 30, 31) thay vì để fast-check sinh số nguyên bất kỳ — nếu không, shrinking sẽ tốn thời gian tìm phản ví dụ ở những cycle-length không bao giờ xảy ra trong đời thực (ví dụ cycle 400 ngày), làm loãng tín hiệu.

Bước 7: Chạy Mutation Testing và đọc kết quả

Đến đây bộ suite đã khá dày: unit, TDD-driven idempotency test, integration, contract, property-based. Câu hỏi còn lại: các test này có thực sự "cắn" được logic, hay chỉ đang chạy qua chạy lại mà không assert đủ chặt? Mutation testing bằng Stryker trả lời câu hỏi đó bằng cách tự động biến đổi nhỏ trong source code (đổi > thành >=, xóa một dòng, đổi hằng số) rồi xem test có phát hiện ra không.

Prompt sau khi có report:

Đây là báo cáo Stryker mutation testing cho module billing (paste report). Mutation score 87%.
Với mỗi surviving mutant, hãy phân loại: (a) đáng sửa vì lộ ra assertion còn thiếu,
hay (b) chấp nhận được vì mutant đó không ảnh hưởng hành vi quan sát được từ bên ngoài
(ví dụ đổi tên biến nội bộ, hoặc log message). Giải thích lý do cho từng case.

Kết quả rút gọn:

Mutation score: 87% (156 killed / 179 total mutants)

Surviving mutant #1:
  File: proration-calculator.js:42
  Mutation: `if (daysUsed < 0)` -> `if (daysUsed <= 0)`
  => SỐNG SÓT. Không có test nào cover case daysUsed = 0 (đổi plan đúng lúc
     cycleStart = changeDate). Đây là gap thật -> cần thêm test.

Surviving mutant #2:
  File: billing-service.js:88
  Mutation: xóa dòng `logger.info('cycle processed', ...)`
  => Chấp nhận được. Đây chỉ là logging, không ảnh hưởng hành vi nghiệp vụ,
     không cần viết test riêng để "giết" mutant log.

Surviving mutant #3:
  File: payment-provider-client.js:15
  Mutation: đổi timeout 5000 -> 4999
  => Ranh giới, tạm chấp nhận. Off-by-one trên timeout hiếm khi quan sát được
     qua test, chi phí viết test giết mutant này cao hơn giá trị mang lại.

Điểm quan trọng: mutation score 87% không phải mục tiêu tự thân. Việc đọc từng surviving mutant và quyết định "sửa test hay bỏ qua có chủ đích" mới là giá trị thật — mutant #1 dẫn thẳng tới một test mới cho case daysUsed = 0, còn #2, #3 được ghi chú lại trong strategy doc là "chấp nhận rủi ro thấp" thay vì âm thầm bỏ qua.

Mẹo: Loại trừ (exclude) các file logging/telemetry thuần khỏi phạm vi mutation testing ngay từ config Stryker — vừa giảm thời gian chạy, vừa tránh mutation score bị "pha loãng" bởi những mutant không đáng giết ngay từ đầu, đỡ phải giải thích lại mỗi lần chạy.

Bước 8: Audit cuối cùng — Test suite có đáp ứng strategy không?

Quay lại risk matrix ở Bước 1, giờ là lúc đối chiếu từng dòng với những gì đã build được.

Prompt:

Đây là strategy doc ban đầu (paste risk matrix từ Bước 1) và đây là danh sách toàn bộ
test hiện có trong repo (paste danh sách file test). Với mỗi dòng rủi ro trong strategy,
xác định: đã có test loại nào cover chưa, còn thiếu gì so với coverage goal đã đề ra.
Liệt kê gap dưới dạng action item cụ thể, không nói chung chung.

Kết quả audit:

Rủi ro Coverage goal Thực tế Trạng thái
Double-charge khi retry Unit + Integration TDD unit test + testcontainers integration test Đạt
Proration miscalculation Unit boundary + Property-based 12 unit case + 3 property test Đạt
Webhook race condition Integration mô phỏng thứ tự bất kỳ Chưa có test nào mô phỏng webhook đến TRƯỚC response gốc Gap
Payment Provider contract 1 Pact contract cho charge Có pact cho charge, thiếu pact cho refund Gap (nhỏ)
UI hiển thị Không cần đầu tư sâu Không có test, đúng như dự tính Đạt (chủ đích)

Gap về webhook race condition là nghiêm trọng nhất còn sót — nó đã được xếp High risk từ Bước 1 nhưng chưa có test nào bảo vệ. Đây là ví dụ thực tế cho lý do vì sao bước audit không thể bỏ qua: nếu chỉ nhìn coverage % tổng thể, con số vẫn có thể "đẹp" trong khi lỗ hổng rủi ro cao nhất lại đang bị bỏ trống. Action item được đưa vào backlog: viết integration test giả lập webhook đến trước khi charge() kịp resolve, xác nhận BillingService xử lý đúng thứ tự bằng state machine thay vì ghi đè trực tiếp.

Mẹo: Chạy audit này như một checklist định kỳ (mỗi sprint hoặc mỗi khi risk matrix thay đổi), không chỉ một lần khi kết thúc dự án — spec billing luôn có xu hướng phình thêm case mới (coupon, trial, refund một phần) mà risk matrix ban đầu không lường trước.

Tổng kết: Một bộ test suite hoàn chỉnh trông như thế nào

Sau 7 bước, bức tranh tổng thể của Subscription Billing Service test suite trông như sau:

  • Unit test cho pricing/proration: 32 case, bao gồm toàn bộ boundary ngày tháng và tổ hợp plan.
  • TDD-driven idempotency test: 4 test case (tuần tự, song song/race, DB constraint, retry sau lỗi).
  • Integration test cho billing cycle: 8 kịch bản chạy trên Postgres thật qua testcontainers.
  • Contract test: 1 Pact interaction cho charge, 1 gap còn thiếu cho refund (đã ghi nhận, chưa xử lý).
  • Property-based test: 3 bất biến toán học, mỗi bất biến chạy hàng nghìn input sinh tự động.
  • Mutation score: 87%, với 2 surviving mutant được chấp nhận có ghi chú lý do, 1 mutant dẫn tới test mới.

Nhìn lại vai trò của AI xuyên suốt: AI agent giỏi nhất ở việc sinh nhanh khối lượng lớn test case theo khuôn mẫu đã định — boundary case, Pact interaction theo schema, property generator, đọc report Stryker và phân loại mutant theo tiêu chí đã cho. Nhưng ở mọi bước, quyết định mang tính đánh đổi vẫn nằm ở người: chọn cơ chế idempotency key nào là đủ (Bước 3), quyết định mutant nào đáng sửa (Bước 7), và quan trọng nhất — nhận ra gap webhook race condition ở Bước 8 mà không công cụ tự động nào tự phát hiện ra, vì nó đòi hỏi đọc lại spec gốc và tưởng tượng ra kịch bản chưa từng được viết thành test.

Và cần thẳng thắn: bộ suite này, dù đã "hoàn chỉnh" theo strategy đề ra, vẫn còn rủi ro tồn dư. Gap về webhook race condition chưa được vá. Coupon và trial period — nếu spec mở rộng sau này — sẽ cần risk matrix mới. Không có bộ test nào là bằng chứng tuyệt đối cho sự đúng đắn; nó chỉ là bằng chứng tốt nhất mà nhóm có thể tạo ra trong thời gian và ngân sách cho phép, được cập nhật liên tục khi rủi ro thay đổi.

Mẹo: Lưu lại bảng tổng kết này (loại test, số lượng, mutation score) như một "snapshot sức khỏe" của service, review lại mỗi quý — con số tự nó không nói lên chất lượng, nhưng xu hướng giảm dần theo thời gian là tín hiệu sớm cho việc test suite đang bị nợ kỹ thuật lấn át.

Những điểm chính

  • Test strategy phải bắt đầu từ risk matrix cụ thể cho từng service, không phải coverage % chung chung — mỗi rủi ro cần được gắn với đúng tầng trong test pyramid.
  • Unit test là nơi rẻ nhất để bắt lỗi boundary case (ngày tháng, làm tròn), nhưng không chứng minh được hành vi hệ thống thật — cần integration test bổ sung cho phần đụng DB/side-effect.
  • TDD (red-green-refactor) đặc biệt hiệu quả cho các cơ chế "phải đúng ngay từ đầu" như idempotency — viết test race condition song song, không chỉ tuần tự.
  • Contract test (Pact) giải quyết vấn đề mà integration test với fake server không thể: xác nhận cả hai phía (consumer/provider) thực sự đồng thuận trên cùng một hợp đồng.
  • Property-based test lộ ra bug ở những tổ hợp input mà không ai nghĩ ra viết tay — đặc biệt hữu ích cho logic tính toán tài chính có bất biến toán học rõ ràng.
  • Mutation score chỉ có giá trị khi đi kèm việc đọc từng surviving mutant và quyết định có chủ đích — con số cao không đồng nghĩa an toàn nếu mutant sống sót đúng vào vùng rủi ro cao nhất.
  • Bước audit cuối cùng — đối chiếu ngược lại risk matrix ban đầu — là bước dễ bị bỏ qua nhất nhưng lại là nơi phát hiện gap nghiêm trọng nhất (webhook race condition trong ví dụ này).
  • AI tăng tốc việc sinh test theo khuôn mẫu, nhưng phán đoán rủi ro, chọn cơ chế kỹ thuật, và quyết định đánh đổi vẫn là trách nhiệm của kỹ sư con người.
  • Không bộ test suite nào "hoàn chỉnh" vĩnh viễn — nó cần được nuôi dưỡng và audit lại mỗi khi spec hoặc rủi ro nghiệp vụ thay đổi.