Mở đầu — vì sao bài này quan trọng
Nếu bạn từng làm QA cho một sản phẩm thương mại điện tử hay fintech tại Việt Nam, bạn sẽ nhanh chóng nhận ra một sự thật: luồng thanh toán là nơi nguy hiểm nhất trong toàn hệ thống. Một bug ở màn hình đăng ký chỉ khiến người dùng bực mình; nhưng một bug ở luồng thanh toán có thể khiến khách hàng bị trừ tiền mà không nhận được hàng, hoặc tệ hơn — hệ thống ghi nhận "đã thanh toán" trong khi tiền chưa hề về tài khoản merchant. Đây là loại lỗi khiến kế toán khóc, CEO gọi điện lúc nửa đêm, và team QA bị hỏi "sao không test kỹ?".
Ở dự án 2 này, chúng ta sẽ xây dựng một bộ test tích hợp (integration test) thực chiến cho ba cổng thanh toán phổ biến nhất Việt Nam: VNPay, MoMo, và ZaloPay. Đây không phải bài lý thuyết về cách gọi API đơn lẻ — điều đó bạn đã học ở các bài trước. Ở đây, bạn sẽ học cách thiết kế một collection Postman có kiến trúc, cho phép test cùng một luồng nghiệp vụ ("tạo giao dịch → chuyển hướng thanh toán → nhận callback → đối soát kết quả") trên cả ba cổng, mà không phải viết lại code ba lần.
Điểm đặc biệt của payment testing so với test API thông thường là tính bất đối xứng của luồng: bạn gửi request đi, nhưng kết quả cuối cùng lại quay về qua một kênh khác (webhook/IPN — Instant Payment Notification), thường là bất đồng bộ. Bạn phải test được cả hai chiều, xử lý chữ ký số (signature/secure hash), và mô phỏng được các sandbox của từng nhà cung cấp. Đó là lý do payment integration test xứng đáng là một project riêng.
Khái niệm cốt lõi
Backend của bạn là "façade" — không test thẳng cổng thanh toán
Sai lầm phổ biến của người mới: cố gắng gọi thẳng API của VNPay/MoMo từ Postman. Trong thực tế, frontend và mobile app không bao giờ gọi trực tiếp cổng thanh toán, vì điều đó buộc phải lộ secret key ra client. Thay vào đó, kiến trúc chuẩn là:
Client → Backend (merchant server) → Payment Gateway
↑ ↓
└──── IPN/Callback ──────────┘
Backend của bạn đóng vai trò façade (mặt tiền thống nhất). Vì vậy, đối tượng test chính của chúng ta là API nội bộ của merchant, không phải API của cổng. Theo đúng ghi chú dàn ý, ta thiết kế một interface chung:
POST /payment/create
Body: { gateway, amount, orderId, orderInfo, returnUrl }
Response: { payUrl, transactionRef, gateway }
Trường gateway nhận giá trị vnpay | momo | zalopay. Backend chịu trách nhiệm dịch request chung này thành đúng format của từng cổng (ký secure hash, gọi endpoint sandbox tương ứng). Nhờ vậy, test của bạn chỉ cần đổi một biến gateway là chạy được cả ba cổng — đây chính là chỗ Postman collection variables và data-driven phát huy sức mạnh.
Ba trạng thái phải phân biệt rõ
Trong payment testing, đừng bao giờ nhầm lẫn ba khái niệm sau:
- Tạo giao dịch thành công (create success): backend trả về
payUrlhợp lệ. Điều này chưa nghĩa là đã thanh toán. - Người dùng thanh toán xong (payment completed): sự kiện xảy ra ở phía cổng, báo về qua IPN.
- Đối soát khớp (reconciliation matched): số tiền, mã đơn, và trạng thái ở hệ thống bạn khớp với báo cáo của cổng.
Signature/Secure Hash — trái tim của bảo mật thanh toán
Mỗi cổng dùng một cơ chế ký khác nhau, và test của bạn PHẢI kiểm tra nó:
- VNPay: dùng
vnp_SecureHash— HMAC-SHA512 trên chuỗi query đã sắp xếp alphabet theo key. - MoMo: dùng
signature— HMAC-SHA256 trên một chuỗi các field ghép theo thứ tự cố định do MoMo quy định. - ZaloPay: dùng
mac— HMAC-SHA256, và cómacriêng cho callback.
orderId có thể tự bắn IPN giả để "mua hàng miễn phí".Tình huống thực tế
Tình huống 1 — Sàn TMĐT "ChợViệt" và cái bẫy "double IPN"
Một sàn thương mại điện tử giả định tên ChợViệt tích hợp VNPay. Trong tuần đầu go-live, kế toán phát hiện có 47 đơn hàng bị cộng doanh thu hai lần trong báo cáo nội bộ, dù khách chỉ trả một lần. Điều tra ra: VNPay (giống hầu hết cổng) sẽ gửi lại IPN nhiều lần nếu server merchant không phản hồi đúng chuẩn {"RspCode":"00","Message":"Confirm Success"} trong thời gian quy định. Backend của ChợViệt xử lý IPN nhưng không idempotent — mỗi lần nhận IPN lại += số tiền vào ví người bán.
Bài học rút ra: test suite payment phải có ca test gửi cùng một IPN hai lần liên tiếp và assert rằng trạng thái đơn cũng như số dư chỉ thay đổi đúng một lần. Trong Postman, ta lưu số dư trước khi gửi IPN vào một biến, gửi IPN lần 1, gửi lại IPN lần 2 (giống hệt), rồi assert số dư sau cùng chỉ tăng đúng bằng amount. Đây là kiểu bug mà chỉ integration test mới bắt được — unit test từng hàm sẽ pass hết.
Tình huống 2 — Fintech "PayFast" và sai lệch đơn vị tiền của MoMo
Một startup ví điện tử giả định PayFast tích hợp MoMo để nạp tiền. QA test trên sandbox với đơn 10.000đ thì mọi thứ ổn. Nhưng khi lên production, đơn 500.000đ lại bị MoMo từ chối với lỗi signature. Nguyên nhân: MoMo yêu cầu amount là số nguyên VND, không có phần thập phân, và nằm trong khoảng cho phép (ví dụ 1.000đ – 50.000.000đ), đồng thời signature được tính trên đúng chuỗi amount đó. Dev của PayFast lỡ format số tiền lớn thành "500,000" (có dấu phẩy) trước khi ký, khiến chuỗi ký sai lệch với chuỗi gửi đi.
Bài học: test data cho payment phải phủ biên giá trị — số tiền nhỏ nhất, lớn nhất, số tròn nghìn, và số lẻ. Ở dự án này ta dùng data-driven (một file CSV các amount khác nhau) chạy qua Newman để phát hiện sớm những lỗi format kiểu này. Đừng chỉ test với con số "đẹp" 10.000đ.
Tình huống 3 — "GrabFood clone" và callback ZaloPay đến trước redirect
Một ứng dụng đặt đồ ăn tích hợp ZaloPay gặp hiện tượng lạ: đôi khi khách bấm "Thanh toán", ví ZaloPay báo thành công, nhưng màn hình app vẫn hiện "Đang chờ thanh toán" vài giây rồi mới nhảy sang "Thành công". Đội QA ban đầu báo đây là bug. Thực ra đây là hành vi đúng của thiết kế bất đồng bộ: ZaloPay gửi callback (server-to-server) về backend gần như tức thì để xác nhận, trong khi redirect URL (client) phụ thuộc vào mạng của điện thoại khách — hai kênh này đua nhau (race condition).
Bài học: khi test, đừng dựa vào redirect để kết luận trạng thái thanh toán. Nguồn sự thật (source of truth) luôn là callback/IPN server-side. Test suite phải mô phỏng đúng thứ tự: gửi callback trước, rồi mới truy vấn /payment/status để xác nhận backend đã "chốt" đơn dựa trên callback chứ không phải redirect.
Hướng dẫn từng bước
Bước 1 — Dựng cấu trúc collection. Tạo collection Payment Integration Tests với ba folder con: VNPay, MoMo, ZaloPay, cộng một folder Common chứa các request dùng chung như Create Payment và Check Status. Đặt các thông tin sandbox (tmnCode, secret key, partner code, endpoint sandbox) vào environment riêng payment-sandbox, tuyệt đối không hardcode vào request.
Bước 2 — Thiết kế request tạo giao dịch chung. Trong folder Common, request Create Payment gọi POST {{baseUrl}}/payment/create với body dùng biến:
{
"gateway": "{{gateway}}",
"amount": {{amount}},
"orderId": "{{orderId}}",
"orderInfo": "Thanh toan don hang test",
"returnUrl": "{{returnUrl}}"
}
Ở tab Pre-request Script, sinh orderId duy nhất để tránh trùng khi chạy lại:
pm.variables.set("orderId", "TEST" + Date.now());
Bước 3 — Assert kết quả tạo giao dịch. Ở tab Tests, lưu lại transactionRef cho các bước sau và kiểm tra payUrl:
pm.test("Tao giao dich thanh cong", () => {
pm.response.to.have.status(200);
const b = pm.response.json();
pm.expect(b.payUrl).to.match(/^https?:\/\//);
pm.expect(b.gateway).to.eql(pm.variables.get("gateway"));
pm.collectionVariables.set("txnRef", b.transactionRef);
});
Bước 4 — Mô phỏng IPN/callback với chữ ký hợp lệ. Vì ta không thể "bấm trả tiền" tự động trong sandbox, ta tạo request Simulate IPN bắn thẳng vào endpoint nhận callback của backend (ví dụ POST /payment/ipn/vnpay). Ở Pre-request Script, ta tự tính secure hash bằng thư viện crypto-js có sẵn trong sandbox Postman:
const CryptoJS = require("crypto-js");
const secret = pm.environment.get("vnpaySecret");
// giả lập params VNPay trả về, đã sort theo key
const raw = "vnp_Amount=1000000&vnp_ResponseCode=00&vnp_TxnRef="
+ pm.collectionVariables.get("txnRef");
const hash = CryptoJS.HmacSHA512(raw, secret).toString();
pm.collectionVariables.set("ipnQuery", raw + "&vnp_SecureHash=" + hash);
Bước 5 — Assert xử lý IPN đúng. Test rằng backend trả đúng mã xác nhận và cập nhật trạng thái đơn:
pm.test("Backend chap nhan IPN hop le", () => {
pm.expect(pm.response.json().RspCode).to.eql("00");
});
Bước 6 — Ca negative: chữ ký sai. Nhân bản request IPN, cố tình đổi một ký tự trong hash, rồi assert backend từ chối:
pm.test("Backend tu choi IPN sai chu ky", () => {
pm.expect(pm.response.json().RspCode).to.not.eql("00");
});
Bước 7 — Đối soát trạng thái. Cuối luồng, gọi GET /payment/status?orderId={{orderId}} và assert trạng thái là PAID cùng số tiền khớp.
Bước 8 — Data-driven cho ba cổng. Tạo file gateways.csv:
gateway,amount,returnUrl
vnpay,1000000,https://shop.test/return
momo,50000,https://shop.test/return
zalopay,200000,https://shop.test/return
Chạy qua Newman: newman run payment.postman_collection.json -e payment-sandbox.json -d gateways.csv -r cli,html. Mỗi dòng CSV chạy trọn luồng cho một cổng, và bạn có ngay một báo cáo HTML cho cả ba.
Lỗi thường gặp & mẹo
Hardcode secret key vào request. Đây là lỗi khiến key sandbox (và tệ hơn, key production) bị commit lên Git. Luôn để trong environment, và với secret nhạy cảm dùng pm.environment.get() chứ không nhúng vào URL. Khi chạy CI, truyền qua biến môi trường Newman thay vì lưu file environment có secret.
Nhầm số tiền vì đơn vị. VNPay yêu cầu vnp_Amount nhân 100 (đơn vị là "xu"), trong khi MoMo và ZaloPay dùng VND nguyên. Một đơn 100.000đ gửi sang VNPay phải là 10000000. Rất nhiều bug đối soát lệch đúng 100 lần đến từ đây — hãy viết hẳn một test assert công thức quy đổi này.
Không sinh orderId duy nhất. Chạy lại collection với cùng orderId khiến cổng báo "giao dịch đã tồn tại". Luôn dùng Date.now() hoặc pm.variables.replaceIn('{{$guid}}').
Tin vào redirect URL. Như tình huống ZaloPay ở trên, redirect có thể trễ hoặc thất bại (khách tắt trình duyệt). Nguồn sự thật là IPN/callback server-side. Đừng viết test assert trạng thái dựa trên tham số trên returnUrl.
Bỏ qua test idempotency. Gửi lại IPN hai lần là ca test bắt buộc, không phải "nice to have". Nó bắt được lớp bug cộng tiền trùng mà không loại test nào khác thấy.
Mẹo dùng pm.sendRequest để chuỗi hóa. Nếu muốn một request "kịch bản hóa" toàn luồng (tạo → IPN → status) trong một lần chạy, dùng pm.sendRequest trong Tests để gọi liên tiếp, nhưng với suite lớn nên tách thành nhiều request có thứ tự để báo cáo Newman rõ ràng hơn.
Mẹo tách environment theo cổng khi key khác nhau. Nếu ba cổng có bộ credential quá khác nhau, cân nhắc dùng chung một environment nhưng đặt tên biến có tiền tố (vnpaySecret, momoSecret, zalopaySecret) để tránh ghi đè nhầm.
Bài tập thực hành
- Dựng khung collection. Tạo collection
Payment Integration Testsvới các folder và một environmentpayment-sandboxchứa ít nhấtbaseUrl,vnpaySecret,momoSecret,zalopaySecret. Đăng ký sandbox thật của VNPay/MoMo/ZaloPay (đều miễn phí cho developer) để lấy credential mẫu.
- Viết luồng đầy đủ cho VNPay. Bao gồm: tạo giao dịch, mô phỏng IPN hợp lệ (tự tính HMAC-SHA512), một ca IPN sai chữ ký, và một ca gửi IPN hai lần để kiểm tra idempotency. Assert đầy đủ ở mỗi bước.
- Tổng quát hóa bằng data-driven. Viết file
gateways.csvnhư hướng dẫn và chỉnh requestCreate Paymentđể chạy được cả ba cổng chỉ bằng biến{{gateway}}. Chạy bằng Newman và xuất báo cáo HTML.
- Thử thách nâng cao. Thêm một ca test biên: amount = 0 và amount vượt trần cho phép của MoMo (ví dụ 100.000.000đ). Assert backend từ chối tạo giao dịch với thông báo lỗi hợp lý, KHÔNG sinh ra
payUrl.
- Viết một đoạn ghi chú đối soát. Sau khi chạy suite, mô tả bằng lời: nếu số dư sau khi chạy toàn bộ CSV lệch so với tổng
amountmong đợi, bạn sẽ điều tra từ đâu trước? (Gợi ý: bắt đầu từ log IPN và công thức quy đổi đơn vị.)
Tóm tắt
Payment integration testing là bài kiểm tra bản lĩnh của một QA, vì đây là nơi lỗi tốn tiền thật. Ba ý cốt lõi cần khắc sâu: (1) ta test API façade của backend merchant chứ không gọi thẳng cổng thanh toán; (2) nguồn sự thật của trạng thái thanh toán luôn là IPN/callback server-side, không phải redirect — và nó bất đồng bộ, có thể lặp lại, nên idempotency là ca test bắt buộc; (3) chữ ký số (secure hash/signature/mac) phải được test cả chiều hợp lệ lẫn giả mạo, và đơn vị tiền của mỗi cổng khác nhau (VNPay nhân 100).
Bằng cách thiết kế một interface POST /payment/create chung và dùng data-driven qua CSV, bạn viết một bộ test duy nhất chạy được cho VNPay, MoMo và ZaloPay — vừa gọn, vừa dễ mở rộng khi công ty tích hợp thêm cổng thứ tư. Đây chính là tư duy kiến trúc test mà một API Tester tiến lên SDET cần có: không test từng request rời rạc, mà test cả một luồng nghiệp vụ tiền bạc một cách hệ thống và có thể lặp lại trong CI/CD.