Ngừng làm hỏng API: Hướng dẫn thực hành Consumer-Driven Contract Testing với Pact

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

Buổi chiều thứ Sáu khiến tôi mất trắng hai ngày cuối tuần

Ba năm trước, một đợt dọn dẹp API “đơn giản” đã lấy đi của tôi toàn bộ kỳ nghỉ cuối tuần. Nhóm của tôi lúc đó đang quản lý khoảng 25 microservices cho một nền tảng thương mại điện tử có lưu lượng truy cập cao. Tôi có nhiệm vụ xóa hai trường không còn sử dụng khỏi User Service. Tôi đã kiểm tra tài liệu nội bộ, chạy các bài unit test và mọi thứ đều xanh (pass). Tôi đẩy code lên production vào lúc 4 giờ chiều và bắt đầu thu dọn đồ đạc để về.

Đến 4 giờ 30 chiều, Checkout service bị sập. Mặc dù Checkout service không sử dụng các trường đó cho logic của nó, nhưng bộ phân tích dữ liệu cũ (legacy data parser) của nó được cấu hình để báo lỗi nếu các trường đó bị thiếu. Chỉ bằng cách xóa một chuỗi ký tự đơn lẻ đã “bị loại bỏ”, tôi đã vô tình làm tê liệt toàn bộ luồng thanh toán. Đây chính là định nghĩa của “Địa ngục tích hợp” (Integration Hell). Các dịch vụ riêng lẻ hoạt động hoàn hảo khi đứng độc lập nhưng lại thất bại ngay khi chúng giao tiếp với nhau.

Tại sao kiểm thử truyền thống thất bại trong các hệ thống phân tán

Trong kiến trúc Monolith (nguyên khối), trình biên dịch là lưới an toàn của bạn. Nếu bạn thay đổi chữ ký của một phương thức, mã nguồn đơn giản là sẽ không thể build được. Microservices không có được sự xa xỉ này vì chúng được tách rời thông qua mạng lưới. Hầu hết các nhóm cố gắng giải quyết vấn đề này bằng hai chiến lược phổ biến — nhưng đầy khiếm khuyết.

Bẫy kiểm thử E2E

Kiểm thử End-to-End (E2E) yêu cầu phải khởi chạy toàn bộ hệ sinh thái: Service A, Service B, cơ sở dữ liệu và cache. Mặc dù kỹ lưỡng, nhưng chúng cực kỳ chậm. Tôi đã từng thấy các bộ test E2E mất 45 phút để chạy, chỉ để rồi thất bại vì một sự cố mạng nhỏ hoặc một bản ghi cũ trong cơ sở dữ liệu. Khi một bài test E2E thất bại, việc tìm ra nguyên nhân gốc rễ giống như “mò kim đáy bể”.

Nút thắt cổ chai của thư viện dùng chung (Shared Library)

Một số nhóm chia sẻ các thư viện DTO (Data Transfer Object) giữa Producer (bên cung cấp) và Consumer (bên tiêu thụ). Điều này tạo ra một “monolith phân tán”. Nếu Producer cập nhật thư viện, mọi Consumer đều buộc phải cập nhật các phụ thuộc của họ ngay lập tức. Điều này phá hủy lợi ích chính của microservices: khả năng triển khai các dịch vụ một cách độc lập.

Tại sao nên chọn Consumer-Driven Contract Testing (CDCT)

Contract testing (kiểm thử hợp đồng) đảo ngược tình thế. Thay vì kiểm thử toàn bộ hệ thống, chúng ta tạo ra một thỏa thuận chính thức giữa hai dịch vụ. “Consumer” (bên gọi API) định nghĩa chính xác những gì nó cần. Nếu “Provider” (API) thực hiện một thay đổi vi phạm thỏa thuận này, quá trình build sẽ thất bại trước khi mã nguồn kịp rời khỏi máy tính của nhà phát triển.

Pact đã trở thành công cụ hàng đầu cho cách tiếp cận này. Nó cho phép Consumer tạo ra một bản “hợp đồng” JSON mà Provider phải xác minh dựa trên triển khai thực tế của mình. Làm chủ được kỹ thuật này là một bước tiến lớn để chuyển từ vai trò junior lên một kỹ sư senior có khả năng quản lý các kiến trúc phân tán phức tạp.

Triển khai Pact: Hướng dẫn thực hành chi tiết

Chúng ta sẽ sử dụng Node.js cho ví dụ này, mặc dù Pact hoạt động tốt tương đương với Java, Python hoặc Go. Hãy tưởng tượng chúng ta có một Order-Service (Consumer) và một Product-Service (Provider).

Bước 1: Phía Consumer (Định nghĩa các yêu cầu)

Consumer viết một bài test mô tả sự tương tác mà nó mong đợi. Chúng ta sử dụng thư viện Pact để giả lập (mock) Provider trong giai đoạn này.

const { Pact } = require('@pact-foundation/pact');
const path = require('path');

const provider = new Pact({
  consumer: 'OrderService',
  provider: 'ProductService',
  port: 1234,
  log: path.resolve(process.cwd(), 'logs', 'pact.log'),
  dir: path.resolve(process.cwd(), 'pacts'),
  spec: 2
});

describe('Pact với ProductService', () => {
  beforeAll(() => provider.setup());
  afterEach(() => provider.verify());
  afterAll(() => provider.finalize());

  it('nên trả về một sản phẩm khi cung cấp một ID', async () => {
    await provider.addInteraction({
      state: 'sản phẩm với ID 10 tồn tại',
      uponReceiving: 'một yêu cầu cho sản phẩm 10',
      withRequest: {
        method: 'GET',
        path: '/products/10'
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: '10',
          name: 'Bàn phím cơ',
          price: 150
        }
      }
    });

    const result = await fetchProduct('10'); 
    expect(result.name).toEqual('Bàn phím cơ');
  });
});

Khi bài test này chạy, Pact đóng vai trò như một mock server. Nếu bài test vượt qua, Pact sẽ tạo ra một tệp JSON trong thư mục /pacts. Đây chính là hợp đồng chính thức của bạn.

Bước 2: Phía Provider (Xác minh hợp đồng)

Nhóm phát triển Product-Service lấy tệp JSON đó và chạy nó đối với dịch vụ của họ. Họ không cần phải viết các mock phức tạp. Pact Verifier chỉ đơn giản là phát lại các yêu cầu từ hợp đồng đối với Provider đang hoạt động thực tế.

const { Verifier } = require('@pact-foundation/pact');

describe('Xác minh Pact', () => {
  it('nên xác thực các mong đợi của OrderService', () => {
    const opts = {
      provider: 'ProductService',
      providerBaseUrl: 'http://localhost:8080',
      pactUrls: [path.resolve(process.cwd(), './pacts/orderservice-productservice.json')],
      stateHandlers: {
        'sản phẩm với ID 10 tồn tại': () => {
          // Nạp dữ liệu mẫu cho cơ sở dữ liệu kiểm thử với sản phẩm 10
          return Promise.resolve('Dữ liệu đã được nạp');
        }
      }
    };

    return new Verifier().verifyProvider(opts);
  });
});

Nếu một nhà phát triển đổi tên name thành productName, bài test này sẽ thất bại ngay lập tức. Họ sẽ thấy lỗi trước khi code được commit.

Mắt xích còn thiếu: Pact Broker

Việc gửi tệp JSON qua email giữa các nhóm là con đường dẫn đến thảm họa. Trong một quy trình CI/CD chuyên nghiệp, bạn sử dụng Pact Broker. Đây là một trung tâm lưu trữ, nơi Consumer tải lên các hợp đồng và Provider tải chúng xuống để xác minh.

Tôi thực sự khuyên bạn nên sử dụng công cụ can-i-deploy. Bạn có thể thêm một dòng lệnh duy nhất vào script triển khai của mình: pact-broker can-i-deploy --pacticipant OrderService --version $GIT_COMMIT --to prod. Lệnh này sẽ kiểm tra ma trận của Broker để xem phiên bản cụ thể của bạn đã được xác minh với phiên bản hiện tại trên production của các đối tác hay chưa. Nếu việc xác minh thất bại, quá trình triển khai sẽ tự động dừng lại.

Các quy tắc để Contract Testing thành công

Sau khi triển khai Pact trên nhiều dự án quy mô lớn, tôi đã rút ra ba quy tắc giúp quá trình này diễn ra suê sẻ hơn:

  1. Chỉ kiểm thử những gì bạn tiêu thụ: Nếu Provider trả về 50 trường nhưng bạn chỉ cần 3, hãy chỉ định nghĩa 3 trường đó trong Pact. Điều này cho phép Provider thay đổi 47 trường còn lại mà không gây ra cảnh báo lỗi giả.
  2. Ưu tiên Matchers thay vì giá trị cố định: Đừng chỉ kiểm tra "price": 150. Hãy sử dụng các bộ khớp kiểu dữ liệu (type matchers) như Term.like(150). Điều này giúp các bài test của bạn bớt cứng nhắc hơn khi dữ liệu thay đổi.
  3. Đầu tư vào Quản lý Trạng thái (State Management): Các stateHandlers của bạn phải đáng tin cậy. Nếu một bài test mong đợi một “Người dùng có thẻ tín dụng hết hạn”, hãy đảm bảo Provider của bạn có thể thiết lập chính xác kịch bản đó trong môi trường kiểm thử một cách nhất quán.

Contract testing đòi hỏi sự đầu tư ban đầu vào việc thiết lập, nhưng đó là cách hiệu quả nhất để loại bỏ nỗi lo lắng khi triển khai. Bằng cách đưa việc kiểm tra tích hợp lên sớm hơn trong chu kỳ phát triển, bạn cho phép các microservices của mình tiến hóa nhanh chóng mà không sợ làm hỏng hệ thống.

Share: