Test In-App Purchases
StoreKit can serve in-app purchase products from a local .storekit configuration file instead of from App Store Connect. Limrun's iOS instances expose this local test environment through three SDK calls: register a config, auto-generate one from your sandbox products, or clear it. Your app makes the same StoreKit calls it does in production; the simulator answers them from your file.
Before you start
- A Limrun API key in
LIM_API_KEY. If you haven't used the CLI before, walk through the Quickstart first. - An iOS app that uses StoreKit (or StoreKit 2) for in-app purchases.
- Either a
.storekitconfiguration file (the kind Xcode generates via File → New → File → StoreKit Configuration File), or a bundle ID that already has IAPs registered in App Store Connect sandbox. You pick which flow to use later on this page; both are supported.
How the local test environment works
storekitd is the iOS daemon that brokers every StoreKit request between your app and Apple's servers. In the local test environment, it reads products from a JSON-shaped file (a .storekit configuration) instead of contacting App Store Connect. Xcode uses this same mechanism when you run a scheme with a StoreKit config selected; Limrun exposes it remotely through three SDK methods (setStoreKitConfig, discoverStoreKitConfig, clearStoreKitConfig).
Two practical consequences:
- The simulator needs to be set up so it accepts a local config. That means provisioning it with the Xcode sandbox attached (the same sandbox you use for Build with remote Xcode), and ad-hoc-signing the build so the simulator runs it without an Apple Developer account.
- The config is keyed by bundle ID. Setting it for
com.example.MyAppdoesn't affect any other bundle on the same simulator, and clearing it sends StoreKit back to real-sandbox behavior for that bundle only.
Provision an instance with the Xcode sandbox
Same create call you'd use for any build-and-test flow, with sandbox.xcode.enabled switched on:
lim ios create --xcode --reuse-if-exists --region us-west --label name=iap-testimport { Limrun, Ios } from '@limrun/api';
const lim = new Limrun({ apiKey: process.env.LIM_API_KEY });
// `wait: true` blocks until the instance is `ready` and the Xcode sandbox URL
// is populated on `status.sandbox.xcode.url`. `reuseIfExists` keeps you on the
// same warm instance on re-runs; pin a region if you want that to be reliable
// across networks (see Run an iOS Simulator).
const instance = await lim.iosInstances.create({
wait: true,
reuseIfExists: true,
metadata: { labels: { name: 'iap-test' } },
spec: {
region: 'us-west',
sandbox: { xcode: { enabled: true } },
},
});import (
"context"
"os"
limrun "github.com/limrun-inc/go-sdk"
"github.com/limrun-inc/go-sdk/option"
"github.com/limrun-inc/go-sdk/packages/param"
)
lim := limrun.NewClient(option.WithAPIKey(os.Getenv("LIM_API_KEY")))
instance, err := lim.IosInstances.New(context.TODO(), limrun.IosInstanceNewParams{
Wait: param.NewOpt(true),
ReuseIfExists: param.NewOpt(true),
Metadata: limrun.IosInstanceNewParamsMetadata{
Labels: map[string]string{"name": "iap-test"},
},
Spec: limrun.IosInstanceNewParamsSpec{
Region: param.NewOpt("us-west"),
Sandbox: limrun.IosInstanceNewParamsSpecSandbox{
Xcode: limrun.IosInstanceNewParamsSpecSandboxXcode{
Enabled: param.NewOpt(true),
},
},
},
})import os
from limrun_api import Limrun
limrun = Limrun(api_key=os.environ["LIM_API_KEY"])
instance = limrun.ios_instances.create(
wait=True,
reuse_if_exists=True,
metadata={"labels": {"name": "iap-test"}},
spec={
"region": "us-west",
"sandbox": {"xcode": {"enabled": True}},
},
)When the call resolves, two URLs on the returned object matter for IAP work:
- The Xcode sandbox URL (
instance.status.sandbox.xcode.url). The Xcode client connects here to sync source and runxcodebuild. - The device control-plane URL (
instance.status.apiUrl). The iOS client connects here to call the StoreKit helpers and drive the UI.
Both authenticate with the same instance.status.token.
Build and install the app
Sync your source to the Xcode sandbox and run xcodebuild. Critically, the build must be ad-hoc-signed so StoreKit's local test environment accepts the bundle. Simulator-target xcodebuild does ad-hoc signing by default when you don't pass certificate parameters, so you don't need to provision anything signing-related yourself. After a successful build, the app auto-installs on the attached simulator.
# Syncs the source folder and runs xcodebuild in one step. --id targets the
# iOS instance with the Xcode sandbox attached (created above).
lim xcode build ./MyApp --id <instance-id> --scheme MyApp --sdk iphonesimulatorconst xcode = await lim.xcodeInstances.createClient({
apiUrl: instance.status.sandbox!.xcode!.url!,
token: instance.status.token,
});
await xcode.sync('./MyApp', { watch: false });
// `xcodebuild()` with no signing params produces an ad-hoc-signed simulator
// build. After it succeeds, the build is auto-installed on the attached
// simulator on the same instance.
const build = xcode.xcodebuild();
build.stdout.on('data', (line) => process.stdout.write(line.toString()));
build.stderr.on('data', (line) => process.stderr.write(line.toString()));
const { exitCode } = await build;
if (exitCode !== 0) throw new Error(`xcodebuild failed (${exitCode})`);The Xcode sync + build pipeline isn't exposed in the Python or Go SDKs today. From those languages, drive the build through lim xcode build as a subprocess. See Build with remote Xcode for the full surface (workspace, scheme, real-device signing, log streaming, artifact upload).
Pick a flow
The three StoreKit helpers live on the iOS device-control client:
import { Ios } from '@limrun/api';
const ios = await Ios.createInstanceClient({
apiUrl: instance.status.apiUrl!,
token: instance.status.token,
});setStoreKitConfig, clearStoreKitConfig, discoverStoreKitConfig, and softReset are TypeScript-only today. The Go iOS WebSocket client at github.com/limrun-inc/go-sdk/websocket/ios ships the rest of device control (taps, element-tree, screenshots, simctl) but not these StoreKit helpers, and the Python SDK is control-plane only. From Go or Python, drive the StoreKit + soft-reset steps from a Node subprocess (or call the underlying HTTP endpoints directly); the surrounding provision, build, launch, and tap steps still work in your primary language.
From here, you have two ways to populate the local config: register a file you already have, or auto-generate one from your existing App Store Connect setup.
The second option, discover, leans on what you've already done in App Store Connect. When you set up an iOS app for distribution, you also register every in-app purchase there: a product ID, a price tier, localized titles and descriptions, and (for subscriptions) groups and durations. App Store Connect mirrors this data into a sandbox storefront, a parallel pre-production environment that real devices can fetch from when signed into a sandbox-tester Apple ID. Discover skips the tester sign-in: it has the simulator make one sandbox product request (when you open the paywall during a discoverStoreKitConfig call), captures the response from the StoreKit cache on the device, and turns it into a .storekit file. You get a config that exactly matches what App Store Connect has for that bundle, without authoring or maintaining a separate file.
| Flow | What you need | When to use |
|---|---|---|
Explicit (setStoreKitConfig) | A .storekit file on disk | You already maintain a config in Xcode, or you want the test environment to be deterministic across runs. |
Discover (discoverStoreKitConfig) | IAPs registered in App Store Connect sandbox for your bundle | You have a working App Store Connect setup but no .storekit file. Limrun captures the sandbox response and converts it for you. |
These two flows aren't mutually exclusive. Discover is convenient for the first run; explicit is what most teams settle on once they want their tests reproducible.
Explicit flow: upload a .storekit file
If you already have a .storekit (Xcode generates one via File → New → File → StoreKit Configuration File; you can also write one by hand), read the bytes and register them against the bundle ID:
import fs from 'node:fs';
const bundleId = 'com.example.MyApp';
const bytes = fs.readFileSync('./Products.storekit');
await ios.setStoreKitConfig(bundleId, bytes);setStoreKitConfig takes the raw file bytes (Buffer or Uint8Array). The simulator wires the config to bundleId immediately. The app's next StoreKit fetch will hit the local test environment instead of Apple's servers.
If your app is already running, restart it so it picks up the new config:
await ios.terminateApp(bundleId);
await ios.launchApp(bundleId);Discover flow: auto-generate from a sandbox response
If you don't have a .storekit on hand but your bundle ID already has IAPs registered in App Store Connect sandbox, Limrun can read the cached sandbox response from storekitd and turn it into a .storekit for you:
const result = await ios.discoverStoreKitConfig(bundleId, {
timeoutSeconds: 120, // default 120; server caps at 300
});
console.log(`Found ${result.itemsFound} items: ` +
`${result.productsCount} products, ` +
`${result.subscriptionsCount} subscriptions in ` +
`${result.subscriptionGroupsCount} groups.`);The call blocks server-side until either a sandbox response gets cached or the timeout fires. While it's waiting, your app needs to trigger at least one StoreKit product fetch, which usually means opening the in-app paywall. Watch the simulator via instance.status.signedStreamUrl and drive the app there yourself, or automate it with the device-control tap and element-tree APIs.
If discover times out, it's almost always because either the bundle has no IAPs registered in sandbox yet, or the app didn't actually fetch products during the window. Re-running with a longer timeoutSeconds won't help in either case.
After discovery succeeds, the generated config is automatically registered for the bundle, exactly as if you'd called setStoreKitConfig. You don't need a second call.
Run a purchase
Both flows leave the simulator in the same state: products and prices come from the local config. Drive your paywall and complete a purchase the same way you would for any other UI test:
lim ios launch-app --id <instance-id> com.example.MyApp
lim ios open-url --id <instance-id> "myapp://settings/upgrade"
lim ios tap-element --id <instance-id> --ax-label "Subscribe Monthly"
# StoreKit shows its purchase sheet. Tap the confirm button to complete.
lim ios tap-element --id <instance-id> --ax-label "Confirm"await ios.launchApp(bundleId);
// However your app reaches the paywall: a deep link, a tab, a button tree.
await ios.openUrl('myapp://settings/upgrade');
await ios.tapElement({ AXLabel: 'Subscribe Monthly' });
// StoreKit shows its purchase sheet. Tap the confirm button to complete.
await ios.tapElement({ AXLabel: 'Confirm' });What you'll see on screen. The StoreKit purchase sheet looks identical to the production one, except the bottom of the sheet shows [Environment: Xcode] instead of [Environment: Production] or [Environment: Sandbox]. There's no Apple ID prompt, no sandbox-tester sign-in, and the confirmation step completes immediately rather than waiting on a network call to Apple.
What your app code sees. From your app's perspective the purchase is real. You await the purchase call, get back a signed transaction, and the listeners and entitlement checks you've already wired up run unchanged. The only thing that differs from a real-device purchase is the certificate that signs the transaction: it's Apple's local-test certificate, not their production one.
The one caveat: server-side receipt validation. If your backend verifies receipts by calling Apple's production or sandbox verifyReceipt / App Store Server API, those calls will reject the local-environment transaction because the signing certificate isn't Apple's production one. Stub the validation server out in your test environment, or short-circuit it when the build is ad-hoc-signed.
State persists across app relaunches. The simulator remembers the purchase as long as the local config is active. To clear it between test cases without dropping the config, soft-reset the app's data container:
// 'data' (default): terminate the app + wipe its data container. No reinstall.
await ios.softReset(bundleId);
// 'full': terminate + uninstall + clear keychain + clear privacy grants
// + drop NSUserDefaults + wipe shared App Group containers + reinstall
// from the cached source .app.
await ios.softReset(bundleId, { strategy: 'full' });To roll the whole environment back to "no local config," see Clear or replace the config.
Clear or replace the config
Two ways to undo setStoreKitConfig:
// Wipe the local config for this bundle. Future StoreKit requests fall back
// to the real sandbox (or fail if no sandbox account is signed in).
await ios.clearStoreKitConfig(bundleId);
// Or overwrite with a different .storekit. The latest call wins.
await ios.setStoreKitConfig(bundleId, fs.readFileSync('./OtherProducts.storekit'));clearStoreKitConfig is safe to call when nothing is registered, so you can put it in a test teardown without checking state first.
Troubleshooting
Next steps
Run an iOS Simulator
The full device-control surface: taps, element queries, screenshots, app lifecycle.
Build with remote Xcode
The build options behind xcodebuild(): schemes, signing, log streaming, IPA output.
Automatic PR Previews
Ship every PR with a built app and a live preview link from GitHub Actions.
Was this guide helpful?