jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Node.js Production Engineering 09 — Chiến lược Kiểm thử

Thiết kế test portfolio theo rủi ro cho domain, HTTP, database, contract, dependency failure, concurrency và migration; đồng thời kiểm soát coverage, flakiness và thời gian phản hồi CI.

Một pipeline đỏ chỉ bảo vệ sản phẩm khi đội ngũ tin màu đỏ đó. Nếu test thỉnh thoảng fail vì thời gian, mạng hoặc state dùng chung, phản xạ tự nhiên sẽ là rerun. Sau vài tháng, bộ test vẫn nhiều nhưng không còn tạo ra tín hiệu quyết định.

Chiến lược kiểm thử production phải tối ưu đồng thời ba mục tiêu:

confidence     — bắt được lỗi có tác động thật
feedback time  — trả kết quả đủ nhanh cho nhịp phát triển
diagnosability — fail ở đâu thì chỉ ra boundary và nguyên nhân ở đó

Không có một tỷ lệ unit/integration/E2E cố định cho mọi hệ thống. Test portfolio phải xuất phát từ rủi ro, kiến trúc và failure mode của service.

Sau bài này, bạn sẽ làm được gì?

  • Lập risk map và đặt mỗi invariant ở tầng test rẻ nhất có thể chứng minh nó.
  • Viết unit test theo hành vi với fake rõ nghĩa thay vì mock mọi chi tiết.
  • Dùng Testcontainers và Supertest để kiểm database/HTTP thật, cô lập state đúng cách.
  • Kiểm API/event contract, migration compatibility, retry, timeout và concurrency.
  • Dùng coverage, property-based và mutation testing như tín hiệu bổ sung.
  • Thiết kế CI test tiers, flake policy và test telemetry để pipeline đáng tin.

Mental model: test một contract tại boundary sở hữu nó

Mỗi loại lỗi có một boundary phù hợp:

domain invariant       → unit/property test
SQL mapping/constraint → database integration test
HTTP status/schema     → component/API test
provider/consumer      → contract test
critical user journey  → ít E2E test
latency/recovery       → non-functional test

Đẩy mọi thứ lên E2E làm feedback chậm và khó chẩn đoán. Đẩy mọi thứ xuống unit với mock làm ta kiểm implementation tưởng tượng, không kiểm integration thật.

Risk map trước test case

Với Ordering Service:

Rủi roTác độngKhả năngTest chính
Xác nhận order hai lần, charge hai lầnRất caoTrung bìnhconcurrency + idempotency integration
Schema event làm consumer cũ lỗiCaoTrung bìnhcontract compatibility
Query list order N+1Trung bìnhCaointegration + query-count/performance
Text lỗi thay đổiThấpCaoKhông assert toàn chuỗi; assert error code
Provider payment timeoutCaoTrung bìnhcomponent test với controllable fake

Ưu tiên theo tác động và khả năng, không theo dòng code dễ cover.


Test portfolio thay vì “kim tự tháp theo quota”

Một portfolio thực dụng thường có:

  • Nhiều test domain/use case chạy trong memory.
  • Đủ integration test cho database, cache, queue và serialization thật.
  • API/component test chạy app trong process qua HTTP adapter.
  • Contract test cho boundary do team khác sở hữu.
  • Một số ít E2E journey có giá trị kinh doanh cao.
  • Test chuyên biệt cho migration, performance, security và recovery.

Số lượng không quan trọng bằng câu hỏi: nếu implementation sai ở boundary này, test nào fail đầu tiên và thông báo có rõ không?

Unit test: hành vi, invariant và branch quyết định

Unit test tốt không cần database hoặc network. Nó kiểm policy và orchestration qua port.

import { describe, expect, it } from 'vitest';

class InMemoryOrderRepository implements OrderRepository {
  readonly items = new Map<string, Order>();

  async findById(id: string) {
    return this.items.get(id) ?? null;
  }

  async save(order: Order) {
    this.items.set(order.id, order);
  }
}

describe('ConfirmOrder', () => {
  it('xác nhận order sau khi payment được authorize', async () => {
    const orders = new InMemoryOrderRepository();
    orders.items.set('o-1', Order.pending('o-1', 120_000));

    const payments: PaymentGateway = {
      authorize: async () => ({ paymentId: 'pay-1' }),
    };

    await new ConfirmOrder(orders, payments).execute('o-1');

    expect(orders.items.get('o-1')?.status).toBe('confirmed');
    expect(orders.items.get('o-1')?.paymentId).toBe('pay-1');
  });

  it('không đổi trạng thái khi payment bị từ chối', async () => {
    const orders = new InMemoryOrderRepository();
    orders.items.set('o-2', Order.pending('o-2', 120_000));

    const payments: PaymentGateway = {
      authorize: async () => {
        throw new PaymentDeclined();
      },
    };

    await expect(
      new ConfirmOrder(orders, payments).execute('o-2')
    ).rejects.toBeInstanceOf(PaymentDeclined);
    expect(orders.items.get('o-2')?.status).toBe('pending');
  });
});

Fake repository diễn đạt hành vi tốt hơn chuỗi mockResolvedValueOnce. Tuy nhiên fake có thể khác database thật về unique constraint, transaction và isolation; vì vậy nó không thay integration test.

Assert contract ổn định

Ưu tiên:

expect(error.code).toBe('ORDER_NOT_FOUND');
expect(order.status).toBe('confirmed');
expect(event).toMatchObject({ type: 'OrderConfirmed', version: 1 });

Hạn chế assert private method, số lần gọi nội bộ không liên quan contract, snapshot JSON quá lớn hoặc toàn bộ text message. Những assertion đó làm refactor vô hại cũng phá test.

Time, random và ID phải điều khiển được

interface Clock {
  now(): Date;
}
interface IdGenerator {
  next(): string;
}

const fixedClock: Clock = { now: () => new Date('2026-07-11T00:00:00Z') };
const ids: IdGenerator = { next: () => 'order-123' };

Inject clock/ID vào use case quan trọng giúp test deterministic. Fake timer phù hợp với retry/scheduler trong process, nhưng phải restore sau test và không dùng để che integration cần thời gian thật.


Property-based test cho invariant rộng

Example-based test chỉ kiểm vài điểm. Property-based test sinh nhiều input để tìm edge case mà ta chưa nghĩ tới.

import fc from 'fast-check';

it('tổng tiền không âm sau mọi discount hợp lệ', () => {
  fc.assert(
    fc.property(
      fc.integer({ min: 0, max: 10_000_000 }),
      fc.integer({ min: 0, max: 100 }),
      (subtotal, percent) => {
        const total = applyDiscount(subtotal, percent);
        expect(total).toBeGreaterThanOrEqual(0);
        expect(total).toBeLessThanOrEqual(subtotal);
      }
    )
  );
});

Hợp với parser, money, permission, state machine và serialization. Không dùng generator tùy ý cho mọi DTO; property phải diễn đạt invariant có ý nghĩa.


Integration test với database thật

Mock ORM không thể kiểm SQL, migration, constraint hoặc transaction. Testcontainers tạo Postgres dùng thật rồi hủy sau suite.

import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';

let container: Awaited<ReturnType<PostgreSqlContainer['start']>>;
let pool: Pool;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:17-alpine').start();
  pool = new Pool({ connectionString: container.getConnectionUri() });
  await runMigrations(pool);
});

beforeEach(async () => {
  await pool.query('TRUNCATE order_items, orders RESTART IDENTITY CASCADE');
});

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

it('unique idempotency key ngăn hai order cùng request', async () => {
  const repo = new PostgresOrderRepository(pool);

  await repo.create({ idempotencyKey: 'req-1', total: 100_000 });

  await expect(
    repo.create({ idempotencyKey: 'req-1', total: 100_000 })
  ).rejects.toMatchObject({ code: '23505' });
});

Cô lập state: chọn theo kiến trúc test

  • TRUNCATE rõ và ổn cho suite vừa, nhưng chậm hơn khi schema lớn.
  • Schema/database riêng theo worker cho phép chạy song song, đổi lại setup phức tạp.
  • Transaction + rollback nhanh khi mọi query dùng cùng connection/transaction.

Không thể giả định transaction wrapper của test sẽ rollback request Supertest nếu application lấy connection khác từ pool. Khi boundary là HTTP, truncate hoặc database/schema riêng thường dễ đúng hơn.

Pin image version trong test để tránh CI hôm nay tự kéo database major mới. Đồng thời có một job định kỳ kiểm phiên bản mục tiêu tiếp theo.


Component/API test với Supertest

Tạo app qua factory và inject dependency; không import một singleton đã kết nối database trước khi test set config.

import request from 'supertest';

it('POST /orders trả 201 và contract tối thiểu', async () => {
  const app = createHttpApp({
    createOrder: new CreateOrder(new PostgresOrderRepository(pool)),
    logger: silentLogger,
  });

  const response = await request(app)
    .post('/orders')
    .set('Idempotency-Key', 'req-42')
    .send({
      customerId: '3fef84c5-df8f-46f9-b70e-f8259e43bd32',
      items: [{ sku: 'BOOK-1', quantity: 2 }],
    });

  expect(response.status).toBe(201);
  expect(response.headers['content-type']).toContain('application/json');
  expect(response.body).toMatchObject({ status: 'pending' });

  const persisted = await pool.query(
    'SELECT status FROM orders WHERE id = $1',
    [response.body.id]
  );
  expect(persisted.rows[0].status).toBe('pending');
});

Test cả invalid JSON/DTO, auth, content type, limit, error mapping và duplicate idempotency key. Status code đúng nhưng database sai vẫn là fail; database đúng nhưng response lộ internal field cũng là fail.


Contract test: kiểm tương thích mà không dựng toàn hệ thống

HTTP contract

OpenAPI là contract nếu pipeline kiểm nó:

  • response thật validate theo schema;
  • breaking-change detector so spec mới với bản production;
  • generated client smoke-test với provider;
  • version/deprecation policy có thời hạn.

Breaking change gồm xóa/đổi tên field, thu hẹp enum, đổi required, đổi status hoặc semantics — không chỉ đổi URL.

Event contract

{
  "type": "OrderConfirmed",
  "version": 1,
  "eventId": "evt-123",
  "occurredAt": "2026-07-11T00:00:00Z",
  "data": {
    "orderId": "o-1",
    "customerId": "c-1",
    "total": 120000
  }
}

Producer test schema và backward compatibility; consumer test fixture từ registry. Thêm field optional thường an toàn, đổi nghĩa field cũ thì không. Version event không miễn trách nhiệm migration consumer.

Consumer-driven contract phù hợp khi provider có nhiều consumer độc lập và khó chạy E2E chung. Nó không thay test semantics nghiệp vụ giữa các team.


Outbound dependency: dùng controllable fake

Không gọi sandbox bên thứ ba trong test mặc định: chậm, quota và trạng thái ngoài kiểm soát. Bọc SDK sau port và dùng fake có script failure.

class ScriptedPaymentGateway implements PaymentGateway {
  calls = 0;

  constructor(private readonly outcomes: Array<'timeout' | '503' | 'ok'>) {}

  async authorize() {
    const outcome = this.outcomes[this.calls++] ?? 'ok';
    if (outcome === 'timeout') throw new TimeoutError();
    if (outcome === '503') throw new TransientProviderError();
    return { paymentId: `pay-${this.calls}` };
  }
}

it('retry một lần khi provider tạm lỗi rồi thành công', async () => {
  const gateway = new ScriptedPaymentGateway(['503', 'ok']);
  const resilient = new RetryingPaymentGateway(gateway, {
    sleep: async () => {},
    maxAttempts: 2,
  });

  await expect(resilient.authorize(validPayment)).resolves.toBeDefined();
  expect(gateway.calls).toBe(2);
});

Thêm một smoke test ít tần suất với sandbox thật để phát hiện credential/SDK/provider contract, nhưng không để nó chặn mọi PR nếu provider ngoài quyền kiểm soát.

Nếu cần kiểm HTTP client thay vì port, dùng Nock/MSW/Undici MockAgent và assert request method/header/body. Reset interceptor sau mỗi test để không rò state.


Concurrency và idempotency phải được test đồng thời

Test tuần tự không bắt lost update hoặc double side effect.

it('hai request cùng idempotency key chỉ tạo một order', async () => {
  const app = createTestApp(pool);

  const send = () =>
    request(app)
      .post('/orders')
      .set('Idempotency-Key', 'same-key')
      .send(validOrderPayload);

  const [first, second] = await Promise.all([send(), send()]);

  expect([first.status, second.status].sort()).toEqual([200, 201]);

  const count = await pool.query(
    'SELECT count(*)::int AS count FROM orders WHERE idempotency_key = $1',
    ['same-key']
  );
  expect(count.rows[0].count).toBe(1);
  expect(first.body.id).toBe(second.body.id);
});

Để tăng khả năng tái hiện race, đặt barrier/hook có kiểm soát giữa read và write trong test hoặc chạy nhiều iteration với seed lưu lại. Promise.all một lần không chứng minh race đã được kích hoạt.

Các case cần có:

  • duplicate request cùng lúc;
  • worker crash sau side effect nhưng trước ack;
  • optimistic version conflict;
  • serialization failure/deadlock và retry;
  • message đến sai thứ tự hoặc lặp.

Failure-path test và recovery test

Happy path thường không phải nơi outage bắt đầu. Tạo failure có chủ đích:

FailureĐiều cần assert
DB timeoutrequest dừng trong budget, error code đúng, không retry vô hạn
Redis downfallback hoặc fail mode đúng, metric degraded tăng
Queue publish lỗitransaction/outbox không mất intent
SIGTERMngừng traffic mới, request/job đang chạy được drain
Provider 429tôn trọng retry policy/budget, không tạo retry storm
Partial migrationcả code cũ và mới vẫn chạy

Đo side effect và telemetry, không chỉ response. Một test timeout pass nhưng không kiểm timer/socket cleanup vẫn có thể để leak.


Migration compatibility test

Với expand–contract, pipeline nên kiểm ma trận:

old code + expanded schema  → pass
new code + expanded schema  → pass
backfill resumable          → pass
new code + contracted schema→ pass chỉ ở release cuối

Khởi tạo database từ snapshot/schema production representative, chạy migration, kiểm constraint/index và dữ liệu. Test migration trên database rỗng bỏ lỡ lock time, null cũ và backfill volume.


Coverage, mutation và test quality

Coverage trả lời “dòng/branch nào đã chạy”, không trả lời assertion có giá trị không.

Sử dụng coverage như:

  • guardrail cho module quan trọng và code mới;
  • chỉ báo branch chưa được exercise;
  • input cho review, không phải KPI cá nhân/team.

Không áp 80% toàn repo một cách mù quáng: generated code và adapter mỏng khác domain pricing. Đặt threshold theo rủi ro.

Mutation testing đổi toán tử/condition rồi chạy test. Mutant sống sót cho thấy test không phân biệt implementation đúng/sai. Chạy cho domain module hoặc nightly vì chi phí cao, không nhất thiết mọi PR.


Flaky test là production incident của pipeline

Flake rate dù 1% sẽ rất lớn khi pipeline có hàng nghìn test. Chính sách cần rõ:

  1. Lưu seed, order, worker, duration và attempt.
  2. Rerun để phân loại, không biến rerun-pass thành xanh im lặng.
  3. Quarantine có owner, ticket và deadline; không để suite chính bị block vô hạn.
  4. Sửa nguồn nondeterminism: clock, random, port, state, race, external network.
  5. Theo dõi flake rate theo test/file/owner và xóa test không còn giá trị.

Failure message nên chứa input tối thiểu, seed, expected/actual và correlation id; đừng buộc người sửa đọc 10.000 dòng log.

CI tiers và feedback budget

PR (< 10 phút)
  lint + typecheck + unit + selected integration + contract diff

merge/main
  full integration + migration + image smoke

pre-production/canary
  E2E critical journeys + performance guardrail + recovery smoke

nightly
  mutation + property run lớn + compatibility matrix + security scan

Sharding chỉ giúp khi test độc lập. Cache dependency giúp setup, nhưng không cache kết quả test dựa trên input không đầy đủ. Theo dõi p50/p95 duration và critical path của pipeline.

Test telemetry

Dashboard suite nên có:

  • duration và queue time;
  • pass/fail/flake/rerun;
  • top test chậm;
  • failure theo boundary và owner;
  • container startup/migration time;
  • mutation score/coverage trend cho module quan trọng.

Mục tiêu là rút ngắn thời gian từ lỗi tới chẩn đoán, không chỉ tổng số test.

Checklist chiến lược kiểm thử

  • Có risk map liên kết invariant/failure mode với tầng test.
  • Unit test assert hành vi, không khóa private implementation.
  • Database/cache/queue adapter có integration test với service thật.
  • State isolation đúng khi test chạy song song.
  • HTTP và event contract có compatibility gate.
  • Retry, timeout, idempotency và concurrency có test đường lỗi.
  • Migration kiểm cả old/new code và dữ liệu cũ.
  • Coverage theo rủi ro; mutation test tập trung vào domain.
  • Flake có metric, owner, quarantine deadline và root-cause policy.
  • CI tiers giữ feedback nhanh nhưng không bỏ boundary quan trọng.

Lab: test portfolio cho Ordering Service

Yêu cầu

  1. Lập risk map tối thiểu 10 failure mode và chọn tầng test.
  2. Viết unit/property test cho order state machine và money invariant.
  3. Dùng Testcontainers Postgres; chạy migration và test constraint/query thật.
  4. Dùng Supertest kiểm create/confirm order, auth, validation và error contract.
  5. Test hai request đồng thời cùng idempotency key.
  6. Test payment timeout/503/decline, queue publish lỗi và graceful shutdown.
  7. Thêm OpenAPI/event compatibility check, coverage và mutation job.

Acceptance criteria

  • Suite PR hoàn tất trong feedback budget đã đặt và chạy lặp 50 lần không flake.
  • Fake repository và Postgres adapter cùng vượt một contract-test suite.
  • Concurrent duplicate chỉ tạo một order và một payment intent.
  • Provider timeout không vượt request budget và không để active timer/socket.
  • Schema/event breaking change bị pipeline chặn trước deploy.
  • Old code + expanded schema và new code + expanded schema đều pass.
  • Test fail hiển thị seed, boundary, input tối thiểu và owner đủ để chẩn đoán.

Tài liệu chính thức


Phần tiếp theo

Test portfolio tạo ra ranh giới có thể kiểm chứng. Phần 10 dùng những ranh giới đó để thiết kế kiến trúc có khả năng tiến hóa: modular monolith, bounded context, dependency rule, ADR và fitness function; chỉ đưa workflow sang outbox, saga hoặc service riêng khi constraint thực sự yêu cầu.