StoreKit Testing を使った自動テストをアプリに導入した話

『スタディサプリ ENGLISH』 Mobileチームのhirothingsです。
ENGLISHのiOSアプリでは、StoreKit Testingを活用してIn-App Purchase(以下、IAP)の安定運用を行っています。

この記事では、

  • StoreKit Testingの自動テスト導入
  • 最新のSandbox事情

について解説します。

StoreKit Testingとは

WWDC2020で発表されたApp Storeサーバに接続することなく、アプリ内課金をテストするためのローカル環境の総称です。

StoreKit Configファイルで設定したプロダクト情報を元に

  • ローカル(シミュレータでも可)で課金処理のシミュレーション
  • 自動ユニットテスト

の実行ができます。

設定については割愛します。ドキュメントはこちらです

StoreKit Testを使った課金テストの自動化

StoreKit Testingを導入すると、IAPの自動テストを導入できます。
StoreKit Configで設定したプロダクト情報を利用して様々なテストが可能です。
2022.02現在、Englishアプリでは自動更新購読型の課金処理の下記のテストを導入しています。

  • プロダクトの取得
  • 購読(正常系)
  • 購読(異常系)
  • 決済は正常終了したが、アプリサーバー側でエラーになったケース

(その他にも、購読処理が中断された場合のテストなど様々なテストが可能)
実装にはStoreKit Testフレームワークを使います。

制限事項

1点、実装前に知っておくべき制限事項があります。
StoreKit Testingを用いてローカルで生成したレシートは、本番環境とは署名が異なるため、レシート検証API(verifyReceipt)は必ず失敗します。
大半のサービスはレシートの不正利用を防ぐため、サーバーレシート検証を実装していると思いますが、この部分まで一気通貫でテストはできません。

[ App ] - [ StoreKit Testing ] - [ App Server ] -×- [ App Store ]

ユニットテストではこのAPIにリクエストするRepositoryをモック化することで対応しています。
詳細はコードを交えて解説します。

Productの取得のテスト

SKTestSessionを使い、StoreKit Configで設定したProduct情報が取得できるかテストできます。
(分かりやすさのため、実コードの一部を簡略化しています)

重要なポイント

SKTestSessionはテスト環境で1つのインスタンスを共有するので、他の課金テストの影響を受けないようテスト前にresetToDefaultState(), clearTransactions()を呼んで、状態をリセットすると良いです。(1)
この処理を書かないと、session.failTransactionsEnabled = trueなどの設定が保持されたまま後続のテストに副作用を与えてしまいます。

override func setUp() {
    session = try! SKTestSession(configurationFileNamed: "Configuration")
    // (1)テスト間で副作用のないように、SKTestSessionの状態をリセットする
    session.resetToDefaultState()
    session.clearTransactions()
    // disableDialogs = trueにするとUnitテスト用に課金周りのダイアログが出なくなる
    session.disableDialogs = true
    session.locale = Locale(identifier: "ja_JP")
    apiClient = APIClient.mock()
    repository = SubscriptionPlanRepositoryImpl(apiClient: apiClient)
    APIClient.registerStub(jsonFile: "iap_product_id.json", for: ProductIdRequest(paymentItemType: paymentItemType))
}
func test_プランの取得ができること() {
    do {
        // [補足]このrepository経由でSKProductsRequestを呼んでいる。そのテストをしている
        // テストコードには、RxBlockingを使っています
        let product = try repository.findPlan(with: paymentItemType).toBlocking().first()!
        XCTAssertEqual(product.name, "月額")
        XCTAssertEqual(product.price, 3700)
        XCTAssertEqual(product.freeTrialDays, 7)
        XCTAssertEqual(product.paymentItemType, PaymentItemType.toeic1month)
    } catch {
        XCTFail(error.localizedDescription)
    }
}

購読のテスト(正常系 / 異常系)

購読のテストは正常系だけでなく、StoreKit側で何らかのエラーがあった場合の異常系もテストできます。
前述した通り、サーバーレシート検証はローカルで生成したレシートではできないのでレシート検証APIを叩くリクエストはモック化してテストしています(1)

override func setUp() {
    session = try! SKTestSession(configurationFileNamed: "Configuration")
    session.resetToDefaultState()
    session.clearTransactions()
    session.disableDialogs = true
    session.locale = Locale(identifier: "ja_JP")
    // (1)productId取得と、レシート検証APIについてはモック
    APIClient.registerStub(jsonFile: "iap_product_id.json", for: ProductIdRequest(paymentItemType: .toeic1month))
    APIClient.registerStub(jsonFile: "success.json", for: ValidateReceiptRequest(receiptData: ""))
    repository = SubscriptionRepositoryImpl(
        apiClient: APIClient.mock(),
        paymentTransactionObserver: PaymentTransactionObserverImpl(),
        paymentQueue: SKPaymentQueue.default(),
        userRepository: MockPremiumUserRepository() // (1)プレミアムユーザーを返すモック
    )
}
func test_正常にプラン購読できること() {
    repository.setupPayment()
    do {
        try repository.purchase(with: .toeic1month).toBlocking().first()
        // [補足]決済 - ステータス変更まで完了したら、finishTransactionObservableの川を流しているので、
        // それを検証している
        let result: Void? = try repository.finishTransactionObservable.toBlocking().first()
        XCTAssertNotNil(result)
    } catch {
        XCTFail(error.localizedDescription)
    }
}
func test_StoreKit側でエラーが発生した場合() {
    session.failTransactionsEnabled = true // transactionを失敗させる場合はこのフラグをON
    repository.setupPayment()
    do {
        try repository.purchase(with: .toeic1month).toBlocking().first()
        try repository.finishTransactionObservable.toBlocking().first()
    } catch {
        XCTAssertEqual(error as? InfrastructureError, InfrastructureError.purchase)
    }
}

なお、開発時のシミュレータでのStoreKit Testing利用はそこまで活発に行っていません。
StoreKit2に差し替えるタイミングなど課金周りに改修が入った際に検討したいと思います。

課金処理 x モジュール分割について

Englishアプリでは機能単位でのモジュール分割を進めており、現在下記の構成でモジュールを分けています。

課金処理もモジュールとして分割しています。
課金処理がモジュールにまとまっているので、モジュールのディレクトリ配下に変更が入った場合、プルリクエスト時Dangerでアラートが出せます。
これによって、課金処理のコードに手が入ったときにアラートを通して気づけるようにしています。

Englishアプリは、Targetで複数のアプリをビルドするプロジェクト構成なのですが、学校向けのアプリは課金処理が必要ありません。
課金処理が必要ないアプリは課金モジュールをモックにして依存させることで、課金のないアプリに副作用が起きないようにしています。
また、モジュールに紐づいたライブラリの依存がなくなるのでバイナリサイズが軽減されます。

Tips: モジュール分割下でのStoreKit Testについて

各モジュールのテストは、アプリの依存を剥がす & テスト速度高速化のため、Host Application: Noneの指定をしているのですが、課金のテストはレシートを Bundle.main.appStoreReceiptURL のメインバンドルから参照するため、Host Applicationの指定が必要でした。
そのため、課金処理のコアロジックのみテストターゲットを分け、そのテスト郡のみHost Applicationを指定する構成でテストを運用しています。

Sandboxのここ数年のアップデートについて

Sandbox環境もここ数年のアップデートでかゆいところに手が届く作りになってきており、自動テストと併用して利用しています。

iOS14~購読のキャンセル・再開・ダウングレード・アップグレードなど、テスト可能に

設定.app > App StoreのSandbox設定画面から購読のキャンセルなどが可能になりました。
アプリ外で購読キャンセル→再開したときにアプリのユーザーステータスが適切に切り替わっているかなど様々なケースの検証が可能になっています。

2021/10~ AppStore Connectで購入履歴の削除ができるようになりました。

これにより、なんとなくSandbox環境が不安定な時に、アカウントを量産する作業からついに解放されました。

公式ドキュメント
https://help.apple.com/app-store-connect/#/dev7e89e149d

まとめ

複雑な課金処理の実装ですが、ここ数年のアップデートで徐々に開発・テストしやすくなってきています。
課金処理の安定稼働の参考になれば幸いです。