Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Test luồng MoMo / ZaloPay / VNPay sandbox

Automation Testing Introduction Bài 46/60

Mở đầu — vì sao bài này quan trọng

Nếu bạn làm QA cho bất kỳ sản phẩm số nào ở Việt Nam — thương mại điện tử, giao đồ ăn, ví điện tử, khóa học online, game, hay đơn giản là một app bán vé xem phim — thì sớm hay muộn bạn sẽ đụng tới luồng thanh toán. Và ở Việt Nam, "thanh toán" gần như đồng nghĩa với ba cái tên: MoMo, ZaloPayVNPay. Đây là những cổng (payment gateway) chiếm thị phần áp đảo cho các giao dịch online của người Việt.

Vấn đề là: luồng thanh toán là nơi mà một con bug không chỉ gây khó chịu, mà gây mất tiền thật. Nếu callback bị xử lý sai, hệ thống có thể ghi nhận đơn "đã thanh toán" trong khi tiền chưa về — doanh nghiệp giao hàng và chịu lỗ. Ngược lại, nếu bạn không xử lý được trạng thái "thành công" đúng cách, khách đã trừ tiền nhưng đơn vẫn treo — khách nổi giận, tổng đài quá tải. Đây là lý do các luồng payment luôn nằm trong nhóm critical path cần được automate và regression liên tục.

Nhưng automate thanh toán VN có những đặc thù mà tài liệu tiếng Anh không dạy bạn: cơ chế signature (chữ ký HMAC), IPN/callback bất đồng bộ, số điện thoại và OTP test riêng của từng ví, và việc phần lớn thao tác cuối cùng lại xảy ra trong app MoMo/ZaloPay trên điện thoại — nơi Selenium hay Cypress không với tới được. Bài này sẽ dạy bạn cách nghĩ đúng và test đúng ba cổng này trong môi trường sandbox (môi trường thử nghiệm, không dùng tiền thật).

Khái niệm cốt lõi

Sandbox là gì và tại sao bắt buộc dùng nó

Sandbox là môi trường mô phỏng của cổng thanh toán, nơi mọi giao dịch đều là giả — không có tiền thật chạy qua. Mỗi cổng cung cấp một bộ credentials riêng cho sandbox (khác hoàn toàn production): với MoMo là partnerCode, accessKey, secretKey; với VNPay là vnp_TmnCodevnp_HashSecret; với ZaloPay là app_id, key1, key2. Tuyệt đối không bao giờ chạy automation test lên môi trường production với tài khoản thật — đó là quy tắc số một.

Các endpoint sandbox thường gặp:

  • MoMo: https://test-payment.momo.vn/v2/gateway/api/create
  • VNPay: https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
  • ZaloPay: https://sb-openapi.zalopay.vn/v2/create

Ba giai đoạn của một luồng thanh toán

Dù là cổng nào, luồng thanh toán online về bản chất luôn có ba giai đoạn, và bạn phải test cả ba:

1. Khởi tạo giao dịch (Create Order / Payment). Backend của bạn gọi API cổng thanh toán để tạo một giao dịch, gửi kèm amount, orderId, orderInfo và một chữ ký (signature). Cổng trả về một payUrl hoặc deeplink để redirect người dùng sang trang/app thanh toán.

2. Người dùng thanh toán. Đây là phần "người thật bấm nút" — quét QR, nhập OTP, xác nhận trong app ví. Với sandbox, ví cung cấp tài khoản testOTP cố định để bạn giả lập bước này.

3. Nhận kết quả (Return URL + IPN/Callback). Cổng thông báo kết quả theo hai kênh song song:

  • Return URL (redirect): trình duyệt người dùng được đưa về website của bạn kèm query string chứa kết quả. Kênh này để hiển thị UI cho khách, không đáng tin để cập nhật DB (vì khách có thể đóng trình duyệt giữa chừng).
  • IPN (Instant Payment Notification) / callback server-to-server: cổng gọi thẳng vào backend của bạn để báo kết quả. Đây mới là nguồn sự thật (source of truth) để cập nhật trạng thái đơn hàng.
Hiểu sự khác biệt giữa Return URL và IPN là điều quan trọng nhất trong toàn bộ bài này. Rất nhiều bug production sinh ra vì lập trình viên cập nhật đơn hàng dựa trên Return URL thay vì IPN.

Signature — trái tim của bảo mật thanh toán

Mỗi request/response đều đi kèm một chữ ký, thường là HMAC-SHA256 (VNPay dùng HMAC-SHA512) tính trên chuỗi các tham số đã sắp xếp, ký bằng secretKey. Test signature là phần dễ bị bỏ sót nhất nhưng lại là nơi kẻ tấn công nhắm vào: nếu backend của bạn không verify signature trên IPN, kẻ xấu có thể tự gửi một callback giả "thanh toán thành công" để nhận hàng miễn phí. Vì vậy trong bộ automation, bạn phải có cả test case tích cực (signature đúng → chấp nhận) và test case tiêu cực (signature sai/thiếu → từ chối).

Vì sao automation payment khó

Điểm mấu chốt: bước "người dùng thanh toán" xảy ra trên hạ tầng của bên thứ ba (trang VNPay, app MoMo). Bạn không kiểm soát DOM của họ, họ có thể đổi giao diện bất cứ lúc nào, và với ví như MoMo/ZaloPay thao tác cuối lại nằm trong app native. Do đó, chiến lược khôn ngoan là: automate mạnh ở hai đầu (create và callback), còn phần giữa thì mock hoặc drive tối thiểu. Chúng ta sẽ nói kỹ điều này ở phần hướng dẫn.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT giả định "ChoTot Mart" và cái bẫy Return URL

Một team QA tại một sàn thương mại điện tử giả định tên ChoTot Mart viết test tự động cho luồng VNPay. Kịch bản của họ: gọi API tạo đơn → mở payUrl sandbox bằng Playwright → nhập thẻ test NCB → chờ redirect về Return URL → assert rằng URL chứa vnp_ResponseCode=00 (thành công) → kết luận "pass". Suite xanh mướt, ai cũng vui.

Ba tuần sau, production xảy ra sự cố: có khoảng 40 đơn khách đã trừ tiền nhưng đơn vẫn ở trạng thái "chờ thanh toán". Điều tra ra nguyên nhân: backend cập nhật đơn dựa trên Return URL, nhưng nhiều khách hàng trên mạng 3G yếu bị mất kết nối ngay sau khi bấm xác nhận, trình duyệt không kịp quay về Return URL — dù VNPay đã gửi IPN thành công. Suite test của họ không hề chạm tới IPN nên không phát hiện được.

Bài học: Test phải kiểm chứng IPN là nguồn cập nhật trạng thái, không phải Return URL. Team đã bổ sung một test case mô phỏng đúng tình huống này: gọi thẳng endpoint IPN với payload hợp lệ (không thông qua trình duyệt) rồi assert đơn hàng chuyển sang "đã thanh toán" trong DB — bất kể Return URL có được gọi hay không.

Ví dụ 2 — App giao đồ ăn và callback đến trễ

Một startup giao đồ ăn ở TP.HCM tích hợp MoMo. QA của họ viết test: tạo giao dịch MoMo sandbox → giả lập thanh toán → ngay lập tức gọi API kiểm tra trạng thái đơn → assert "đã thanh toán". Test thỉnh thoảng đỏ, thỉnh thoảng xanh — một ca flaky test kinh điển.

Nguyên nhân: IPN của MoMo là bất đồng bộ, thường về sau vài trăm mili-giây đến vài giây. Test của họ assert quá sớm, trước khi callback kịp cập nhật DB. Cách sửa sai lầm là thêm sleep(3000) — làm test chậm và vẫn không ổn định.

Cách sửa đúng: dùng polling với timeout — hỏi trạng thái đơn mỗi 500ms trong tối đa 15 giây, chỉ fail khi hết thời gian mà vẫn chưa "đã thanh toán". Đây chính là explicit wait cho hệ bất đồng bộ. Sau khi áp dụng, tỉ lệ flaky của case này giảm từ khoảng 12% xuống gần 0.

Bài học: Luồng thanh toán vốn bất đồng bộ. Đừng bao giờ assert kết quả IPN ngay lập tức; hãy poll cho tới khi trạng thái ổn định hoặc timeout.

Ví dụ 3 — Bảo mật: callback giả tại một công ty fintech

Một công ty fintech nhỏ bán gói subscription qua ZaloPay. Trong một buổi security review, một bạn SDET thử nghiệm: tự tay gửi một HTTP POST tới endpoint callback nội bộ với nội dung {"status": 1, "app_trans_id": "..."} nhưng cố tình để mac (chữ ký) sai. Kết quả gây sốc: hệ thống vẫn kích hoạt gói premium cho tài khoản đó, vì code chỉ đọc statusquên verify mac.

Đây là lỗ hổng nghiêm trọng: bất kỳ ai biết cấu trúc callback đều có thể "mua" gói premium miễn phí. Team lập tức thêm bước verify mac bằng key2, và quan trọng hơn, đưa hẳn test case này vào bộ automation regression: gửi callback với signature sai → hệ thống phải trả lỗi và KHÔNG kích hoạt gì cả.

Bài học: Với payment, test tiêu cực (negative test) về signature quan trọng ngang test tích cực. Một callback giả mà hệ thống chấp nhận là một lỗ hổng mất tiền.

Hướng dẫn từng bước

Dưới đây là cách xây một bộ automation cho luồng thanh toán VN, dùng Python + pytest + requests làm ví dụ (bạn hoàn toàn có thể chuyển sang Java/JS).

Bước 1 — Chuẩn bị credentials sandbox. Đăng ký tài khoản developer trên trang dành cho nhà phát triển của MoMo, VNPay, ZaloPay để lấy bộ credentials sandbox. Lưu chúng vào biến môi trường / file .env, tuyệt đối không hardcode secretKey vào source. Ví dụ với MoMo sandbox: partnerCode=MOMO, cùng accessKeysecretKey được cấp trong tài liệu test.

Bước 2 — Viết hàm tạo signature dùng chung. Đây là nền tảng của mọi test. Ví dụ HMAC-SHA256 cho MoMo:

import hmac, hashlib

def momo_signature(raw_data: str, secret_key: str) -> str: return hmac.new( secret_key.encode(), raw_data.encode(), hashlib.sha256 ).hexdigest()

Với VNPay bạn sắp xếp tham số theo alphabet, nối thành query string rồi ký bằng HMAC-SHA512; với ZaloPay chuỗi ký app_id|app_trans_id|app_user|amount|app_time|embed_data|item ký bằng key1.

Bước 3 — Test khởi tạo giao dịch. Gọi API create của cổng và assert cấu trúc trả về:

def test_momo_create_payment():
    order_id = f"TEST_{int(time.time())}"
    payload = build_momo_create_payload(order_id, amount=50000)
    resp = requests.post(MOMO_CREATE_URL, json=payload)
    body = resp.json()
    assert resp.status_code == 200
    assert body["resultCode"] == 0          # 0 = thành công
    assert body["payUrl"].startswith("https://")

Bước 4 — Xử lý bước "người dùng thanh toán". Có hai chiến lược:

  • Chiến lược A — Drive UI bằng Playwright (chủ yếu cho VNPay, vì VNPay có trang web nhập thẻ test): mở payUrl, chọn ngân hàng NCB, điền thông tin thẻ test do VNPay cung cấp (số thẻ, tên chủ thẻ, ngày phát hành, OTP mặc định thường là 123456).
  • Chiến lược B — Bỏ qua UI, mock kênh callback (chủ yếu cho MoMo/ZaloPay): vì bước cuối nằm trong app ví, không drive được bằng browser automation, bạn giả lập IPN — tự tạo một payload callback hợp lệ (ký signature đúng) và POST thẳng vào endpoint IPN của backend. Cách này nhanh, ổn định, và test đúng phần logic quan trọng nhất của bạn.
Bước 5 — Test IPN/callback (phần quan trọng nhất). Mô phỏng cổng gọi vào backend của bạn:

def test_momo_ipn_success_updates_order(created_order):
    ipn_payload = build_valid_ipn(created_order.id, result_code=0)
    resp = requests.post(BACKEND_IPN_URL, json=ipn_payload)
    assert resp.status_code == 204        # MoMo yêu cầu trả 204

# Poll trạng thái đơn thay vì assert ngay assert wait_for_status(created_order.id, "PAID", timeout=15)

Bước 6 — Poll trạng thái bất đồng bộ. Viết helper để chờ trạng thái ổn định thay vì sleep cứng:

def wait_for_status(order_id, expected, timeout=15, interval=0.5):
    end = time.time() + timeout
    while time.time() < end:
        if get_order_status(order_id) == expected:
            return True
        time.sleep(interval)
    return False

Bước 7 — Thêm các test tiêu cực. Đây là nơi tạo ra giá trị thật:

  • IPN với signature sai → backend trả lỗi, đơn KHÔNG chuyển sang PAID.
  • IPN với số tiền không khớp đơn gốc (ví dụ đơn 50.000đ nhưng callback báo 5.000đ) → phải bị từ chối.
  • IPN lặp lại (duplicate) cùng một orderId → chỉ được cập nhật một lần, không cộng dồn (test tính idempotency).
  • Giao dịch thất bại/hủy (resultCode != 0) → đơn về trạng thái FAILED, không giao hàng.
Bước 8 — Đưa vào CI với đánh dấu riêng. Vì test payment gọi ra sandbox bên ngoài, hãy gắn marker (@pytest.mark.payment) để tách khỏi unit test nhanh, và cho chạy ở stage riêng, có retry hợp lý cho phần phụ thuộc mạng.

Lỗi thường gặp & mẹo

  • Nhầm Return URL với IPN. Như ví dụ 1, đừng bao giờ cập nhật DB dựa trên Return URL. Luôn coi IPN server-to-server là nguồn sự thật, và test nó độc lập với trình duyệt.
  • Assert kết quả IPN ngay lập tức. Callback bất đồng bộ — hãy poll với timeout, đừng dùng sleep cứng cũng đừng assert tức thì.
  • Quên verify signature. Đây là lỗ hổng chết người. Bắt buộc có negative test cho signature sai, thiếu, và số tiền không khớp.
  • Bỏ qua idempotency. Cổng có thể gửi IPN nhiều lần (retry khi chưa nhận 204/200). Nếu code cộng tiền/giao hàng mỗi lần nhận callback, bạn sẽ giao hàng nhiều lần cho một đơn. Test duplicate callback là bắt buộc.
  • Hardcode secretKey vào code hoặc commit lên git. Dùng biến môi trường; secret sandbox tuy không phải tiền thật nhưng thói quen xấu này sẽ theo bạn lên production.
  • Cố drive app MoMo/ZaloPay bằng Selenium web. Không được, vì đó là app native. Nếu thật sự cần test end-to-end qua app ví, đó là chuyện của Appium trên thiết bị/emulator — còn cho phần lớn nhu cầu, mock IPN là đủ và ổn định hơn nhiều.
  • orderId trùng nhau giữa các lần chạy. Sandbox thường từ chối orderId đã tồn tại. Luôn sinh orderId duy nhất (kèm timestamp) cho mỗi test.
  • Sai amount: đơn vị và định dạng. Lưu ý VNPay yêu cầu số tiền nhân 100 (đơn vị là đồng x100), còn MoMo/ZaloPay dùng số nguyên đồng. Nhầm chỗ này khiến signature sai hoặc số tiền lệch.
  • Mẹo — ghi log request/response đầy đủ khi test. Payment fail thường khó debug; lưu lại raw signature string, payload và response giúp bạn tìm ra lệch dấu cách hay lệch thứ tự tham số rất nhanh.

Bài tập thực hành

  • Tạo giao dịch MoMo sandbox. Viết một test pytest gọi endpoint https://test-payment.momo.vn/v2/gateway/api/create với partnerCode=MOMO và credentials sandbox trong tài liệu MoMo. Assert resultCode == 0payUrl hợp lệ. Chú ý tự tính đúng signature.
  • Mô phỏng IPN thành công. Xây một hàm build_valid_ipn(order_id, result_code) tạo payload callback có signature đúng, POST vào endpoint IPN của một backend demo (bạn có thể dựng một Flask/Express nhỏ). Viết test khẳng định đơn chuyển sang PAID, dùng helper wait_for_status để poll thay vì sleep.
  • Negative test signature. Sửa payload ở bài 2 để signature sai một ký tự. Viết test khẳng định backend từ chối và đơn vẫn ở trạng thái ban đầu. Đây là test bảo vệ bạn khỏi lỗ hổng ở ví dụ 3.
  • Test idempotency. Gửi cùng một IPN hợp lệ hai lần liên tiếp. Assert rằng đơn chỉ được cập nhật một lần và không có tác dụng phụ nào bị lặp (không giao hàng hai lần, không cộng số dư hai lần).
  • (Nâng cao) VNPay end-to-end với Playwright. Tạo giao dịch VNPay sandbox, mở payUrl, dùng Playwright chọn ngân hàng NCB và điền thẻ test, nhập OTP 123456, rồi khẳng định backend nhận được IPN và cập nhật đơn đúng. So sánh độ ổn định của cách này với cách mock IPN ở bài 2 — bạn sẽ tự cảm nhận vì sao mock IPN thường được ưu tiên.

Tóm tắt

Test luồng thanh toán MoMo/ZaloPay/VNPay sandbox là kỹ năng "phải có" của bất kỳ QA/SDET nào làm sản phẩm cho thị trường Việt Nam, bởi đây là critical path liên quan trực tiếp tới tiền. Ba điều cốt lõi cần nhớ:

  • Mỗi luồng thanh toán có ba giai đoạn — khởi tạo, người dùng thanh toán, và nhận kết quả — nhưng nguồn sự thật để cập nhật đơn hàng luôn là IPN/callback server-to-server, không phải Return URL. Đây là bài học đắt giá nhất.
  • Payment là bất đồng bộ: đừng assert ngay, hãy poll với timeout để tránh flaky test.
  • Signature là ranh giới bảo mật: luôn có negative test cho signature sai, số tiền không khớp, callback trùng lặp (idempotency). Một callback giả được chấp nhận là một lỗ hổng mất tiền.
Chiến lược automation khôn ngoan là automate mạnh ở hai đầu (create và callback), mock phần giữa — vừa nhanh, vừa ổn định, vừa test đúng phần logic quan trọng nhất của chính bạn. Khi bạn đã nắm chắc cách mô phỏng IPN và kiểm chứng ba giai đoạn này trong sandbox, bạn đã có nền tảng để bảo vệ doanh nghiệp khỏi những con bug tốn tiền nhất mà một sản phẩm số có thể mắc phải.