Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang làm QA cho một sàn thương mại điện tử kiểu Tiki hay Shopee. Hệ thống không còn là một khối monolith duy nhất nữa, mà đã tách thành hàng chục microservices: service giỏ hàng, service thanh toán, service kho, service khuyến mãi, service người dùng... Mỗi service do một team riêng phát triển, deploy độc lập nhiều lần mỗi ngày.
Rồi một ngày đẹp trời, team Payment sửa API: họ đổi tên trường amount thành total_amount trong response, hoặc chuyển kiểu dữ liệu của order_id từ số nguyên sang chuỗi. Với họ, đây là một thay đổi nhỏ, tests của họ vẫn xanh 100%. Nhưng team Checkout — bên đang gọi API đó — không hề biết. Đến khi lên production, giỏ hàng của khách bỗng dưng báo lỗi khi bấm thanh toán. Cả team QA nháo nhào tìm nguyên nhân giữa đêm.
Đây là nỗi đau kinh điển của kiến trúc microservices: integration hidden bug — lỗi tích hợp ẩn giữa các service. Cách cũ để bắt lỗi này là chạy end-to-end integration test: dựng cả hệ thống lên, cho các service gọi nhau thật. Nhưng cách này chậm, giòn (flaky), tốn hạ tầng, và khó biết chính xác service nào phá vỡ hợp đồng.
Contract Testing — mà công cụ tiêu biểu là Pact — sinh ra để giải quyết đúng vấn đề này. Nó cho phép bạn bắt lỗi "provider thay đổi làm consumer vỡ" sớm, nhanh, ngay trong CI của từng team, mà không cần dựng cả hệ thống. Đây là một kỹ năng ngày càng bắt buộc với SDET trong các công ty có kiến trúc phân tán — và ở Việt Nam, hầu hết các fintech, ví điện tử, sàn TMĐT lớn đều đã đi theo hướng microservices.
Khái niệm cốt lõi
Consumer và Provider
Trong contract testing, ta luôn nói về hai vai:
- Consumer (bên tiêu thụ): service gọi API. Ví dụ: Frontend gọi API Order, hay service Checkout gọi service Payment.
- Provider (bên cung cấp): service được gọi, tức bên cung cấp API. Ví dụ: service Payment.
Contract (hợp đồng) là gì?
Contract là một tài liệu ghi lại chính xác kỳ vọng của consumer về provider: "Khi tôi gửi request thế này, tôi mong nhận response thế kia." Ví dụ:
> Khi Checkout gửi GET /payments/12345, nó mong nhận về HTTP 200 với body chứa trường order_id (string), amount (number), và status (là một trong "pending", "paid", "failed").
Điểm mấu chốt phân biệt Pact với các cách khác: contract được sinh ra TỪ phía consumer, dựa trên những gì consumer thực sự dùng. Đây gọi là consumer-driven contract testing. Consumer không quan tâm provider trả về 50 trường; nó chỉ quan tâm 3 trường nó dùng. Contract chỉ ghi lại 3 trường đó.
Luồng hoạt động của Pact
Pact chia quá trình thành hai giai đoạn tách biệt, chạy ở hai phía khác nhau:
1. Phía Consumer — sinh ra contract: Bạn viết một unit test cho code gọi API của consumer. Nhưng thay vì gọi provider thật, bạn dùng một mock provider do Pact dựng lên. Bạn khai báo: "Với state X, khi tôi gọi request Y, hãy trả về response Z." Consumer code chạy thật, gọi vào mock. Nếu code consumer xử lý được response đó, test pass — và Pact ghi lại toàn bộ tương tác này thành một file JSON gọi là pact file (chính là contract).
2. Phía Provider — xác minh contract:
File pact được chuyển sang phía provider (qua một trung tâm gọi là Pact Broker, hoặc chia sẻ trực tiếp). Pact đọc từng tương tác trong contract, replay request đó vào provider thật, và kiểm tra response thực tế của provider có khớp với kỳ vọng trong contract không. Nếu provider đã đổi amount thành total_amount, bước verify này sẽ đỏ ngay — báo cho team provider biết họ đang phá vỡ hợp đồng với consumer.
Pact Broker — trái tim của quy trình
Vì consumer và provider ở hai repo, hai team, hai pipeline khác nhau, ta cần một nơi trung gian lưu trữ và chia sẻ các pact file, theo dõi phiên bản, và trả lời câu hỏi sống còn: "Phiên bản này của tôi có an toàn để deploy lên production không?" Đó là vai trò của Pact Broker (bản thương mại là PactFlow). Nó lưu lịch sử contract, kết quả verify, và cung cấp lệnh can-i-deploy — một cổng kiểm soát trước khi release.
Khác biệt so với schema/OpenAPI
Nhiều bạn hỏi: "Đã có OpenAPI/Swagger rồi, cần gì Pact?" Khác biệt lớn: OpenAPI mô tả provider hứa cung cấp gì (provider-driven, tĩnh). Pact ghi lại consumer thực sự dùng gì (consumer-driven, kiểm chứng bằng code chạy thật). Pact bắt được trường hợp provider đổi thứ mà consumer đang phụ thuộc — điều OpenAPI một mình không đảm bảo. Hai công cụ bổ sung cho nhau, không thay thế.
Tình huống thực tế
Ví dụ 1 — Ví điện tử: sự cố "status" âm thầm
Một ví điện tử tại TP.HCM (giả định, tương tự MoMo về quy mô) có service wallet-core (provider) và service notification (consumer). Service notification lắng nghe kết quả giao dịch để gửi thông báo cho người dùng.
Contract của notification ghi: field status trả về một trong ["SUCCESS", "FAILED", "PENDING"]. Một sprint nọ, team wallet-core thêm trạng thái mới "REVERSED" (giao dịch bị hoàn) và... đổi luôn "SUCCESS" thành "COMPLETED" cho "gọn". Tests nội bộ của họ xanh. Nhưng khi CI chạy Pact verification với contract của notification, bước verify đỏ ngay lập tức: provider trả "COMPLETED" nhưng consumer kỳ vọng "SUCCESS".
Bài học: Lỗi bị chặn ngay trong pipeline của provider, trước khi merge. Team wallet-core thấy rõ "notification đang phụ thuộc vào giá trị SUCCESS", nên họ giữ nguyên tên cũ và chỉ thêm giá trị mới. Không có sự cố production, không có cuộc gọi lúc nửa đêm. Trước khi có Pact, đội này mất trung bình 2-3 giờ điều tra mỗi lần lỗi tích hợp kiểu này lọt ra staging.
Ví dụ 2 — Sàn TMĐT: contract test thay thế 40 phút E2E
Một sàn TMĐT khu vực Đông Nam Á (kiểu Lazada) có suite end-to-end kiểm tra tích hợp giữa 12 service, chạy mất 40 phút và flaky ~15% (cứ 7 lần chạy thì hỏng 1 lần vì lý do hạ tầng, không phải lỗi thật). Đội QA rất mệt vì phải rerun liên tục.
Họ áp dụng chiến lược: thay phần lớn kiểm tra "service A gọi service B có đúng không" bằng Pact. Mỗi cặp consumer-provider có contract riêng. Kết quả: contract test chạy dưới 2 phút mỗi service, ổn định gần như 100% vì không cần dựng cả hệ thống. Họ vẫn giữ lại một số ít E2E "happy path" quan trọng nhất cho luồng đặt hàng, nhưng cắt được ~70% số ca E2E.
Bài học: Contract testing không thay thế hoàn toàn E2E, nhưng nó dịch chuyển phần lớn việc kiểm tra tích hợp xuống tầng nhanh và rẻ hơn — đúng tinh thần của test pyramid. Feedback loop từ 40 phút rút còn vài phút.
Ví dụ 3 — Cái bẫy "over-specification"
Một team fintech mới học Pact đã mắc lỗi kinh điển: khi viết contract, họ khai báo response phải khớp chính xác cả created_at: "2024-01-15T10:30:00Z" và một transaction_id cụ thể. Kết quả: mỗi lần provider verify, response thật có timestamp khác, id khác — và verify đỏ liên tục dù chẳng có lỗi thật nào.
Team tưởng Pact "dở", suýt bỏ. Sau khi được mentor chỉ, họ dùng matchers (so khớp theo kiểu/định dạng thay vì giá trị tuyệt đối): like() cho "miễn là một số", term() cho "khớp regex ISO 8601". Contract lập tức ổn định.
Bài học: Contract test nên kiểm tra hình dạng và kiểu dữ liệu (structure & type), không phải giá trị cụ thể. Đây là nguyên tắc số một để tránh contract giòn.
Hướng dẫn từng bước
Ta minh họa bằng JavaScript (Pact JS) cho consumer là service Checkout gọi provider Payment. Tư duy này áp dụng y hệt cho Pact Java, Python, .NET.
Bước 1 — Cài đặt phía consumer
npm install --save-dev @pact-foundation/pact
Bước 2 — Viết consumer test sinh contract
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, term } = MatchersV3;
const { getPayment } = require('../src/paymentClient');const provider = new PactV3({
consumer: 'CheckoutService',
provider: 'PaymentService',
});
describe('Checkout gọi Payment', () => {
it('lấy được thông tin thanh toán', () => {
provider
.given('payment 12345 tồn tại') // provider state
.uponReceiving('yêu cầu lấy payment 12345')
.withRequest({ method: 'GET', path: '/payments/12345' })
.willRespondWith({
status: 200,
body: {
order_id: like('12345'), // miễn là string
amount: like(150000), // miễn là number
status: term({ // khớp regex
matcher: 'pending|paid|failed',
generate: 'paid',
}),
},
});
return provider.executeTest(async (mockServer) => {
const res = await getPayment(mockServer.url, '12345');
expect(res.status).toEqual('paid');
});
});
});
Chạy test này, Pact sinh ra file pacts/CheckoutService-PaymentService.json.
Bước 3 — Đẩy contract lên Pact Broker
pact-broker publish ./pacts \
--broker-base-url=$BROKER_URL \
--consumer-app-version=$GIT_SHA \
--branch=$GIT_BRANCH
Bước 4 — Provider verify contract
Trong pipeline của Payment, viết một test xác minh. Ở đây bạn phải xử lý provider states: khi Pact nói "given payment 12345 tồn tại", bạn cần một hook để chuẩn bị dữ liệu (seed DB, mock repo) sao cho endpoint trả về đúng.
const { Verifier } = require('@pact-foundation/pact');new Verifier({
provider: 'PaymentService',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.BROKER_URL,
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
stateHandlers: {
'payment 12345 tồn tại': async () => {
await seedPayment({ id: '12345', amount: 150000, status: 'paid' });
},
},
}).verifyProvider();
Bước 5 — Cổng can-i-deploy trước khi release
Trước khi deploy consumer hoặc provider lên production, chạy:
pact-broker can-i-deploy \
--pacticipant CheckoutService \
--version $GIT_SHA \
--to-environment production
Lệnh này trả lời: "Với phiên bản Checkout này, tất cả provider mà nó phụ thuộc đã verify thành công contract chưa?" Nếu chưa → chặn deploy. Đây chính là giá trị lớn nhất của Pact: biến "hy vọng nó không vỡ" thành "biết chắc nó không vỡ".
Lỗi thường gặp & mẹo
- Over-specification (giòn nhất): như ví dụ 3, đừng hardcode timestamp, UUID, giá trị động. Luôn dùng matchers (
like,term,eachLike) để kiểm tra kiểu và cấu trúc, không kiểm tra giá trị tuyệt đối.
- Quên xử lý provider states: nếu contract có
given('user tồn tại')mà provider không cóstateHandlertương ứng để seed dữ liệu, verify sẽ trả 404 và đỏ oan. Mỗi provider state cần một hook chuẩn bị dữ liệu.
- Lầm tưởng Pact test cả logic nghiệp vụ: Pact chỉ kiểm tra hình dạng của tương tác (request/response shape). Nó KHÔNG kiểm tra "amount tính đúng chưa", "giảm giá áp đúng chưa". Đó là việc của unit/functional test. Đừng nhồi logic nghiệp vụ vào contract.
- Dùng Pact cho API bên thứ ba bạn không kiểm soát: Pact yêu cầu bạn verify được ở phía provider. Với API của bên ngoài (VNPay, Google...) bạn không chạy được verification trên hệ thống họ, nên Pact không phù hợp — hãy dùng service virtualization/mocking thay thế.
- Bỏ qua Pact Broker, chia sẻ file thủ công: với 2 service có thể tạm ổn, nhưng khi lên chục service, việc theo dõi phiên bản và
can-i-deploylà bất khả thi nếu không có Broker. Đầu tư Broker sớm.
- Mẹo versioning: luôn gắn contract với Git SHA và branch. Điều này giúp Broker biết chính xác phiên bản nào tương thích phiên bản nào — nền tảng cho can-i-deploy hoạt động đúng.
- Mẹo tích hợp CI: đặt bước
pact publishvào pipeline consumer,pact verify+publish resultvào pipeline provider, vàcan-i-deploylàm cổng chặn trước bước deploy. Tự động hóa hoàn toàn, không làm thủ công.
Bài tập thực hành
- Dựng cặp consumer-provider tối giản: Viết một provider đơn giản (Express/Flask) có endpoint
GET /users/{id}trả về{ id, name, email }. Viết một consumer gọi endpoint này.
- Sinh contract: Viết Pact consumer test với matchers (
likecho id,termvới regex email cho trường email). Chạy và xác nhận file pact JSON được sinh ra. Mở file ra đọc để hiểu cấu trúc.
- Verify phía provider: Viết provider verification test với một
stateHandlerđể seed user. Chạy verify và xác nhận nó xanh.
- Cố tình phá hợp đồng: Đổi tên trường
emailthànhemail_addressở provider. Chạy lại verify. Quan sát Pact báo đỏ và đọc kỹ thông báo lỗi — nó chỉ chính xác trường nào lệch. Đây là khoảnh khắc "aha" của contract testing.
- (Nâng cao) Dựng Pact Broker bằng Docker (
pactfoundation/pact-broker), publish contract lên, rồi thử lệnhcan-i-deploy. Quan sát nó chặn/cho phép thế nào khi provider chưa verify.
Tóm tắt
- Contract testing giải quyết nỗi đau lớn nhất của microservices: provider thay đổi âm thầm làm consumer vỡ ở production. Pact bắt lỗi này sớm, nhanh, ngay trong CI từng team, không cần dựng cả hệ thống.
- Luôn phân biệt consumer (bên gọi) và provider (bên được gọi). Contract được sinh từ phía consumer, dựa trên những gì consumer thực sự dùng — đó là consumer-driven.
- Quy trình hai giai đoạn: consumer sinh pact file bằng mock; provider replay và verify contract đó vào code thật. Pact Broker kết nối hai phía và cung cấp cổng can-i-deploy.
- Contract kiểm tra hình dạng và kiểu dữ liệu, không kiểm tra giá trị cụ thể hay logic nghiệp vụ. Dùng matchers để tránh contract giòn.
- Pact bổ sung chứ không thay thế E2E; nó dịch phần lớn kiểm tra tích hợp xuống tầng nhanh và rẻ. Không dùng Pact cho API bên thứ ba mà bạn không verify được.