●MAX — Rork Max builds native Swift apps rather than React Native, spanning iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●APIS — It reaches native Apple APIs including SwiftUI, ARKit, HealthKit, HomeKit, Core ML, and Metal, so AR/LiDAR scanning and on-device ML are in scope●CLOUD — Rork Max writes Swift, compiles it on cloud-hosted Macs, and streams a live simulator that accepts real touch input●SUBMIT — From there it prepares the build for device install or App Store submission●SEED — Rork raised a $15M seed led by Left Lane Capital in April, joined by Peak XV and a16z Speedrun●PRICE — It's free to start at roughly 5 prompts a week, paid plans begin at $25/month, and Rork Max runs $200/month●MAX — Rork Max builds native Swift apps rather than React Native, spanning iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●APIS — It reaches native Apple APIs including SwiftUI, ARKit, HealthKit, HomeKit, Core ML, and Metal, so AR/LiDAR scanning and on-device ML are in scope●CLOUD — Rork Max writes Swift, compiles it on cloud-hosted Macs, and streams a live simulator that accepts real touch input●SUBMIT — From there it prepares the build for device install or App Store submission●SEED — Rork raised a $15M seed led by Left Lane Capital in April, joined by Peak XV and a16z Speedrun●PRICE — It's free to start at roughly 5 prompts a week, paid plans begin at $25/month, and Rork Max runs $200/month
Test Generated Purchase Code Before You Touch Sandbox — Automating Refunds, Expiration, and Failures with SKTestSession
Rork Max generates StoreKit 2 code that proves people can buy. It does not prove refunds, expiration, or failed purchases are handled. Here is how to cover those paths with SKTestSession before you ever open Sandbox.
The first thing I noticed in the purchase code Rork Max produced was a fifteen-line function that walked Transaction.currentEntitlements and returned a Bool. Nothing about it was wrong. Buy the product, the feature unlocks.
The problem was everything downstream. What happens on a refund? What happens when renewal fails? Create a Sandbox account, buy, wait a month — that is not a workflow.
Billing bugs are frightening for an indie developer because they do not announce themselves. Paying customers see nothing wrong. What is broken is the entitlement of the handful of people who got refunded, and those people leave without saying a word.
StoreKitTest is Apple's answer to that blind spot. It stands up a fake App Store inside your test process, and lets you trigger refunds and expiration with your own hands.
Some Bugs Die Before Sandbox
Billing verification tends to start at Sandbox, but Sandbox answers a different question: can my app talk to Apple's servers correctly? It does not answer whether my own code reacts correctly to a change in state.
Conflating the two makes the process far too heavy for what you want to learn. Testing one refund means creating a Sandbox account, purchasing, issuing a refund from App Store Connect, and waiting for the notification. Tens of minutes per scenario — and when it fails, you cannot tell whether your code or the environment is at fault.
StoreKitTest owns the second question. No network, no servers. Everything stays inside the test process.
I draw the line like this:
What you want to verify
Where
Cost per run
Refund removes entitlement
SKTestSession
Milliseconds
Expiration removes entitlement
SKTestSession
Milliseconds
Failed purchases are surfaced
SKTestSession
Milliseconds
Signature verification on your server
Sandbox
Minutes
Server notifications arrive and process
Sandbox
Minutes and up
Real charges and localized pricing
Sandbox / Production
Minutes and up
The top three are technically possible in Sandbox, but there is little reason to spend the time there. The bottom three are impossible in SKTestSession. That is where the line belongs.
StoreKitTest Stands Up a Fake App Store On-Device
You need a .storekit configuration file and import StoreKitTest in your test target. You can build the configuration by hand in Xcode, but for a Rork Max project it is faster to sync it from your App Store Connect product definitions.
The moment you create an SKTestSession, every StoreKit 2 API in that process points at it. Product.products(for:) and Transaction.currentEntitlements are answered by the session, not by Apple.
import XCTestimport StoreKitimport StoreKitTest@testable import MyAppfinal class EntitlementTests: XCTestCase { var session: SKTestSession! override func setUpWithError() throws { try super.setUpWithError() // Products.storekit must be a member of the test target session = try SKTestSession(configurationFileNamed: "Products") // No confirmation dialogs. Otherwise the test waits for a human finger. session.disableDialogs = true // Do not carry transactions across tests session.clearTransactions() session.resetToDefaultState() } override func tearDownWithError() throws { session = nil try super.tearDownWithError() }}
disableDialogs and clearTransactions() are the two lines that will hurt you if you skip them. Drop the first and your test hangs on a dialog forever. Drop the second and a subscription bought in an earlier test leaks into the next one, producing a pass you cannot explain. When a test passes "somehow," this is usually why.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you have verified that purchases work but never tested refunds or expiration, you can walk away today with working XCTest cases that reproduce both in milliseconds
✦You will learn how timeRate, expireSubscription, refundTransaction, and setSimulatedError collapse a month-long Sandbox renewal scenario into a single test
✦You can close the refund gap that generated code almost always leaves open, and verify billing logic before paying the $99 Apple Developer Program fee
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Start with the path the generated code is most confident about. If this does not hold, there is no point looking further.
func testPurchaseGrantsEntitlement() async throws { // No entitlement before purchase let before = await EntitlementStore.shared.isSubscribed() XCTAssertFalse(before, "Entitlement granted before purchase") // Drive the purchase from the session, bypassing the UI try await session.buyProduct(identifier: "com.example.pro.monthly") // Call your app's own entitlement logic let after = await EntitlementStore.shared.isSubscribed() XCTAssertTrue(after, "No entitlement after purchase")}
The expected outcome is not simply a green test. It is confirmation that EntitlementStore actually goes through Transaction.currentEntitlements. If the generated code writes a flag to UserDefaults on purchase completion and reads only that flag afterward, this test still passes — and the next one fails.
That is exactly the point.
Move Time Forward to Test Renewal and Expiration
timeRate is the most useful property in StoreKitTest. It compresses the subscription timeline so renewals happen in seconds instead of a month.
func testExpirationRevokesEntitlement() async throws { session.timeRate = .oneSecondIsOneDay try await session.buyProduct(identifier: "com.example.pro.monthly") let granted = await EntitlementStore.shared.isSubscribed() XCTAssertTrue(granted) // Stop renewing and expire it outright try session.expireSubscription(productIdentifier: "com.example.pro.monthly") let revoked = await EntitlementStore.shared.isSubscribed() XCTAssertFalse(revoked, "Entitlement survived expiration")}
With expireSubscription(productIdentifier:) you do not even need to wait on timeRate. Advance time when you care about behavior after several renewals have fired; when you just want an expired state, expire it directly. It is faster and the test is steadier.
forceRenewalOfSubscription(productIdentifier:) triggers the other direction. If you run work on every renewal — syncing to a server, re-sending a receipt — this is how you find out it fires twice.
Why the Expiration Test Comes First
Missing entitlement grants get reported. The customer who paid will tell you. Revocation is different: nobody tells you. Users you failed to cut off keep using the app quietly, and users you cut off too aggressively leave quietly.
Write the test for the failure that never reaches you.
Reproduce a Refund — the Path Generated Code Misses
Refunds are the path AI-generated billing code nearly always drops, for a simple reason: they are not downstream of the purchase flow. A refund is not a user action. It arrives later, from the App Store.
func testRefundRevokesEntitlement() async throws { try await session.buyProduct(identifier: "com.example.pro.lifetime") XCTAssertTrue(await EntitlementStore.shared.isSubscribed()) // Grab the most recent transaction and refund it let transactions = session.allTransactions() guard let target = transactions.first else { return XCTFail("No transaction found") } try session.refundTransaction(identifier: target.identifier) // Give Transaction.updates a moment to land try await Task.sleep(nanoseconds: 300_000_000) let stillHasAccess = await EntitlementStore.shared.isSubscribed() XCTAssertFalse(stillHasAccess, "Entitlement survived a refund")}
When this test fails, the cause is almost always the same: the app is not observing Transaction.updates, or it starts observing too late.
The Trap Is Where the Listener Starts
Transaction.updates has to be running from app launch. Generated code often creates the listener when the purchase button is tapped. That design drops any refund that arrives while the app is closed.
// Once, at launch. Not when a button is tapped.@mainstruct 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 exposes that structural flaw in a few hundred milliseconds. Issuing a refund from App Store Connect and waiting for the notification is a different sport entirely.
Client-side reaction to refunds is a display-layer patch, though. In production, keep the source of truth on a server and let the device do nothing but render the answer. If your source of truth for entitlements lives on a server, you also need to verify the path covered in Verifying StoreKit 2 Signed Transactions in a Cloudflare Worker, along with the server notification side.
Fail a Purchase On Purpose
What does the generated code do when a purchase fails? Swallow it with try? and dismiss the sheet as if nothing happened?
setSimulatedError(_:forAPI:) lets you fail one specific StoreKit API.
func testPurchaseFailureSurfacesToUser() async throws { // Fail only the purchase API session.setSimulatedError(.generic(.unknown), forAPI: .purchase) let viewModel = PaywallViewModel() await viewModel.purchase(productID: "com.example.pro.monthly") // Is the failure visible to the user? XCTAssertNotNil(viewModel.errorMessage, "Purchase failure never reached the user") XCTAssertFalse(viewModel.isPurchasing, "Loading state was never cleared") // No entitlement from a failed purchase let granted = await EntitlementStore.shared.isSubscribed() XCTAssertFalse(granted, "Failed purchase granted an entitlement") // Clean up session.setSimulatedError(nil, forAPI: .purchase)}
That isPurchasing assertion looks trivial and earns its keep. A paywall with a spinner that never stops is indistinguishable from a broken app, and it costs you conversions directly. You will never find that bug without somewhere to trigger failure deliberately.
forAPI: also accepts .loadProducts. Use it to check that a failed product fetch does not leave your paywall rendering as a blank sheet — the exact moment a reader's intent to pay is highest.
Fold Ask to Buy and Storefronts Into One Test
Family Sharing's "Ask to Buy" returns purchases in a deferred state. Reproducing that on a device means setting up family accounts.
func testAskToBuyPendingState() async throws { session.askToBuyEnabled = true try await session.buyProduct(identifier: "com.example.pro.monthly") // No entitlement before approval let beforeApproval = await EntitlementStore.shared.isSubscribed() XCTAssertFalse(beforeApproval, "Entitlement granted before approval") // Approve as the parent would let pending = session.allTransactions().filter { $0.state == .deferred } guard let target = pending.first else { return XCTFail("No deferred transaction") } try session.approveAskToBuyTransaction(identifier: target.identifier) try await Task.sleep(nanoseconds: 300_000_000) let afterApproval = await EntitlementStore.shared.isSubscribed() XCTAssertTrue(afterApproval, "No entitlement after approval")}
Swap storefront and the same structure covers region-specific product availability. Whether your pricing display survives a jump between Japan and the US no longer needs two physical devices.
I keep both of these lower in priority. Nail refunds and expiration first.
Where to Draw the CI Line
None of these tests need a device. They run in the simulator with no outbound traffic, which means they belong in CI.
That said, be selective about what runs every time.
Test
Every CI run
Why
Purchase grants entitlement
Yes
Fast; fatal when broken
Expiration revokes entitlement
Yes
Fast; you cannot notice it yourself
Refund revokes entitlement
Yes
Fast; weakest spot in generated code
Purchase failure reaches the UI
Yes
Fast; directly affects conversion
Long-horizon renewal via timeRate
No
Time-dependent and flaky
Ask to Buy / storefronts
Pre-release only
Rarely changes
Run timeRate tests on every commit and you lose the ability to tell "broken" from "did not finish in time." Time-dependent tests blur what red and green mean, and blurred tests eventually get ignored.
I would make those four a required CI job. They run fast, and when one goes red there is almost nothing to blame but the billing code.
In a workflow where the code gets rewritten on every generation, these four tests are the difference between regenerating with confidence and regenerating with a held breath. When you ask Rork Max to rebuild a screen, the billing layer is the one place you want left alone. These four draw that boundary.
What Stays in Sandbox
The limits are clean.
Server-side signature verification will not pass: transactions issued by SKTestSession are not signed with Apple's production keys. App Store Server Notifications V2 never fire. Real charges and real localized prices do not appear.
The division of labor: SKTestSession is the tool for doubting your own code, Sandbox is the tool for doubting your connection to Apple. Reverse that order and you will burn hours on the wrong question.
One more quiet advantage: SKTestSession runs without a $99 Apple Developer Program membership. Even before you have decided whether to ship the app Rork Max built, you can move the billing logic forward.
One Next Step
Write testRefundRevokesEntitlement and nothing else. Trigger one refund.
If it goes green, your Transaction.updates observer is wired correctly. If it goes red, go find where the listener starts. It is probably inside the purchase button.
I shipped generated code to the App Store once, trusting it as written, and found the hole in the refund path only afterward. I had verified that people could buy, and stopped there. Tests, I learned quietly that day, are also a tool for showing you what you never checked.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.