Open13

Xcode: SKTestSessionを使う

kabeyakabeya

XcodeでStoreKit/StoreKit2のUnitTestを自動実行するには、SKTestSessionというクラスを使います。
その覚え書き。

  • SKTestSession(configurationFileNamed: ".storekit設定ファイル名(拡張子を除く)")で「ファイルが見つかりません」エラーが出て進めない場合があります。解消するには、.storekitファイルをUnitTestターゲットの対象に含めます。
    具体的には、Xcode上で.storekitファイルを選んだ状態で右サイドパネルを開き、Target MembershipのところでUnitTestターゲットのチェックボックスにチェックを入れます。
kabeyakabeya

SKTestSession.localeおよび.storefrontはテストコード内からコードで変更できます。

session.locale = Locale(identifier: "ja_JP")
session.storefront = "JPN"

ちなみに、app.launch()の後に設定しても変わらないようです。app.launch()の前で設定します。
あと、.storefrontの文字列がどういう体系なのかまだ調べていません。
日本は"JPN"、米国は"USA"のようです。

kabeyakabeya

StoreKitのProduct.displayPriceは、iOS 16では"$100.00""¥100"を返しますが、もしかするとiOS 17では"US$100.00""JP¥100"を返すようになるのかも知れません。

kabeyakabeya

SKTestSession.clearTransactions()がSimulatorのトランザクションをクリアしてくれません。

具体的には、setUpWithError()内で.clearTransactions()を呼ぶのだけども、Simulatorに購買履歴が残ったままの様子で、初回は未購入の状態からテストできるのに、2回目以降は購入済みになっています。

もう少し詳しく言うと、以下のようになります。

  • デバッグ実行するときのSimulatorとテスト実行するときのSimulatorが違っている(インスタンス?というのかな)。
  • テスト実行のほうのSimulatorには「Clone 1 of iPhone…」というような名前がついている。デバッグ実行のほうは「Clone 1」とかはなし。
  • デバッグ実行のほうにはトランザクションが残ってないのだけども、Clone 1のほうには残ってしまっているようで、Clone 1で普通にアイコンタップしてAppを起動すると、購入済み状態になってしまっている。デバッグ実行のほうは未購入に戻っている。
  • .clearTransactions()がデバッグ実行のほうのSimulatorをクリアしているのかというとそうではなくて、単にデバッグ実行の方では購入してないだけ。デバッグ実行のほうで購入すれば購入済みになる。こっちはXcodeのDebugメニュー→StoreKit→Manage Transactions…でトランザクションの編集(削除も)ができる。Clone 1のほうはXcodeからは編集できない。

待機時間を入れたり、.allTransactions().deleteTransaction()したり、順序を入れ替えたり、と色々試した結果、テスト終了時に.clearTransactions()を呼ぶとうまくクリアされるように見えます。
tearDownWithError()に入れるか、個々のテストケースに突っ込むか、考えどころです。
タイミングに依存していると思われるので、他の回避方法もあるような気がします。

Xcode 14.3.1 + iOS 16.4です。

kabeyakabeya

SKTestSessionを使ったテストで、購入処理のあとのダイアログを表示しなくするには、.disableDialogs = trueを設定するのですが、じゃあ表示させてそのダイアログを含めたテストをできないのか?という話です。

このダイアログ、Appから表示されていなくて、Appの上に重ねて表示されているようで、このダイアログが表示された状態でAppを終了してもダイアログだけ残ります。他のAppなのかOSなのか、とにかくAppのプロセスとは切り離されています。

このため、このダイアログで「購入」あるいは「×」ボタンを押す動作のシミュレートはXCUITestではできなさそうです。(.launch()したXCUIApplicationからアクセスする方法がない)

ちなみに、この「×」ボタンを押すとダイアログが閉じて元の画面(通常、自分で作った「購入」ボタンがある画面)に戻るのですが、この閉じて戻る(そしてトランザクションが発生しない)という動きは、ダイアログが表示された状態でホームボタンを押しても発生します。

これを利用すると、このダイアログのボタンが押された後の動作のテスト、というようなこともできそうです。

  • 「購入」ボタンのシミュレーション:あらかじめ.disableDialogs = trueにしてダイアログを出さない
  • 「×」ボタンのシミュレーション:自分の購入ボタンを押した後、XCUIDevice.shared.press(XCUIDevice.Button.home)を呼ぶ
kabeyakabeya

ペアレンタルコントロールで親の承認が必要な場合、Pending Approvalという状態になるようですが、この状態のとき、自前の購入ボタンを再度押せないようにしたほうが良いのか?という疑問があります。

回答が以下にありました。

https://developer.apple.com/forums/thread/706277

There is no need to manage in your UI pending In-app purchases for the 24-hour period. While it's recommended at time of purchases, once the customer navigates away and back to the In-app purchase buy button, they are safe to re-request if they so wish. Any duplicate Ask to Buy requests are consolidated and only resets the 24-hour timer. Once approved, only 1 purchase is completed.

ちょっと分かりにくい説明のような気がするのですが、意味合いとしては以下のような感じではないかと思います。

  • 自前の購入ボタンを押して、「Ask Permission」ダイアログが表示され、そこで「Ask」を選び、画面に戻った、というまさにその時点では、自前の購入ボタンは押せなくなっていることが望ましい。
  • ただし、その後にいったん他の画面にいったりAppから離れたりした後で戻ってきたときには、自前の購入ボタンは押せるようになっていても構わない。仮に押されたとしても、二重購入にはならない。
    • 重複した「Ask to Buy」要求は1つにまとめられる。ただし要求される都度、24時間タイマーがリセットされる。
    • 承認された場合は、1回の購入だけが完了する。

24時間タイマー、というのは以下のページで触れられている機能(太字にしました)のような気がします。

https://support.apple.com/ja-jp/HT201089

管理者がリクエストを却下した場合は、お子様にリクエストが却下されたことを知らせる通知が届きます。管理者がリクエストを見落とした場合や、購入手続きをしなかった場合、お子様はもう一度リクエストを送信する必要があります。iOS 16.1 または iPadOS 16.1 以前では、リクエストを拒否した場合やそのまま閉じた場合、そのリクエストは 24 時間後に削除されます。

これ16.2以降はどうなったのか気になりますが。
削除されないのかな?

ちなみに、「画面に戻った、というまさにその時点」というのはProduct.purchase()を呼んでProduct.PurchaseResultが返ってきているタイミングです。
Pending Approvalの場合、PurchaseResult = .pendingになります。なのでこれを見て購入ボタンを押せないようにすれば良いのだろうと思います。ただそれ以外にも.pendingになるケースがあるようです。とは言え、まだどういうケースなのか調べ切れていません。

kabeyakabeya

ただそれ以外にも.pendingになるケースがあるようです。

以下に記載のある、interrupted purchaseというのが該当するようです。

https://developer.apple.com/help/app-store-connect/test-in-app-purchases-main/test-in-app-purchases

In general, an interrupted purchase is experienced anytime a customer needs to address an issue with their Apple ID. For example, they may need to agree to updated terms and conditions or update an expired payment method.

AppleIDの問題、利用規約の変更への同意がまだ、決済手段の期限切れへの対応(更新)、などの際に発生するということですね。

以下にStoreKit1時代の記載がありました。

https://developer.apple.com/documentation/storekit/in-app_purchase/testing_in-app_purchases_with_sandbox/testing_an_interrupted_purchase

StoreKit2+SKTestSession利用前提で、「Begin testing」部分を書くとすると以下のようになるでしょうか。

【事前準備】

  • Xcodeで.storekitファイルを選び、Editorメニュー→Enable Interrupted Purchasesを選択(メニュー項目がDisable Interrupted Purchasesになります)

【テスト】

  1. ユーザは、アプリを使って自前の「購入」ボタンを押して、アプリ内購入を行います。アプリはProduct.purchase()を呼びます。
  2. システムにより、支払いシートが表示されます。ユーザは「購入」ボタンを押します。SKTestSessionでは.disableDialogs = trueにすると同等の動作になります。
  3. Product.purchase()が、PurchaseResult.pendingを返してくることを確認します。StoreKit.Transactionは取得できません。(なのでTransaction.finish()も呼べません)
  4. XcodeのDebugメニュー→StoreKit→Manage Transactions...で、トランザクションがFailedになっていることを確認します。
  5. XcodeでFailedのトランザクションを選択し、Resolve Issuesボタンを押して利用規約に同意したことをシミュレートします。SKTestSessionでは.resolveIssueForTransaction(identifier:)を呼ぶと同等の動作になります。
  6. Transaction.updatesに新しいトランザクションが生成されて、VerifiedResult<Transaction>.verifiedになっていることを確認します。
  7. アプリでの購入処理(有効化処理)が実行され、サービスまたは製品(機能)が提供されていることを確認します。
  8. アプリが、生成されたトランザクションを.finish()することを確認します。

こんな感じでしょうか。
XCUITestするにはFailedのトランザクションのIDが必要ですが、どう取得するのがいいのか、調べましょうか。

Ask to Buyは、もとのトランザクションが完了されないまま残り(24時間経つと消える)、承認されるとそれが更新されるようなイメージなのに対し、interrupted purchaseの場合は最初のトランザクションがFailedのまま完了し(StoreKit1では自分で完了させ)、新たなトランザクションが生まれるようです。

kabeyakabeya

XCUITestするにはFailedのトランザクションのIDが必要ですが、どう取得するのがいいのか、調べましょうか。

このトランザクションはStoreKit.Transactionではなくて、SKTestTransactionでした。

テストなのでSKTestSession.allTransactions().lastで取得すれば充分ではないかと思います。

kabeyakabeya

StoreKit2の場合、「Ask to Buy」で承認された場合は、Transaction.updatesに更新されたトランザクションが入ってきます。一方、却下された場合は(おそらく24時間タイムアウト時も)何も起きません。

.pendingになったときに購入ボタンを押せなくする場合、却下後に元に戻す(購入ボタンを再度押せるようにする)きっかけがありません。

どうするのがいいのでしょうか。

kabeyakabeya

.pendingになったときに購入ボタンを押せなくする場合、却下後に元に戻す(購入ボタンを再度押せるようにする)きっかけがありません。

interrupted purchaseの場合も同様で、再度押せるようにするきっかけがありません。

自前の購入ボタンを押して、「Ask Permission」ダイアログが表示され、そこで「Ask」を選び、画面に戻った、というまさにその時点では、自前の購入ボタンは押せなくなっていることが望ましい。

ということだったのですが、StoreKit2の処理の流れ的に、たぶんこれは無理ですね。購入したときの通知はあるものの、購入しないときには何もトリガーがありません。なので、押せなくしたときに押せるように戻すのがタイマーぐらいしかなくなります。そのタイマーも妥当性のある間隔というのが存在しない。

「押せてしまっても良い」ということだったのでそうします。

kabeyakabeya

ふと思いましたが、interrupted purchaseのケースで、ずっとクレジットカードの期限が切れたまま放置していて、購入したことを忘れていたようなパターンがあったとして、何かのきっかけでクレジットカードの登録内容を更新したら、ずいぶん前に購入ボタンを押したものがピロッと支払われてしまう、というケースもありそうです。

こっちにもタイムアウトあるのでしょうか。

kabeyakabeya

https://adapty.io/blog/ios-skerrordomain-error-codes/

に、StoreKitでのエラーへの対処方法案が書かれています。

ただ、StoreKit2になって、このうちの多くのエラーケースがProduct.PurchaseResult = .successにならずに、.pendingになってしまって、エラー自体が取れずApp側で原因を分類できない(メッセージの表示分けとかができない)ような気がしています。

.successにならないとVerificationResultも取れず、そこからのエラーも取れません。
Transaction.unfinishedとか.allで、未完了のトランザクションを取って確認するべきかと思いきや、.allにもトランザクションがいないという状態です。

普通、どうするものなのでしょうか。