[UnitTest]StoreKitTest を使って、In-App Purchase をテストする方法

StoreKitTest を使ったテストの方法を紹介します

StoreKit

すでに使われたプロジェクトがある前提で説明します。

非同期処理を含んだテストになるので、XCTestCase 側で 以下のタイミングを知ることができる必要があります。

  • ProductRequest が終了したタイミング
  • PaymentQueu の Transaction の処理が終了したタイミング

テストの内容

以下の内容の UnitTest を実装していきます。

  • AppStore 側から、登録してあるはずの SKProduct 情報がきちんと取得できるか
  • 購入処理をすると、アプリ内部の購入済みフラグが、true にセットされるか
  • 外部購入していると、アプリ起動時に内部の購入済みフラグが適切にセットされるか(購入履歴あり・なし の2ケース)
  • 返金処理をすると、アプリ内部の購入済みフラグが、false にセットされるか

Subscription 等は、複雑になりそうなので、必要性に応じて。

テスト対象コード

Store というクラスを作り、StoreKit とのやり取りをまとめました(WWDC2020 を真似てます)

Store code

//
//  Store.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/24
//  © 2020  SmallDeskSoftware
//

import Foundation
import StoreKit
import os

typealias FetchCompletionHandler = ( ([SKProduct]) -> Void )
typealias PurchaseCompletionHandler = ( (SKPaymentTransaction) -> Void )

class Store: NSObject, ObservableObject {
    @Published var allItems = [StoreItem]()
    
    private let allProductsIdentifiers = Set([
        "com.smalldesksoftware.IAP.item1",
        "com.smalldesksoftware.IAP.item2"
    ])
    
    private let log = Logger(subsystem: "com.smalldesksoftware.IAP.Store", category: "Store")
    
    @Published var purchsedProductIDs: [String] = [] {
        didSet {
            DispatchQueue.main.async {
                for index in self.allItems.indices {
                    self.allItems[index].isLocked = !self.purchsedProductIDs.contains(self.allItems[index].id)
                }
            }
        }
    }
    
    private var productsRequest: SKProductsRequest? = nil
    private var fetchedProducts:[SKProduct] = []
    private var fetchCompletionHandler: FetchCompletionHandler? = nil
    private var purchaseCompletionHandler: PurchaseCompletionHandler? = nil
    
   override init() {
        log.debug("Store.init")
        super.init()
        startObservingPaymentQueue()
    }

    public func fetchProducts(_ additionalHandler: FetchCompletionHandler? = nil ) {
        fetchProducts { products in
            self.allItems = products.map{StoreItem(product: $0)}
            self.log.debug("completion in fetchProduct")
            additionalHandler?(products)
            self.log.debug("completion in additional fetchProductHandler")
        }
    }
    
    private func startObservingPaymentQueue() {
        SKPaymentQueue.default().add(self)
    }
    
    private func fetchProducts(_ completionHandler: @escaping  FetchCompletionHandler  ) {
        log.debug("fetchProducts called")
        guard self.productsRequest == nil else { return }
        fetchCompletionHandler = completionHandler
        productsRequest = SKProductsRequest(productIdentifiers: allProductsIdentifiers)
        productsRequest?.delegate = self
        productsRequest?.start()
        log.debug("productsRequest start")
    }
    
    private func buy(_ product: SKProduct, completion: @escaping PurchaseCompletionHandler) {
        purchaseCompletionHandler = completion
        let payment = SKPayment(product: product)
        SKPaymentQueue.default().add(payment)
    }
    
}

extension Store {
    func product(for identifier: String) -> SKProduct? {
        return fetchedProducts.first(where: {$0.productIdentifier == identifier})
    }

    func purchaseProduct(_ product: SKProduct) {
        startObservingPaymentQueue()
        buy(product) { _ in }
    }
    
    func restorePurchase() {
        startObservingPaymentQueue()
        SKPaymentQueue.default().restoreCompletedTransactions()
        return
    }
}

extension Store: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        log.debug("paymentQueue.updatedTransactions called")
        for transaction in transactions {
            var shouldFinishTransaction = false
            switch transaction.transactionState {
                case .purchased, .restored:
                    purchsedProductIDs.append(transaction.payment.productIdentifier)
                    shouldFinishTransaction = true
                case .failed:
                    shouldFinishTransaction = true
                case .purchasing, .deferred:
                    break
                @unknown default:
                    break
            }
            if shouldFinishTransaction {
                SKPaymentQueue.default().finishTransaction(transaction)
                DispatchQueue.main.async {
                    self.purchaseCompletionHandler?(transaction)
                    self.purchaseCompletionHandler = nil
                }
            }
        }
    }
}

extension Store: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        log.debug("productsRequest.didReceive called")
        let products = response.products
        let invalidProducts = response.invalidProductIdentifiers
        
        guard !products.isEmpty else {
            log.error("could not get the products info from AppStore")
            if !invalidProducts.isEmpty {
                log.error("invalid products \(invalidProducts)")
            }
            productsRequest = nil
            return
        }
        
        fetchedProducts = products
        
        DispatchQueue.main.async {
            self.fetchCompletionHandler?(products)
            self.fetchCompletionHandler = nil
            self.productsRequest = nil
        }
        
    }
    
    func request(_ request: SKRequest, didFailWithError error: Error) {
        log.error("Error \(error.localizedDescription)")
    }
}

このクラスに対してのテストを作っていきます。

テストのポイント

  • 非同期処理のため、適宜、「待つ」必要があります
  • 処理によって、XCTestExpectation を .fulfill するのは、Delegate だったり、observer だったりします

テストコード

製品情報が取得できているか

まずは、製品情報(SKProduct)が正しく取得できるかのテストを作ります。

製品情報は非同期で読み込まれますので、delegate 内で、処理完了を待ちます。

example code

//
//  BuyTests.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/05
//  © 2020  SmallDeskSoftware
//

import XCTest
import StoreKit
import StoreKitTest

class BuyTests: XCTestCase {
    private var session: SKTestSession!
    private var store: Store!
    
    private var productRequestDone: XCTestExpectation = XCTestExpectation()

    let idForItem1 = "com.smalldesksoftware.IAP.item1"
    let idForItem2 = "com.smalldesksoftware.IAP.item2"

    override func setUpWithError() throws {
        // (1)
        session = try SKTestSession(configurationFileNamed: "Configuration")
        // (2)
        session.disableDialogs = true
        // (3)
        session.clearTransactions()
        // (4)
        store = Store()
    }

    override func tearDownWithError() throws {
        // (5)
        session.resetToDefaultState()
    }

    func setupStore() -> Store {
        // (6)
        productRequestDone = XCTestExpectation(description: "wait for product request done")
        store.fetchProducts { _ in
            self.productRequestDone.fulfill()
        }
        // (7)
        wait(for: [productRequestDone], timeout: 20)
        return store
    }
    
    func test_getProductInfo_initial_shouldBe2() {
        // (8)
        let store = setupStore()
        XCTAssertNotNil(store)
        // (9)
        XCTAssertEqual(store.allItems.count, 2)
        XCTAssertNotNil(store.product(for: idForItem1))
        XCTAssertNotNil(store.product(for: idForItem2))
    }
}
コード解説
  1. テスト用の Session を作ります。Configuration.storekit というコンフィグレーションを使うことを指定しています。
  2. ユーザーとのやり取りなしに進めることを指定します
  3. テスト前にトランザクションをクリアしています
  4. テスト用の Store を作成しています
  5. テスト用の Session に対しての設定をリセットします
  6. 製品情報は、非同期で読み込ませるために、終了を待つための XCTestExpectation を作成し、読み込み終了時に、 fulfill しています。
  7. XCTestExpectation が fulfill されるまで待ちます。
  8. Store を生成し、製品情報を読み込ませています。
  9. 製品情報 読み込み後に、期待通りの製品が2つ登録されていることをテストしています

製品を購入処理して、フラグがアップデートされるか

アプリ内で、購入処理をした時に、購入済みであることを表すフラグが正しくアップデートされるかをテストします。

製品購入も非同期で処理されます。製品購入については、observer をセットできますので、その中で、処理完了のフラグをセットします。

内部購入処理テスト code

//
//  BuyTests.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/05
//  © 2020  SmallDeskSoftware
//

import XCTest
import StoreKit
import StoreKitTest

class BuyTests: XCTestCase {
    private var session: SKTestSession!
    private var store: Store!
    
    private var productRequestDone: XCTestExpectation = XCTestExpectation()
    private var purchaseTransactionDone: XCTestExpectation = XCTestExpectation()
    
    let idForItem1 = "com.smalldesksoftware.IAP.item1"
    let idForItem2 = "com.smalldesksoftware.IAP.item2"

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.
        session = try SKTestSession(configurationFileNamed: "Configuration")
        session.disableDialogs = true
        session.clearTransactions()
        store = Store()
    }

    override func tearDownWithError() throws {
        session.resetToDefaultState()
    }

    func setupStore() -> Store {
        productRequestDone = XCTestExpectation(description: "wait for product request done")
        store.fetchProducts { _ in
            self.productRequestDone.fulfill()
        }
        wait(for: [productRequestDone], timeout: 20)
        return store
    }
    
    func test_buyProduct_newPurchase_flagShouldBeSet() {
        let store = setupStore()
        let item1 = store.product(for: idForItem1)!
        // (1)
        SKPaymentQueue.default().add(self)
        
        self.purchaseTransactionDone = XCTestExpectation(description: "wait for purchase transaction done")
        // (2)
        store.purchaseProduct(item1)
        wait(for: [self.purchaseTransactionDone], timeout: 20)

        // (3)
        guard let purchaseTransaction = session.allTransactions().first else {
            XCTFail("no purchase transaction, stop testing")
            return
        }
        
        // (4)
        XCTAssertEqual(purchaseTransaction.state, .purchased)
        XCTAssertEqual(purchaseTransaction.productIdentifier, idForItem1)
        
        // (5)
        guard let purchasedID = store.purchsedProductIDs.first else {
            XCTFail("no flag for purchased item")
            return
        }
        XCTAssertEqual(purchasedID, idForItem1)
    }
}
extension BuyTests: SKPaymentTransactionObserver {
    func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
        // (6)
        for transaction in transactions {
            if transaction.transactionState == .purchased {
                self.purchaseTransactionDone.fulfill()
            }
        }
    }
}
コード解説
  1. 自身を SKPaymentQueue の Observer として登録します( SKPaymentTransactionObserver に extension で準拠させています)
  2. アプリコードとしての購入処理です。次の行の wait で購入処理が終わるのを待っています
  3. session に登録された transaction があることを確認します
  4. session に登録された transaction が購入処理であり、対象の製品 ID に対する購入が終わっていることをテストします
  5. アプリ内で保持している購入済み情報をテストしています
  6. 購入済み情報が、item1 についての情報であることをテストしています

外部購入していると、アプリ起動時に内部の購入済みフラグが適切にセットされるか

アプリ外で購入された時に、アプリ起動時に処理をし、購入済みであることを表すフラグが正しくアップデートされるかをテストします。

外部購入処理テスト code

//
//  BuyTests.swift
//
//  Created by : Tomoaki Yagishita on 2020/12/05
//  © 2020  SmallDeskSoftware
//

import XCTest
import StoreKit
import StoreKitTest

class BuyTests: XCTestCase {
    // omit...   same with before
    func test_restoreProduct_boughtOutside_flagShouldBeSet() {
        // buy product outside app
        // (1)
        try! session.buyProduct(productIdentifier: idForItem1)

        // (2)
        guard let transaction = session.allTransactions().first else {
            XCTFail("something wrong in Test session")
            return
        }
        XCTAssertEqual(transaction.state, .purchased)

        let store = setupStore()
        // (3)
        XCTAssertTrue(store.purchsedProductIDs.contains(idForItem1))
    }
    
    func test_restoreProduct_noBoughtOutside_flagShouldBeSet() {
        // (4)
        XCTAssertEqual(session.allTransactions().count, 0)
        let store = setupStore()
        // (5)
        XCTAssertFalse(store.purchsedProductIDs.contains(idForItem1))
    }
}
コード解説
  1. SKTestSession の buyProduct をコールし、外部購入を模擬しています
  2. session に購入transaction が作られていることをテストしています
  3. 結果として、アプリ内でも item1 が購入されたフラグが設定されていることをテストしています
  4. 外部で購入されていない前提なので、transaction が 0 であることをテストしています
  5. 購入済みフラグが設定されていないことをテストしています

Refund 処理のテスト

SKTestSession の refundTransaction が期待通りに動かないです・・・・

refundTransaction をコールすると、新しい Transaction が Session に登録されることを期待しているのですが、refundTransaction コールの前後で、(refund 対象となる予定の)購入 transaction のみが、session に含まれるだけです。session が変化しません。

自分の環境依存なのか、期待値が間違っているのかがわかりません。

わかりましたら、アップデートします。

まとめ:StoreKitTest を使って、In-App Purchase をテストする方法

StoreKitTest を使って、In-App Purchase をテストする方法
  • XCTestSession を使って、購入全体を管理します
  • XCTestExpectation を使って、待つ必要がある
  • 適宜、Delegate, Observer を使って、処理が完了されることを待つ

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です