Sponsor Link
目次
StoreKit
すでに使われたプロジェクトがある前提で説明します。
非同期処理を含んだテストになるので、XCTestCase 側で 以下のタイミングを知ることができる必要があります。
- ProductRequest が終了したタイミング
- PaymentQueu の Transaction の処理が終了したタイミング
テストの内容
以下の内容の UnitTest を実装していきます。
- AppStore 側から、登録してあるはずの SKProduct 情報がきちんと取得できるか
- 購入処理をすると、アプリ内部の購入済みフラグが、true にセットされるか
- 外部購入していると、アプリ起動時に内部の購入済みフラグが適切にセットされるか(購入履歴あり・なし の2ケース)
- 返金処理をすると、アプリ内部の購入済みフラグが、false にセットされるか
Subscription 等は、複雑になりそうなので、必要性に応じて。
テスト対象コード
Store というクラスを作り、StoreKit とのやり取りをまとめました(WWDC2020 を真似てます)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
// // 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 内で、処理完了を待ちます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
// // 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)) } } |
- テスト用の Session を作ります。Configuration.storekit というコンフィグレーションを使うことを指定しています。
- ユーザーとのやり取りなしに進めることを指定します
- テスト前にトランザクションをクリアしています
- テスト用の Store を作成しています
- テスト用の Session に対しての設定をリセットします
- 製品情報は、非同期で読み込ませるために、終了を待つための XCTestExpectation を作成し、読み込み終了時に、 fulfill しています。
- XCTestExpectation が fulfill されるまで待ちます。
- Store を生成し、製品情報を読み込ませています。
- 製品情報 読み込み後に、期待通りの製品が2つ登録されていることをテストしています
製品を購入処理して、フラグがアップデートされるか
アプリ内で、購入処理をした時に、購入済みであることを表すフラグが正しくアップデートされるかをテストします。
製品購入も非同期で処理されます。製品購入については、observer をセットできますので、その中で、処理完了のフラグをセットします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
// // 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() } } } } |
- 自身を SKPaymentQueue の Observer として登録します( SKPaymentTransactionObserver に extension で準拠させています)
- アプリコードとしての購入処理です。次の行の wait で購入処理が終わるのを待っています
- session に登録された transaction があることを確認します
- session に登録された transaction が購入処理であり、対象の製品 ID に対する購入が終わっていることをテストします
- アプリ内で保持している購入済み情報をテストしています
- 購入済み情報が、item1 についての情報であることをテストしています
外部購入していると、アプリ起動時に内部の購入済みフラグが適切にセットされるか
アプリ外で購入された時に、アプリ起動時に処理をし、購入済みであることを表すフラグが正しくアップデートされるかをテストします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
// // 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)) } } |
- SKTestSession の buyProduct をコールし、外部購入を模擬しています
- session に購入transaction が作られていることをテストしています
- 結果として、アプリ内でも item1 が購入されたフラグが設定されていることをテストしています
- 外部で購入されていない前提なので、transaction が 0 であることをテストしています
- 購入済みフラグが設定されていないことをテストしています
Refund 処理のテスト
SKTestSession の refundTransaction が期待通りに動かないです・・・・
refundTransaction をコールすると、新しい Transaction が Session に登録されることを期待しているのですが、refundTransaction コールの前後で、(refund 対象となる予定の)購入 transaction のみが、session に含まれるだけです。session が変化しません。
自分の環境依存なのか、期待値が間違っているのかがわかりません。
わかりましたら、アップデートします。
まとめ:StoreKitTest を使って、In-App Purchase をテストする方法
- XCTestSession を使って、購入全体を管理します
- XCTestExpectation を使って、待つ必要がある
- 適宜、Delegate, Observer を使って、処理が完了されることを待つ
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link