Rork Max が吐き出した購入処理を初めて読んだとき、目が止まったのは Transaction.currentEntitlements を舐めて Bool を返す、十数行の関数でした。読む限り、間違っていません。実際に買えば、ちゃんと機能が開きます。
困ったのは、その先を確かめる手段のほうでした。返金されたら何が起きるのか。更新に失敗したら何が起きるのか。Sandbox アカウントを作って、購入して、1ヶ月待つ。そういうわけにもいきません。
個人開発で課金まわりが怖いのは、バグが「動かない」形で出てこないからです。買えている人の画面には何も起きません。壊れているのは返金された数人の権利だけで、その数人は何も言わずに離れていきます。
StoreKitTest は、この空白を埋めるために Apple が用意したフレームワークです。App Store を呼びに行かず、端末の中に閉じた偽の App Store を立てて、返金も失効も自分の手で起こせます。
Sandbox に入る前に落とせるバグがある
課金の検証というと Sandbox から始めがちですが、Sandbox は「Apple のサーバーと正しく話せるか」を見る場所です。「自分のコードが状態変化に正しく反応するか」を見る場所ではありません。
この2つを混ぜると、確認したいことに対して手続きが重すぎます。返金を1回試すために、Sandbox アカウントを作り、購入し、App Store Connect から返金を発行し、通知が届くのを待つ。1シナリオに数十分かかり、しかも失敗したときに原因がコードなのか環境なのか切り分けられません。
StoreKitTest が担うのは前者ではなく後者です。ネットワークもサーバーも出てきません。テストプロセスの中で完結します。
私はこの2つを、次のように分けて考えています。
| 確かめたいこと | 使う場所 | 1回あたりの手間 |
| 返金されたら権利が消えるか | SKTestSession | 数百ミリ秒 |
| 期限が切れたら権利が消えるか | SKTestSession | 数百ミリ秒 |
| 購入失敗を握り潰していないか | SKTestSession | 数百ミリ秒 |
| 署名検証がサーバーで通るか | Sandbox | 数分 |
| サーバー通知が届いて処理されるか | Sandbox | 数分〜 |
| 実際の請求と価格表示が合うか | Sandbox / 本番 | 数分〜 |
上3行は Sandbox でもできますが、やる意味が薄い作業です。下3行は SKTestSession では絶対にできません。境界はここに引くのが現実的だと考えています。
StoreKitTest は端末の中に偽の App Store を立てます
必要なのは .storekit の Configuration ファイルと、テストターゲットでの import StoreKitTest です。Configuration ファイルは Xcode で作れますが、Rork Max で生成したプロジェクトなら App Store Connect の商品定義から同期させるのが早い方法です。
SKTestSession を作った瞬間から、そのプロセス内の StoreKit 2 API はすべてこのセッションを向きます。Product.products(for:) も Transaction.currentEntitlements も、Apple ではなくセッションが答えます。
import XCTest
import StoreKit
import StoreKitTest
@testable import MyApp
final class EntitlementTests: XCTestCase {
var session: SKTestSession!
override func setUpWithError() throws {
try super.setUpWithError()
// Products.storekit をテストターゲットに含めておきます
session = try SKTestSession(configurationFileNamed: "Products")
// 購入確認ダイアログを出さない。出すとテストが人間の指を待ち続けます
session.disableDialogs = true
// 前のテストのトランザクションを持ち越さない
session.clearTransactions()
session.resetToDefaultState()
}
override func tearDownWithError() throws {
session = nil
try super.tearDownWithError()
}
}
disableDialogs と clearTransactions() は、忘れると必ず痛い目を見る2行です。前者を落とすとテストがダイアログの前で固まり、後者を落とすと前のテストで買った購読が次のテストに漏れて、原因の分からない成功が出ます。テストが「なぜか通る」ときは、だいたいここです。
購入が権利になるまでを1本で確かめる
最初に書くべきなのは、生成コードが最も自信を持っている経路です。ここが通らなければ、その先を疑う意味がありません。
func testPurchaseGrantsEntitlement() async throws {
// 購入前は権利なし
let before = await EntitlementStore.shared.isSubscribed()
XCTAssertFalse(before, "購入前に権利が付いています")
// セッション側から購入を発生させる(UI を経由しない)
try await session.buyProduct(identifier: "com.example.pro.monthly")
// アプリ側の判定ロジックを呼ぶ
let after = await EntitlementStore.shared.isSubscribed()
XCTAssertTrue(after, "購入後も権利が付いていません")
}
期待する出力は、テストが緑になることそのものではありません。EntitlementStore が Transaction.currentEntitlements を経由していることの確認です。もし生成コードが購入完了時に UserDefaults へフラグを書き、以後それを読むだけの実装になっていると、このテストは通ってしまいます。そして次のテストで落ちます。
そこが狙いです。次に進みます。
時間を進めて、更新と失効を確かめる
timeRate は StoreKitTest でいちばん効くプロパティです。購読の時間軸を圧縮し、1ヶ月待つ代わりに数秒で更新を起こせます。
func testExpirationRevokesEntitlement() async throws {
session.timeRate = .oneSecondIsOneDay
try await session.buyProduct(identifier: "com.example.pro.monthly")
let granted = await EntitlementStore.shared.isSubscribed()
XCTAssertTrue(granted)
// 更新を止めて、明示的に失効させる
try session.expireSubscription(productIdentifier: "com.example.pro.monthly")
let revoked = await EntitlementStore.shared.isSubscribed()
XCTAssertFalse(revoked, "失効後も権利が残っています")
}
expireSubscription(productIdentifier:) を使えば、timeRate で待つ必要すらありません。時間を進めたいのは「更新が何回か走った後の挙動」を見たいときで、単に切れた状態を作りたいだけなら直接失効させるほうが速く、テストも安定します。
forceRenewalOfSubscription(productIdentifier:) を使えば更新側も同じように起こせます。更新のたびに走らせている処理(サーバーへの同期やレシート再送)がある場合は、これで多重実行になっていないかを確かめられます。
なぜ失効テストを先に書くのか
権利の付与は、外していれば必ず気づきます。買った本人が怒るからです。剥奪は誰も教えてくれません。切り忘れた側は黙って使い続け、切りすぎた側は黙って離れます。
先に書くべきなのは、痛みが自分に届かないほうです。
返金を再現する — 生成コードが最も落としやすい経路
返金は、AI が生成した課金コードでほぼ確実に抜け落ちる経路です。理由は単純で、購入フローの延長線上にないからです。返金はユーザーの操作ではなく、App Store から後から降ってきます。
func testRefundRevokesEntitlement() async throws {
try await session.buyProduct(identifier: "com.example.pro.lifetime")
XCTAssertTrue(await EntitlementStore.shared.isSubscribed())
// 直近のトランザクションを取り出して返金する
let transactions = session.allTransactions()
guard let target = transactions.first else {
return XCTFail("トランザクションが見つかりません")
}
try session.refundTransaction(identifier: target.identifier)
// Transaction.updates の反映を待つ
try await Task.sleep(nanoseconds: 300_000_000)
let stillHasAccess = await EntitlementStore.shared.isSubscribed()
XCTAssertFalse(stillHasAccess, "返金後も権利が残っています")
}
このテストが落ちるとしたら、原因はだいたい1つです。アプリが Transaction.updates を監視していない、あるいは監視の開始が遅すぎるかです。
落とし穴はリスナーの開始位置にあります
Transaction.updates はアプリ起動直後から張っておく必要があります。生成コードでは、購入ボタンを押したときに初めてリスナーを作る形になっていることがあります。それだと、アプリを閉じている間に届いた返金を取りこぼします。
// アプリ起動時に1度だけ。ボタンを押した時ではありません
@main
struct MyApp: App {
@State private var updatesTask: Task<Void, Never>?
var body: some Scene {
WindowGroup {
ContentView()
.task {
updatesTask = Task.detached {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await EntitlementStore.shared.refresh()
await transaction.finish()
}
}
}
}
}
}
refundTransaction は、この構造の欠陥を数百ミリ秒で暴きます。App Store Connect から返金を発行して通知を待つのとは、確かめられる速さがまるで違います。
なお、端末側の判定だけで返金に追随させるのは、あくまで表示のための応急処置です。本番環境まで見据えるなら、権利の正本はサーバーに置き、端末はその答えを表示するだけに留めるのが確実な回避策になります。権利の正本をサーバーに置いている場合は、StoreKit 2 の署名付きトランザクションを Cloudflare Worker で検証するで扱っている検証経路と、サーバー通知の側も合わせて確かめる必要があります。
購入の失敗をわざと起こす
購入が失敗したとき、生成コードは何をしているでしょうか。try? で握り潰して、何事もなかったかのように画面を閉じていないでしょうか。
setSimulatedError(_:forAPI:) を使うと、特定の StoreKit API だけをエラーにできます。
func testPurchaseFailureSurfacesToUser() async throws {
// 購入 API だけを失敗させる
session.setSimulatedError(.generic(.unknown), forAPI: .purchase)
let viewModel = PaywallViewModel()
await viewModel.purchase(productID: "com.example.pro.monthly")
// 失敗がユーザーに見えているか
XCTAssertNotNil(viewModel.errorMessage, "購入失敗がユーザーに伝わっていません")
XCTAssertFalse(viewModel.isPurchasing, "ローディングが解除されていません")
// 失敗したのに権利が付いていないか
let granted = await EntitlementStore.shared.isSubscribed()
XCTAssertFalse(granted, "失敗した購入で権利が付いています")
// 後片付け
session.setSimulatedError(nil, forAPI: .purchase)
}
最後の isPurchasing の検証は、地味ですが効きます。購入に失敗したままスピナーが回り続ける画面は、ユーザーから見ると「アプリが壊れた」と区別がつきません。そして課金率に直接効きます。この手のバグは、失敗を意図的に起こせる場所がないと永遠に見つかりません。
forAPI: には .loadProducts も指定できます。商品情報の取得が失敗したときにペイウォールが空白のまま表示されていないか、これで確認できます。空のペイウォールは、購入意欲が最も高い瞬間の読者を取りこぼす場所です。
Ask to Buy とストアフロントの差を1テストに畳む
ファミリー共有の「承認と購入のリクエスト」は、購入が保留状態で返ってくる経路です。実機で再現しようとすると、家族アカウントが要ります。
func testAskToBuyPendingState() async throws {
session.askToBuyEnabled = true
try await session.buyProduct(identifier: "com.example.pro.monthly")
// 承認前に権利が付いていないこと
let beforeApproval = await EntitlementStore.shared.isSubscribed()
XCTAssertFalse(beforeApproval, "承認前に権利が付いています")
// 保護者が承認したことにする
let pending = session.allTransactions().filter { $0.state == .deferred }
guard let target = pending.first else {
return XCTFail("保留トランザクションがありません")
}
try session.approveAskToBuyTransaction(identifier: target.identifier)
try await Task.sleep(nanoseconds: 300_000_000)
let afterApproval = await EntitlementStore.shared.isSubscribed()
XCTAssertTrue(afterApproval, "承認後に権利が付いていません")
}
storefront を差し替えれば、地域による商品の出し分けも同じ枠組みで確かめられます。日本と米国で価格表示が壊れないかは、実機を2台用意しなくてもここで見られます。
私はこの2つを、優先度としてはやや後ろに置いています。返金と失効を先に固めてからで十分です。
CI に載せるときの線引き
これらのテストは実機を必要としません。シミュレータで走り、外部通信も発生しません。つまり CI に載ります。
ただし、載せるものと載せないものは分けたほうが穏当です。
| テスト | CI で毎回 | 理由 |
| 購入 → 権利付与 | 載せる | 速い・壊れたら即致命傷 |
| 失効 → 権利剥奪 | 載せる | 速い・自分では気づけない |
| 返金 → 権利剥奪 | 載せる | 速い・生成コードが最も弱い |
| 購入失敗の UI 反映 | 載せる | 速い・課金率に直結 |
| timeRate での長期更新 | 載せない | 時間依存で不安定になりやすい |
| Ask to Buy / ストアフロント | リリース前のみ | 変更頻度が低い |
timeRate を使ったテストを毎回走らせると、失敗の理由が「壊れた」なのか「間に合わなかった」なのか分からなくなります。時間に依存するテストは、緑と赤の意味を曖昧にします。曖昧なテストは、いずれ誰も見なくなります。
上4本については、CI の必須ジョブに入れることを推奨します。実行が速く、落ちたときの原因が課金コード以外にほぼ無いためです。
生成のたびにコードが書き換わる環境では、この4本が回っているかどうかが、そのまま安心して再生成できるかどうかになります。Rork Max に画面を作り直させるとき、課金の周りだけは手を入れさせたくない。その線を引くのが、この4本の役割です。
Sandbox に残る仕事
StoreKitTest で確かめられないものは、はっきりしています。
サーバー側の署名検証は通りません。SKTestSession が発行するトランザクションは、Apple の本番鍵で署名されていないからです。App Store Server Notifications V2 も飛びません。実際の課金額も、地域ごとの実価格も出てきません。
つまり、サーバーを絡めた権利設計を持っているなら、Sandbox は最後まで必要です。支払い失敗からの復帰のように、ストア側の状態遷移そのものを扱う話は 解約だと思って権限を切ったユーザーは、まだ払う気だった — 支払い猶予期間と権限維持の実装 の領域で、これは SKTestSession の外側です。
Sandbox 側の準備で詰まりやすい箇所は Rork のサブスクリプションを Sandbox でテストする実践 — Apple/Google で詰まる箇所と検証手順 にまとめてあります。
役割はこう分かれます。SKTestSession は自分のコードを疑う道具で、Sandbox は Apple との接続を疑う道具です。順番を逆にすると、切り分けに時間を溶かします。
もう1つ、地味ですが小さくない利点があります。SKTestSession は $99 の Apple Developer Program 契約がなくても動きます。Rork Max で作ったアプリを出すかどうかまだ決めていない段階でも、課金ロジックの検証だけは先に進められます。
次の一歩
まず testRefundRevokesEntitlement の1本だけ書いてみてください。返金を1回起こすだけです。
もしそれが緑になったら、Transaction.updates の監視は正しく張れています。赤になったら、リスナーの開始位置を探してください。おそらく購入ボタンの中にあります。
私自身、生成されたコードをそのまま信じて App Store に出したことがあり、返金経路の穴に後から気づきました。買えることだけを確かめて満足していたからです。テストは、自分が確かめていないことを教えてくれる道具でもあるのだと、あのとき静かに思い知りました。