iOS課金処理Unitテスト(StoreKit2)を書いてみた

# はじめに
この記事は [and factory.inc Advent Calendar 2024](https://qiita.com/advent-calendar/2024/andfactory) 6日目の記事です。

and factoryでiOSエンジニアをやっています。
最近の開発でやったことを書こうと思います。

# 概要
現在、自分の所属プロジェクトでStoreKit2移行を行なってます。
課金処理を変更するのは非常にデリケートかつ、こけた場合直接損害が出やすい部分です。なので、既存の課金処理に対してテストコードを用意して安全に移行作業を行おうと考えました。
以前課金処理をアプリで書いたタイミングでは、Appleサーバーとのやり取りが必要だったりと環境依存なところが多く、断念していましたが、.storekitの登場であまり環境に縛られずUnitテストを書くことができます。
.storekitを用いた課金処理のUnitテストを書いていく上で色々他のUnitテストとは書き方、設定などが必要でしたので、そちらをつらつらと書いていきます。

# .storekitファイルの用意
.storekitのファイルは、StoreKitの環境をApple上のサーバーに接続せず、ローカル環境で課金環境を構築して課金のテストを行うことができます。
テストコードでもこちらの設定ファイルを元にテストを書くことができるため用意します。
[File]->[New...]で検索に「storekit」入力すると出てくるファイルを作成します。

![スクリーンショット 2024-12-02 11.28.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/369939/bc5b7553-9e8d-d6f3-854f-9d794d91f990.png)

## 課金アイテムの登録
.storekitファイルの中で任意の課金アイテムを登録します。
もろもろ登録方法、課金アイテム設定、ロケーション設定などの説明は本筋ではないので、割愛します。知りたい方は[公式ページ](https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode/)をご覧ください。

![スクリーンショット 2024-12-02 11.48.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/369939/1b951f15-8b2b-0c4f-24f1-dd07262758e2.png)

# テストコード
では実際にテストコードの方で.storekitのファイルを用いて書いてみましょう。

## setUp処理

```swift
final class StoreKit2ProductTests: XCTestCase {

    // 割愛...
    
    override func setUp() {
        // その他setup処理
        // ...
        
        // (1) 課金環境構築
        session = try! SKTestSession(configurationFileNamed: "{前述で作成した.storekitファイル名}")
        // (2) テスト間で副作用が起きないように
        session.resetToDefaultState()
        session.clearTransactions()
        
        // (3) ダイアログ表示をさせない
        session.disableDialogs = true
        session.locale = Locale(identifier: "ja_JP")
    }
}    
```

setUp処理では、ローカルでの課金環境を構築する処理を書いています。基本的に上記の処理で構築ができましたので、テストを書くことができます。
1. 課金環境を構築して保持
2. テスト間で副作用が起きないようにSKTestSessionの状態をリセット
3. Unitテストなので、ダイアログ表示をOFF

以下のサイトを参考にしました。
https://techblog.recruit.co.jp/article-651/

## 実際のテストコード
setUpでローカル課金環境が立ち上げられたので、実際に課金アイテム取得部分の以下の2パターンでコードを書いてみます。
1. 指定したProductID配列で取得できているか
2. 無効なProductID配列を含んだ配列で無効なProductを省いて取得できているか

```swift
// StoreKit2はiOS15以上
@available(iOS 15.0, *)
final class StoreKit2ProductTests: XCTestCase {

    // 割愛...
    
    // 1. プロダクトを取得して正しいか検証するテストメソッド
    func testFetchProducts() async throws {
        // テストで使用するプロダクトIDのリスト
        let productIds = [
            "gold.test.ios.tier1",
            "gold.test.ios.tier3",
            "gold.test.ios.tier6"
        ]
    
        // StoreKitからプロダクトを取得
        let fetchedProducts = try await fetchProducts(for: productIds)
        
        // 取得したプロダクトの数が指定したプロダクトIDの数と一致しているか確認
        XCTAssertEqual(fetchedProducts.count, productIds.count, "取得したプロダクトの数が期待する数と一致しません")
        
        // 各プロダクトIDが取得結果に含まれているか確認
        let fetchedProductIds = fetchedProducts.map { $0.id }
        for productId in productIds {
            XCTAssertTrue(fetchedProductIds.contains(productId), "プロダクトID \(productId) が取得結果に含まれていません")
        }
    }

    // 2. 存在しないプロダクトIDが取得結果に含まれていないことをテストするメソッド
    func testFetchProductsWithInvalidProductID() async throws {

        // テスト対象のプロダクトIDリスト（存在しないIDを含む）
        let productIds = [
            "gold.test.ios.tier1",
            "gold.test.ios.tier3",
            "gold.test.ios.tier6",
            "gold.test.ios.tier9999" // 存在しないプロダクトID
        ]
        
        // StoreKitを使用してプロダクトを取得
        let fetchedProducts = try await fetchProducts(for: productIds)
        
        // 取得されたプロダクトのIDを取得
        let fetchedProductIds = fetchedProducts.map { $0.id }
        
        // 有効なプロダクトID（存在するIDのみ）を定義
        let validProductIds = [
            "gold.test.ios.tier1",
            "gold.test.ios.tier3",
            "gold.test.ios.tier6"
        ]
        
        // 取得されたプロダクトが有効なIDと一致することを確認
        for validId in validProductIds {
            XCTAssertTrue(fetchedProductIds.contains(validId), "有効なプロダクトID \(validId) が取得結果に含まれていません")
        }
        
        // 存在しないプロダクトIDが取得結果に含まれていないことを確認
        let invalidProductId = "gold.test.ios.tier9999"
        XCTAssertFalse(fetchedProductIds.contains(invalidProductId), "無効なプロダクトID \(invalidProductId) が取得結果に含まれています")
    }
    
    // プロダクトを取得するヘルパーメソッド
    private func fetchProducts(for ids: [String]) async throws -> [Product] {
        // 指定したIDに基づいてプロダクトを取得
        let products = try await Product.products(for: ids)
        return products
    }
}
```

# まとめ
以上で簡単ではありますが、環境にあまり縛られることなく、課金に関するテストが書けました。
実行してもローカル環境だからか気になるテスト実行スピードも早いです。

実際には、APIから取得想定のものをモックで返却して、そちらを用いてのAppleに問い合わせての確認というテストコード（Unitテストではない）を書いています。
結局は課金処理のテストコード書いたからといって、動作確認、テスト工数を省けるわけではないですが、安心材料にはなりますので、ぜひ皆さんも試してみてください。

まだ課金実行のテストは書けてないので、書けたら別途記事書きたいなと思います。