[Xcode 12] ローカルで課金(StoreKit)のテストをする方法について

XCode12からの新機能のひとつとして、ローカル環境で課金(StoreKit)のテストをする環境が追加されました。 この記事では、そんなローカルで課金のテストをする方法を試しています。
2020.10.09

はじめに

XCode12からの新機能のひとつとして、ローカル環境で課金(StoreKit)のテストをする環境が追加されました。 StoreKit構成ファイルをローカルで作成し有効にすると、App Storeのサーバーに接続しないで課金のテストを実行することが出来ます。

今回はローカルで課金をしてみるまでの流れを簡単にではありますが試してみました。

具体的にどんなことが出来るようになったの?

Appleのサイトによると以下の項目が挙げられています。

  • 開発の初期段階、またはApp Store Connectでアプリ内購入を構成する前に課金ができる。
  • ネットワーク接続が利用できない場合のローカルテスト。
  • Sandboxでの設定が難しいアプリ内購入のユースケースのデバッグ(プロモーション特典の利用資格など)
  • 支払いシートでローカライズされた製品情報を表示。
  • 失敗したトランザクションを含め、トランザクションをエンドツーエンドでテストできる。
  • XcodeでのStoreKitテストのを自動化出来る。(アプリ内購入テストの自動化については、StoreKit Testを参照)

試してみる

StoreKit構成ファイルを作成する

まず、ローカルでの課金テストをするためには、StoreKit構成ファイル(StoreKit configuration file)を作成する必要があります。

  • [File] > [New] > [File...] を選択します

  • StoreKit Configuration File を選択し、新規でファイルを作成します

  • 作成されたファイルの左下の+ボタンから各種課金設定を追加します

  • +ボタンを選択すると課金種別の選択が表示されます

上から、消耗品型、非消耗品型、定期購読型ですね。 各アイテムを選択するとそれぞれの設定画面が表示されます。

Add Consumable In-App Purchase (消耗品型)

Add Non-Consumable In-App Purchase (非消耗品型)

Add Auto-Renewable Subscription (定期購読型)

定期購読型のみ、最初にSubscription Groupの設定画面が表示されます。

  • 各種、必要に応じて課金アイテムを設定する

Reference Name、Product ID、金額等を設定します。(Product IDは後で課金アイテム一覧を取得するところで必要になります)

ローカル課金を有効にする

設定ファイルを作成しただけでは、ローカルでの課金テストは有効にはなっていません。スキームを作成し、そのスキームに対してStoreKit構成ファイルを有効にする設定をします。StoreKit構成ファイルは複数作成出来ますが、スキームに対して有効にできるのは1ファイルのみです。

対象のスキーム設定画面の [Run] > [Options] > [StoreKit Configuration] から対象の .storekit ファイルを選択します。

これで、このスキームを選択している間はローカルの設定ファイルが有効になりました。

また、元に戻す(App Storeのサーバーに接続して課金するようにする)には、Noneを選択します。

StoreKit構成ファイルの各種設定

ローカルでの課金テストのために、各種設定をすることができます。Xcode上で.storekit ファイルを選択した状態でメニューバーから [Editor] を選択します。

デフォルトの通貨

[Editor] > [Default Storefront]

デフォルトの言語

[Editor] > [Default Localization]

Time Rate

[Editor] > [Time Rate]

(見た感じ、時間経過を変えられる設定なのかな。Subscription型のテストに使うと思われる)

購入を失敗させる

[Editor] > [Fail Transactions]

購入履歴を見る

アプリで購入を実行すると、履歴を見ることが出来ます。

[Debug] > [StoreKit] > [Manage Transactions...]

※ ID:4 は失敗させる設定に変えて購入を実行したものです

サンプルコード

蛇足かなと思ったのですが、今回試してみたコードを載せます。アイテム一覧を取得して購入を実行するコードです。(さくっと試しただけなので色々と足りてないです)

StoreKitSampleApp.swift

import SwiftUI

@main
struct StoreKitSampleApp: App {
    
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        StoreKitManager.shared.addPaymentQueue()
        return true
    }
    
    func applicationWillTerminate(_ application: UIApplication) {
        StoreKitManager.shared.removePaymentQueue()
    }
}

StoreKitManager.swift

import Foundation
import StoreKit

class StoreKitManager: NSObject {
    
    typealias ReceiveProductsResponse = ((Result<[SKProduct], Error>) -> Void)

    static let shared = StoreKitManager()
    
    // 構成ファイルせ作成した課金アイテムIDを設定する
    private let itemIdentifiers: Set<String> = [
        "storekit.testing.item1",
        "storekit.testing.item2",
        "storekit.testing.item3",
    ]
    
    private var onReceiveProductsHandler: ReceiveProductsResponse?

    func getProducts(_ handler: @escaping ReceiveProductsResponse) {
        onReceiveProductsHandler = handler
        let request = SKProductsRequest(productIdentifiers: itemIdentifiers)
        request.delegate = self
        request.start()
    }
    
    func purchase(product: SKProduct) {
        if SKPaymentQueue.canMakePayments() {
            let payment = SKPayment(product: product)
            SKPaymentQueue.default().add(payment)
        }
    }
    
    func addPaymentQueue() {
        SKPaymentQueue.default().add(self)
    }

    func removePaymentQueue() {
        SKPaymentQueue.default().remove(self)
    }
}

extension StoreKitManager: SKProductsRequestDelegate, SKRequestDelegate {
    
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        onReceiveProductsHandler?(.success(response.products))
    }
    
    func request(_ request: SKRequest, didFailWithError error: Error) {
        print("didFailWithError ", error)
    }
    
    func requestDidFinish(_ request: SKRequest) {
        print("request did finish")
    }
}

extension StoreKitManager: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
    }
    
    func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
        print("restoreCompletedTransactionsFailedWithError ", error)
    }
    
    func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
        print("transactions finished")
    }
}

ProductItem.swift

import Foundation
import StoreKit

struct ProductItem: Identifiable {
    let product: SKProduct
    let id: String
    var price: String {
        let formatter = NumberFormatter()
        formatter.formatterBehavior = .behavior10_4
        formatter.numberStyle = .currency
        formatter.locale = product.priceLocale
        return formatter.string(from: product.price)!
    }
}

ContentViewModel.swift

import Foundation
import StoreKit

class ContentViewModel: ObservableObject {
    @Published var products: [ProductItem] = []
    
    init() {
        loadProducts()
    }
    
    func loadProducts() {
        StoreKitManager.shared.getProducts { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success(let skProducts):
                    var list: [ProductItem] = []
                    skProducts.forEach {
                        list.append(ProductItem(product: $0, id: $0.productIdentifier))
                    }
                    self?.products = list
                case .failure(let error):
                    print(error.localizedDescription)
                }
            }
        }
    }
    
    func purchase(_ product: SKProduct) {
        StoreKitManager.shared.purchase(product: product)
    }
}

ContentView.swift

import SwiftUI

struct ContentView: View {
    
    @ObservedObject var viewModel = ContentViewModel()
    var body: some View {
        List {
            ForEach(viewModel.products) { item in
                Button(action: {
                    viewModel.purchase(item.product)
                }, label: {
                    ListCell(item: item)
                })
            }
        }
    }
}

ListCell.swift

import SwiftUI

struct ListCell: View {
    var item: ProductItem
    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(item.product.localizedTitle)
                Text(item.product.localizedDescription)
                    .font(.caption)
            }
            Spacer()
            Text(item.price)
                .foregroundColor(.gray)
        }
    }
}

さいごに

今までは課金のテストをする際はSandboxでテスターのアカウントを登録したりと手間が掛かっていましたが、それが不要になったことで手軽にテストが出来るようになったと思います。StoreKit構成ファイル(StoreKit configuration file)、とても便利だと思いました。