Тестирование In-App Purchase в мобильной игре: 9 разделов и чек-лист
IAP — самый дорогой сегмент кода для бага. Один пропущенный кейс «деньги списались, но товар не пришёл» — это support-тикеты, рефанды, плохие отзывы и удержание под угрозой. И при этом IAP покрытие...
IAP — самый дорогой сегмент кода для бага. Один пропущенный кейс «деньги списались, но товар не пришёл» — это support-тикеты, рефанды, плохие отзывы и удержание под угрозой. И при этом IAP покрытие тестами в моб. играх обычно слабее всего: «работает на моей машине» в режиме sandbox — норма. Полный гайд как тестировать.
Почему IAP сложнее обычного API
- Транзакция идёт через посредника (App Store / Play Store), а не напрямую в ваш бекенд. Запрос → платформа → ваш код → ваш сервер → подтверждение → выдача товара. Любое звено может сломаться отдельно.
- Receipt-валидация на сервере — критично, но часто пропускается. Без неё клиент может «подделать» успешную покупку.
- Восстановление покупок (restore) — отдельная и часто забываемая ветка кода. Юзер сменил девайс, сбросил прогресс, переустановил приложение.
- Подписки имеют свой жизненный цикл: grace period, billing retry, upgrade/downgrade с проратированием, family sharing, paused subscriptions.
- Регион и валюта влияют на цену, налоги, доступность. То что работает в US-сторе может не работать в Турции или Индии.
1. Setup: sandbox и тестовые аккаунты
iOS
- App Store Connect → Users and Access → Sandbox Testers — создайте отдельные тестовые Apple ID. Никогда не используйте свой реальный.
- На устройстве: Settings → App Store → Sandbox Account → залогиниться тестовым ID. Не выходите из своего основного — sandbox-логин отдельно от Apple ID устройства.
- Если в приложении используется StoreKit 2 — sandbox-режим работает без сепаратного account-логина при запуске из Xcode. Это удобнее для разработки, но QA-флоу из TestFlight всё равно через Sandbox Tester.
Android
- Google Play Console → Setup → License testing — добавьте Google-аккаунты тестировщиков. Эти аккаунты увидят тестовые цены (₽0) и могут пройти полный flow.
- Для тестирования нужны: closed/internal/open testing track в Play Console, тестовый аккаунт добавлен в track, приложение скачано с Play Store через ссылку track’а.
- Важно: IAP не работают через
adb install— только через установку с Play. Без этого Billing вернётBILLING_UNAVAILABLE.
2. Functional: основные сценарии
Минимальный набор кейсов, который должен пройти каждый IAP в продукте:
- Покупка успешна: тап → подтверждение → товар добавлен в баланс/инвентарь → событие аналитики ушло →
purchase_token / transaction_idзалогирован. - Пользователь отменил платёжный диалог: возврат в нормальное состояние, никаких частичных эффектов, не списано ни баллов ни денег.
- Двойной тап на кнопку «Купить»: запрос отправлен только один раз. Многократный grant товара — классический баг.
- Приложение убито посреди транзакции: после релонча приложение видит pending-транзакцию, завершает её, выдаёт товар.
- Покупка с offline-стартом: нет сети → кнопка отключена или показывает понятную ошибку. Не пустой диалог StoreKit без объяснений.
3. Edge cases: сетевые сбои
Самый «грязный» класс багов — когда платёж прошёл у платформы, но клиент об этом не узнал. Проверять симуляцией:
- Network drop после оплаты: покупка ушла в App Store / Play, ваш сервер про неё не знает. Платформа удерживает receipt — приложение должно поднять её при следующем старте и грантнуть товар.
- Server 500 при receipt-валидации: клиент получил receipt, отправил на ваш бекенд, бекенд упал. Retry-логика? Сколько раз? Через какие интервалы? Сохраняется ли receipt локально для повтора?
- Slow network (10 sec timeout): platform отдала receipt через 8 секунд после тапа. Loading-индикатор не залип? Не показалась ли ошибка «timeout» раньше времени? Используйте Network Conditioner — 3G / packet loss / high latency.
- App в фоне во время оплаты: платёжный диалог вылез поверх — юзер свернул приложение. Возврат через минуту — состояние корректное.
4. Restore Purchases
Эта кнопка обязательна для App Store review. Тестируют её редко. Самые частые баги тут.
- Restore после reset устройства: купили No-Ads → сбросили девайс → переустановили игру → залогинились тем же Apple ID → тап Restore → No-Ads должен включиться. Без покупок повторно.
- Restore с другим Apple ID: купили на ID-A → залогинились на ID-B → Restore. Не должно ничего восстановиться, должна быть понятная ошибка.
- Restore без покупок: никогда ничего не покупал → тап Restore → не падает, показывает «No purchases to restore».
- Двойной Restore: два тапа подряд → один запрос на сторе, не двойной grant.
- Restore для consumable: consumable-покупки (монеты, gems) не должны восстанавливаться через Restore. Только non-consumable и subscriptions. Логика: купил 100 coins → потратил → Restore не должен вернуть 100 монет обратно.
5. Подписки: свой зоопарк сценариев
Если игра продаёт подписку (Battle Pass, VIP, No-Ads-monthly) — тестировать в разы больше:
- Auto-renew: активная подписка возобновилась автоматически — клиент это узнал? UI показывает корректный статус? В sandbox iOS подписка возобновляется на ускоренном таймлайне (5 минут вместо месяца).
- Upgrade / Downgrade: переход с Monthly на Yearly или наоборот. В Play Console — четыре режима proration: ImmediateWithTimeProration, Charge prorated, Without proration, Deferred. Проверьте, какой у вас выставлен, и что юзер получает ровно то что задокументировано.
- Grace period: подписка экспирировала, оплата провалилась → платформа даёт 16 дней grace period на retry. Юзер должен в это время продолжать иметь доступ ко всему premium.
- Billing retry: iOS / Android самостоятельно пробуют списать ещё раз. UI должен показать «Payment issue» с deep-link на Subscriptions settings.
- Cancel mid-period: юзер отменил подписку через Settings App Store → доступ должен сохраниться до конца оплаченного периода, а не сразу пропасть.
- Refund: юзер получил refund через support → ваш сервер получит
REFUNDserver notification → надо отозвать grant. Часто пропускают. - Family Sharing: если включён — подписка одного члена семьи доступна всем. Receipt в этом случае приходит на каждом устройстве, с тем же original_transaction_id.
6. Receipt validation: security
Если ваш сервер не валидирует receipt у Apple / Google — у вас нет IAP. Кто угодно может попросить локальный код «выдай мне товар» и оно сработает. Что должен делать сервер:
- Получить от клиента
receipt(iOS) илиpurchaseToken(Android). - Отправить на
verifyReceiptendpoint Apple илиpurchases.products.getAPI Google. Apple guide, Google guide. - Проверить:
status(валидно ли),bundle_id(наше ли приложение),product_id(тот ли товар),transaction_id(не повторный ли — для consumable важно). - Только после успешной валидации — гранить товар в БД пользователя. И только тогда отвечать клиенту OK.
QA-проверка: попросите разработчика подменить ответ платформы (через Proxyman Map Local) на «success» с поддельным receipt → ваш бекенд должен отказать в выдаче товара. Если выдал — security-бомба.
7. Локализация цен и регионов
- Цена с правильным символом валюты и форматом: ₽299 (РФ), 2,99 € (Германия), $2.99 (US), R$ 14,90 (Бразилия). Не хардкодьте «$» — берите
localizedPriceString(iOS) /formattedPrice(Android Billing 5). - Регион VPN: если юзер залогинен в US App Store, но физически в RU — цена должна показываться по US-стору, не по геолокации. Иначе UX-баг.
- Доступность: проверьте, что товар активен во всех target-странах. В App Store Connect — Pricing and Availability → list of countries. В Play Console — Pricing → Countries / regions.
- Tax behaviour: в некоторых регионах налог включён, в некоторых добавляется отдельно.
priceв API может быть pre-tax или post-tax. Сверяйтесь со сторой, а не считайте сами.
8. Аналитика и серверная сверка
Покупка — это четыре события, которые должны сойтись:
- Клиент послал
purchase_attemptedevent. - Платформа подтвердила платёж → клиент шлёт
purchase_completedс suma, валютой, product_id. - Сервер валидировал → шлёт со своей стороны
purchase_validated(на BI). - Apple/Google присылают S2S-нотификацию → сервер сверяет с тем, что было на клиенте.
Если одно из событий пропускается — у вас «дыра» в воронке. Проверяйте через Proxyman + Amplitude / Firebase real-time view одновременно.
9. Common bugs из практики
- Double-grant — товар выдан дважды (например, после восстановления pending-транзакции). Причина: нет идемпотентности по
transaction_idна сервере. - Lost purchase — юзер заплатил, товар не пришёл, support вытаскивает руками. Причина: receipt не дошёл до сервера (network drop), pending не обработан при следующем релонче.
- Stuck loading spinner — после тапа на Buy показывается loading и не сходит. Причина: не повешено timeout на StoreKit-promise, нет error handling.
- Wrong product price — UI показывает $4.99, а списывается $9.99. Причина: цена хардкоднута, а не берётся из StoreKit
SKProduct.price. - Grant без оплаты — взломанный клиент шлёт «куплено» на бекенд без receipt → товар выдаётся. Причина: нет server-side валидации.
- Restore не работает на новом девайсе — переехал на новый iPhone, Restore не возвращает No-Ads. Причина: бекенд хранит entitlement по device-id, а не по Apple ID.
- Subscription “flicker” — UI на 1 секунду показывает Free, потом Premium, потом снова Free. Причина: гонка между локальным receipt и server-validated state.
Чек-лист на каждый IAP
- Sandbox-аккаунт работает, покупка проходит, событие аналитики ушло
- Отмена платёжного диалога не имеет побочных эффектов
- Double-tap не приводит к double-grant
- Network drop посреди транзакции — pending обрабатывается при релонче
- Restore работает: возвращает non-consumables и subscriptions, не возвращает consumables
- Receipt валидируется на сервере, не на клиенте
- Цена показывается локализованной (валюта + формат)
- Для подписок: auto-renew, cancel, grace period, refund — все проверены
- Server S2S notifications обрабатываются (REFUND, CANCEL, RENEW)
- Аналитика идёт на все события воронки
- Идемпотентность по
transaction_idна сервере — повторный receipt не даёт повторного гранта