Kiểm thử Hợp đồng Hướng Phía Tiêu thụ với Pact và TypeScript: Hướng dẫn Thực hành Toàn diện

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bối cảnh & Lý do: Loại bỏ Xung đột Tích hợp API

Kiến trúc microservices mang lại cho các đội ngũ kỹ thuật sự tự chủ và tốc độ phát triển nhanh chóng, nhưng việc tích hợp qua lại giữa các service lại thường trở thành một bãi mìn rủi ro. Các ứng dụng frontend có thể bị gãy bất ngờ khi kỹ sư backend đổi tên trường userId thành id, thay đổi mã phản hồi từ 200 OK sang 204 No Content, hoặc xóa bỏ các trường không còn dùng (deprecated) trong các đợt phát hành sprint.

Các nhóm thường dựa vào hai chiến lược kiểm thử nhưng đều có những nhược điểm lớn:

  • Unit test phụ thuộc nhiều vào mock: Chạy nhanh và gần như không tốn chi phí. Tuy nhiên, các mock tĩnh ở frontend sẽ bị lệch chuẩn so với API thực tế chỉ sau vài ngày, bởi không có cơ chế nào xác thực chúng với hợp đồng service thực tế.
  • Bộ kiểm thử End-to-End (E2E) trên môi trường staging: Chậm chạp, dễ gãy (flaky) và tốn kém chi phí bảo trì. Khi một bài test E2E thất bại trên staging, các kỹ sư thường phải mất từ 3 đến 4 giờ chỉ để truy vết các vấn đề race condition và dữ liệu test không chuẩn xác trên nhiều downstream service liên quan.

Consumer-Driven Contract Testing (CDCT – Kiểm thử Hợp đồng Hướng Phía Tiêu thụ) giải quyết triệt để bài toán này. Thay vì phỏng đoán schema của API hoặc phải chờ đợi môi trường staging, phía consumer (chẳng hạn như client React hoặc Node.js) sẽ chuẩn hóa chính xác các mẫu request và cấu trúc response mà nó mong đợi. Nó lưu các kỳ vọng này vào một tệp hợp đồng (gọi là Pact). Phía backend provider sau đó sẽ lấy hợp đồng này về và độc lập xác minh các endpoint thực tế của mình đối chiếu với các quy tắc đó.

Khi triển khai mô hình này trên 12 microservices, chúng tôi đã giảm được 80% sự cố tích hợp trên staging. Chúng tôi không còn phải tổ chức các buổi họp release đồng bộ đầy rủi ro mà đã tự tin nâng quy mô lên hơn 5 lượt deploy độc lập lên production mỗi ngày.

Cài đặt: Thiết lập Pact trong Dự án TypeScript

Pact Foundation cung cấp thư viện hỗ trợ native cho TypeScript thông qua @pact-foundation/pact. Hãy cùng cấu hình bộ kiểm thử phía consumer với TypeScript và Jest.

Khởi tạo workspace và cài đặt các dependency cần thiết:

npm init -y
npm install --save-dev typescript jest ts-jest @types/jest @pact-foundation/pact axios
npx ts-jest config:init

Thêm lệnh kiểm thử riêng biệt vào tệp package.json của bạn:

{
  "scripts": {
    "test:pact": "jest --testMatch='**/*.pact.test.ts' --runInBand"
  }
}

Vì sao lại cần --runInBand? Pact sẽ khởi chạy một mock HTTP server gọn nhẹ cho từng test suite. Việc chạy các bài test tuần tự sẽ giúp ngăn ngừa xung đột cổng (port collision) và race condition trên localhost.

Cấu hình: Định nghĩa Kỳ vọng Phía Consumer và Sinh tệp Pact

Quá trình kiểm thử hợp đồng bắt đầu từ phía client. Bài test của bạn sẽ thực thi API client thực tế đối chiếu với một mock server do Pact điều phối. Sau khi mọi assertion đều vượt qua, Pact sẽ serialize tương tác đó thành một tệp hợp đồng JSON.

1. API Client Phía Consumer

Dưới đây là một HTTP client có định kiểu dùng để lấy thông tin chi tiết của người dùng từ backend service:

import axios from 'axios';

export interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

export class UserApiClient {
  constructor(private baseUrl: string) {}

  async getUser(id: string): Promise<User> {
    const response = await axios.get<User>(`${this.baseUrl}/users/${id}`);
    return response.data;
  }
}

2. Viết Assertions Linh hoạt Phía Consumer

Hãy tránh việc kiểm tra giá trị cứng nhắc, cố định (hardcoded). Nếu bạn assert một chuỗi chính xác như 'John Doe', bài test phía backend sẽ bị lỗi bất cứ khi nào dữ liệu mock database thay đổi. Thay vào đó, hãy sử dụng các Pact V3 matcher—chúng xác thực kiểu dữ liệu, định dạng và mẫu regex, giúp dữ liệu có sự biến động tự nhiên mà không gây báo động giả.

import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { UserApiClient } from './userApiClient';

const { like, email, regex } = MatchersV3;

const provider = new PactV3({
  consumer: 'FrontendWebApp',
  provider: 'UserService',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('UserApiClient Contract Tests', () => {
  it('lấy thành công hồ sơ người dùng hiện có', async () => {
    provider
      .given('tồn tại người dùng có ID 101')
      .uponReceiving('một yêu cầu GET cho người dùng 101')
      .withRequest({
        method: 'GET',
        path: '/users/101',
      })
      .willRespondWith({
        status: 200,
        headers: {
          'Content-Type': 'application/json; charset=utf-8',
        },
        body: {
          id: regex('^\\d+$', '101'),
          name: like('John Doe'),
          email: email('[email protected]'),
          role: like('ADMIN'),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const client = new UserApiClient(mockServer.url);
      const user = await client.getUser('101');

      expect(user.id).toBe('101');
      expect(user.name).toBe('John Doe');
      expect(user.role).toBe('ADMIN');
    });
  });
});

Chạy lệnh npm run test:pact sẽ biên dịch test suite của bạn và ghi tệp pacts/FrontendWebApp-UserService.json vào ổ đĩa.

Xác minh & Giám sát: Kiểm thử Phía Provider và Tự động hóa CI/CD

Một hợp đồng chỉ thực sự có giá trị nếu phía provider liên tục kiểm tra tính hợp lệ của nó. Trong repository của backend, chúng ta cấu hình một verification suite để chạy lại các tương tác đã ghi nhận từ consumer trực tiếp lên các route handler thực tế.

1. Script Xác minh Phía Provider

Bài test này sẽ khởi động backend server và yêu cầu Pact phát lại toàn bộ payload của hợp đồng đối chiếu với server đó:

import path from 'path';
import { Verifier } from '@pact-foundation/pact';
import { startServer, stopServer } from '../src/server';

describe('Pact Provider Verification', () => {
  const PORT = 8080;
  let server: any;

  beforeAll(async () => {
    server = await startServer(PORT);
  });

  afterAll(async () => {
    await stopServer(server);
  });

  it('xác thực các yêu cầu đối chiếu với hợp đồng consumer', async () => {
    const verifier = new Verifier({
      provider: 'UserService',
      providerBaseUrl: `http://localhost:${PORT}`,
      pactUrls: [
        path.resolve(process.cwd(), '../frontend/pacts/FrontendWebApp-UserService.json'),
      ],
      stateHandlers: {
        'tồn tại người dùng có ID 101': async () => {
          // Khởi tạo dữ liệu người dùng 101 tạm thời cho bài test
          return Promise.resolve('Cơ sở dữ liệu đã được khởi tạo dữ liệu mẫu với người dùng 101');
        },
      },
    });

    const output = await verifier.verifyProvider();
    console.log('Quá trình xác minh Pact hoàn tất:', output);
  });
});

2. Tự động hóa Kiểm tra Hợp đồng với Pact Broker và CI/CD

Việc chia sẻ các tệp JSON thủ công qua pull request hoặc bộ nhớ dùng chung sẽ sớm bộc lộ nhiều bất cập. Một hệ thống Pact Broker chuyên dụng (hoặc phiên bản hosted trên PactFlow) sẽ đóng vai trò là kho đăng ký hợp đồng và theo dõi trạng thái tập trung.

Một pipeline tự động hóa mạnh mẽ sẽ vận hành qua ba giai đoạn riêng biệt:

  1. Frontend CI: Chạy các bài test consumer, sinh ra hợp đồng, và xuất bản chúng lên broker kèm gắn tag theo mã commit SHA cùng tên nhánh git.
  2. Backend CI: Lấy các hợp đồng consumer đã xuất bản từ broker, kiểm tra đối chiếu với bản build backend mục tiêu, và đẩy kết quả xác minh ngược lại registry.
  3. Chốt chặn Triển khai (can-i-deploy): Truy vấn broker trước bất kỳ đợt deploy nào lên production để đảm bảo tính tương thích phiên bản tuyệt đối giữa consumer và provider.
# Kiểm tra xem FrontendWebApp đã an toàn để triển khai lên production hay chưa
npx pact-broker can-i-deploy \
  --pacticipant FrontendWebApp \
  --version $GIT_COMMIT_SHA \
  --to-environment production \
  --broker-base-url https://your-broker.pactflow.io \
  --broker-token $PACT_BROKER_TOKEN

3. Kinh nghiệm Thực chiến Dành cho các Đội ngũ Production

  • Chỉ assert trên các trường thực sự sử dụng: Nếu frontend chỉ hiển thị name và email, hãy loại bỏ created_at và các metadata nội bộ ra khỏi hợp đồng. Điều này cho phép đội ngũ backend thoải mái tái cấu trúc dữ liệu nội bộ mà không làm gãy hợp đồng.
  • Cô lập trạng thái provider một cách linh hoạt: Sử dụng stateHandlers để nạp dữ liệu tạm thời vào cơ sở dữ liệu test ngay trước khi mỗi tương tác chạy, thay vì phụ thuộc vào các database tĩnh được nạp sẵn rất dễ gây lỗi.
  • Kích hoạt xác minh tự động bằng webhook: Thiết lập webhook trên Pact Broker để mỗi khi một PR phía frontend xuất bản hợp đồng mới, nó sẽ tự động kích hoạt một bản build xác minh provider trong repository của backend.
Share: