Mở đầu — vì sao bài này quan trọng
Trong tất cả các luồng chức năng của một ứng dụng mobile, không có luồng nào "nhạy cảm" hơn In-App Purchase (IAP) — mua hàng trong ứng dụng. Đây là nơi tiền thật của người dùng đi ra, và cũng là nơi doanh thu thật của công ty đi vào. Một bug ở màn hình onboarding có thể khiến trải nghiệm khó chịu, nhưng một bug ở luồng IAP có thể khiến người dùng bị trừ tiền mà không nhận được sản phẩm, hoặc tệ hơn — nhận sản phẩm mà công ty không thu được tiền. Cả hai đều là thảm họa: một bên là khủng hoảng niềm tin và review 1 sao trên App Store, một bên là thất thoát doanh thu trực tiếp.
Đối với QA làm việc trên nền tảng iOS, IAP còn khó ở chỗ nó không phải là một API bình thường mà bạn có thể tự do mock hay gọi thẳng. Toàn bộ giao dịch đi qua StoreKit — framework thanh toán của Apple — và tiền được xử lý bởi hệ thống của Apple, không phải backend của công ty bạn. Điều này có nghĩa là bạn không thể "giả vờ mua hàng" bằng cách sửa vài dòng code. Bạn phải hiểu cơ chế sandbox của Apple, biết cách tạo tài khoản test, và quan trọng nhất là hiểu vòng đời của một giao dịch từ lúc người dùng bấm nút cho đến lúc receipt được xác thực trên server.
Bài này tập trung riêng vào IAP trên iOS — tức là hệ sinh thái StoreKit và App Store Connect. Google Play Billing (Android) và các cổng thanh toán nội địa như MoMo, ZaloPay là chủ đề của các bài sau, nên ở đây chúng ta sẽ đào sâu đúng phần của Apple.
Khái niệm cốt lõi
Các loại sản phẩm IAP trên iOS
Trước khi test, bạn phải hiểu Apple phân loại sản phẩm IAP thành 4 nhóm, mỗi nhóm có hành vi khác nhau và cần kịch bản test khác nhau:
- Consumable (tiêu hao): mua rồi dùng hết, mua lại được nhiều lần. Ví dụ: 100 xu trong game, gói lượt tim trong app hẹn hò. Consumable không được khôi phục (restore) — Apple không lưu lịch sử.
- Non-Consumable (không tiêu hao): mua một lần, sở hữu vĩnh viễn. Ví dụ: mở khóa bộ filter cao cấp, gỡ quảng cáo. Loại này phải restore được.
- Auto-Renewable Subscription (thuê bao tự động gia hạn): gói Premium hàng tháng/hàng năm tự động gia hạn. Đây là loại phức tạp nhất để test.
- Non-Renewing Subscription (thuê bao không tự gia hạn): ví dụ gói nội dung 1 mùa giải thể thao, hết hạn thì phải mua lại thủ công.
StoreKit và Sandbox
Khi app gọi mua hàng, StoreKit hiển thị hộp thoại thanh toán của hệ thống. Trong môi trường sandbox, không có tiền thật nào bị trừ, nhưng luồng hoàn toàn giống production: người dùng đăng nhập bằng Sandbox Tester account, xác nhận mua, và app nhận về một receipt (biên nhận) đã ký mã hóa.
Có hai thế hệ công cụ mà QA cần phân biệt:
- Sandbox truyền thống: dùng tài khoản Sandbox Tester tạo trong App Store Connect, test trên thiết bị thật. Đây là môi trường "thật" nhất, gần production nhất.
- StoreKit Configuration File (StoreKit Testing in Xcode): từ Xcode 12, dev có thể tạo file
.storekitđể test IAP ngay trên simulator mà không cần App Store Connect. Rất nhanh cho unit test và dev, nhưng không thay thế được sandbox thật vì nó không kiểm tra receipt validation phía Apple.
Receipt và Server-Side Validation
Sau khi mua thành công, app nhận receipt. Backend của công ty phải gửi receipt này lên server xác thực của Apple để kiểm tra tính hợp lệ trước khi cấp quyền cho người dùng. Đây là điểm mà rất nhiều bug ẩn náu: nếu backend không phân biệt được URL sandbox (sandbox.itunes.apple.com) và production (buy.itunes.apple.com), thì giao dịch sandbox sẽ bị từ chối hoặc — nguy hiểm hơn — giao dịch production bị xử lý nhầm. Apple trả về mã lỗi 21007 khi bạn gửi receipt sandbox lên endpoint production; backend đúng chuẩn phải bắt được lỗi này và tự động thử lại với endpoint sandbox.
Thời gian gia hạn bị nén trong sandbox
Một điểm cực kỳ quan trọng: trong sandbox, chu kỳ subscription bị nén thời gian. Một gói "1 tháng" trong sandbox chỉ kéo dài 5 phút, gói "1 năm" chỉ khoảng 1 giờ. Apple cũng chỉ cho phép subscription sandbox tự gia hạn tối đa 6 lần liên tiếp rồi dừng. Hiểu điều này giúp bạn test được toàn bộ vòng đời gia hạn, hết hạn, và hủy chỉ trong vài chục phút thay vì chờ cả tháng.
Tình huống thực tế
Tình huống 1 — App học tiếng Anh và cú "21007" chí mạng
Một startup edtech ở TP.HCM (giả định gọi là EngGo) phát hành app luyện IELTS với gói Premium 199.000đ/tháng. Đội QA test toàn bộ luồng mua bằng StoreKit Configuration File trên simulator, mọi thứ pass 100%, và họ release. Ba ngày sau, hàng loạt người dùng phản ánh: mua thành công, bị trừ tiền thật, nhưng app vẫn hiện "Bạn chưa là thành viên Premium".
Điều tra ra: backend của EngGo hardcode endpoint validation là production. Khi đội QA test bằng StoreKit config file, receipt được xác thực cục bộ trên máy nên không bao giờ chạm tới server Apple — hoàn toàn không phát hiện được lỗi endpoint. Trên production, receipt thật vẫn hợp lệ nhưng do một pha xử lý sai state, một số receipt của người dùng mới đăng ký lại bị gửi vào nhánh sandbox retry và trả về lỗi.
Bài học rút ra: StoreKit config file test rất tiện nhưng nó bỏ qua hoàn toàn tầng receipt validation. Bắt buộc phải có một vòng test trên sandbox thật, và QA phải yêu cầu dev cho xem log validation để xác nhận backend xử lý đúng cả mã 21007 lẫn 21008 (receipt production gửi nhầm lên sandbox).
Tình huống 2 — Gói năm bị "gia hạn 6 lần rồi im lặng"
Một app đọc truyện tại Việt Nam bán gói subscription năm. QA phụ trách viết test case "kiểm tra thông báo trước khi gia hạn". Chị test trong sandbox: mua gói, chờ nó gia hạn, và... sau lần gia hạn thứ 6 thì subscription tự dừng hẳn. Chị hoảng, báo bug "subscription không gia hạn vô hạn".
Thực ra đây không phải bug. Apple cố ý giới hạn subscription sandbox tự gia hạn tối đa 6 lần để test không kéo dài vô tận. Nếu không biết điều này, QA sẽ log một bug sai, làm dev mất thời gian điều tra một hành vi được thiết kế có chủ đích.
Bài học rút ra: QA làm IAP phải nắm rõ các "đặc tính riêng của sandbox" — thời gian nén, giới hạn 6 lần gia hạn, việc mã PIN/Face ID có thể bị bỏ qua trong một số phiên bản sandbox. Nếu không, bạn sẽ vừa bỏ sót bug thật vừa báo nhầm bug giả.
Tình huống 3 — "Restore Purchases" bị bỏ quên và App Store từ chối
Một team làm app chỉnh ảnh bán tính năng non-consumable "Gỡ watermark" giá 89.000đ mua một lần. Họ submit lên App Store và bị Apple reject với lý do "Guideline 3.1.1 — thiếu nút Restore Purchases". Đội QA trước đó chỉ test luồng mua mới, chưa từng test kịch bản: người dùng mua trên iPhone, sau đó cài lại app hoặc đổi máy, cần khôi phục quyền đã mua mà không phải trả tiền lần nữa.
Bài học rút ra: Với mọi sản phẩm non-consumable và subscription, "Restore Purchases" là test case bắt buộc, không phải tùy chọn. Kịch bản test chuẩn là: mua trên thiết bị A → xóa app / đăng nhập cùng Apple ID trên thiết bị B → bấm Restore → xác nhận quyền được khôi phục mà không phát sinh giao dịch mới.
Hướng dẫn từng bước
Dưới đây là quy trình test một luồng IAP iOS hoàn chỉnh trên sandbox thật:
Bước 1 — Tạo Sandbox Tester account. Vào App Store Connect → Users and Access → tab Sandbox → Testers → nhấn tạo tester mới. Dùng một email chưa từng liên kết với Apple ID nào (mẹo: dùng địa chỉ dạng qa+iap01@congty.com nếu email của bạn hỗ trợ dấu cộng). Chọn quốc gia/khu vực đúng với thị trường bạn test — vì giá và loại tiền tệ khác nhau theo khu vực.
Bước 2 — Cấu hình sản phẩm IAP. Đảm bảo dev đã tạo các product ID tương ứng trong App Store Connect và trạng thái ít nhất là "Ready to Submit". Ghi lại đúng danh sách product ID để đối chiếu — app phải load được đúng giá và mô tả từ StoreKit.
Bước 3 — Đăng xuất App Store trên thiết bị thật. Trên iPhone test, vào Settings → App Store, kéo xuống dưới cùng và đăng xuất tài khoản Apple ID production. Với các phiên bản iOS mới, bạn không cần đăng nhập sandbox thủ công ở đây — khi thực hiện giao dịch trong app, hệ thống sẽ hỏi và bạn nhập tài khoản Sandbox Tester lúc đó. Tuyệt đối không đăng nhập tài khoản sandbox vào iCloud hay vào phần Settings chính, chỉ dùng nó cho giao dịch IAP.
Bước 4 — Chạy build test. Cài build (thường qua TestFlight hoặc build debug). Mở màn hình mua hàng, xác nhận app load đúng danh sách sản phẩm, đúng giá theo khu vực Sandbox Tester.
Bước 5 — Thực hiện giao dịch. Bấm mua, đăng nhập bằng Sandbox Tester khi được hỏi, xác nhận. Kiểm tra hộp thoại có ghi rõ [Environment: Sandbox] — đây là dấu hiệu bạn đang ở đúng môi trường, không bị trừ tiền thật.
Bước 6 — Xác minh cấp quyền. Kiểm tra app đã unlock đúng nội dung/tính năng. Đồng thời yêu cầu dev/BE xác nhận receipt đã được gửi lên Apple và validate thành công (log trả về status 0).
Bước 7 — Test các nhánh phụ. Lần lượt kiểm: hủy giữa chừng (bấm Cancel ở hộp thoại), mất mạng khi đang mua, Restore Purchases, và với subscription thì chờ gia hạn (nhớ thời gian nén) rồi test hủy gói trong Settings → Apple ID → Subscriptions.
Bước 8 — Reset để test lại. Trong App Store Connect, bạn có thể xóa lịch sử mua của Sandbox Tester (mục Manage trong subscription của tester) để test lại luồng mua mới từ đầu, hoặc tạo tester mới.
Lỗi thường gặp & mẹo
- Chỉ test bằng StoreKit config file rồi tự tin release. Như tình huống EngGo, config file bỏ qua receipt validation. Luôn có vòng sandbox thật.
- Quên test Restore Purchases. Đây là nguyên nhân phổ biến khiến app bị App Store reject. Bắt buộc test với mọi non-consumable/subscription.
- Dùng nhầm Apple ID thật để test. Nếu quên đăng xuất, bạn có thể bị trừ tiền thật. Luôn kiểm tra hộp thoại có chữ "Sandbox" trước khi xác nhận.
- Nhầm hành vi sandbox với bug. Thời gian nén, giới hạn 6 lần gia hạn, đôi khi bỏ qua xác thực — đây là đặc tính, không phải lỗi.
- Không test lỗi mạng và interrupt. Cần chủ động ngắt mạng giữa giao dịch, kill app đúng lúc StoreKit đang xử lý, để kiểm tra app có xử lý pending transaction đúng cách khi mở lại không (StoreKit sẽ resume giao dịch dang dở).
- Bỏ qua "Ask to Buy" / Family Sharing. Nếu app hỗ trợ chia sẻ gia đình, cần test kịch bản trẻ em yêu cầu mua và phụ huynh phê duyệt.
- Mẹo: luôn giữ một bảng ma trận test case gồm: mỗi product ID × mỗi kịch bản (mua mới, hủy, restore, gia hạn, hết hạn, refund, đổi máy). Đánh dấu ô nào đã pass để không bỏ sót.
- Mẹo: dùng nhiều Sandbox Tester ở các quốc gia khác nhau để xác minh giá và định dạng tiền tệ hiển thị đúng.
Bài tập thực hành
- Thiết kế test suite. Cho một app có 3 sản phẩm: gói xu 50.000đ (consumable), "Gỡ quảng cáo" 99.000đ (non-consumable), và Premium 199.000đ/tháng (auto-renewable). Hãy liệt kê đầy đủ test case cho từng loại, chú ý loại nào cần Restore và loại nào không.
- Dựng môi trường. Tạo một Sandbox Tester account trong App Store Connect (nếu bạn có quyền truy cập), đăng xuất App Store trên một thiết bị test, và ghi lại các bước bạn thực hiện.
- Kịch bản gia hạn. Viết kịch bản test đầy đủ cho một auto-renewable subscription trong sandbox, tận dụng thời gian nén: liệt kê thứ tự các bước để quan sát được mua mới → gia hạn lần 1 → hủy → hết hạn, kèm kết quả mong đợi ở mỗi bước.
- Phân tích lỗi. Backend trả về khi validate receipt: status 21007. Giải thích lỗi này nghĩa là gì và backend nên xử lý ra sao. Đây là bug hay hành vi đúng?
Tóm tắt
IAP là luồng nhạy cảm nhất trong app mobile vì nó chạm trực tiếp đến tiền của người dùng và doanh thu của công ty. Trên iOS, mọi giao dịch đi qua StoreKit và được Apple xử lý, nên QA phải làm chủ hệ sinh thái sandbox thay vì mock tùy tiện. Bốn loại sản phẩm — consumable, non-consumable, auto-renewable và non-renewing subscription — có hành vi và test case khác nhau; đặc biệt non-consumable và subscription bắt buộc phải test Restore Purchases, nếu không App Store sẽ reject.
Hai công cụ chính là Sandbox Tester (thiết bị thật, gần production nhất) và StoreKit Configuration File (nhanh nhưng bỏ qua receipt validation) — không được dùng cái sau thay hoàn toàn cái trước. QA cần hiểu các đặc tính riêng của sandbox như thời gian nén, giới hạn 6 lần gia hạn, và phải kiểm tra kỹ tầng server-side validation, đặc biệt là cách backend xử lý mã 21007. Cuối cùng, đừng quên các nhánh phụ: hủy giữa chừng, mất mạng, pending transaction, đổi máy và Restore. Một test suite IAP tốt là một ma trận đầy đủ product × kịch bản, được chạy ít nhất một vòng trên sandbox thật trước mỗi lần release.