The hardest in-app purchase state-machine paths to reproduce are usually not successful purchases, but cancellations, pending transactions, refunds, and transactions left unfinished from a previous run. Delegating these branches directly to an external transaction environment introduces network conditions, product configuration, and test-account state into the results. A more reliable approach is to define products with a StoreKit configuration file on a cloud Mac, then use SKTestSession to control transaction conditions and turn client-side logic into repeatable regression tests.
Define the boundaries of offline testing
StoreKit configuration is well suited to validating product identifier mappings, purchase-state transitions, entitlement refreshes, error messages, and transaction completion logic. It does not require access to an external payment flow, and failures are easier to reproduce.
However, it cannot prove that production products are configured correctly, nor can it cover server notifications, real receipt validation, or the final payment interface. Divide testing into three layers:
| Layer | Primary goal | Execution frequency |
|---|---|---|
| Unit tests | Entitlement calculations and state mappings | Every commit |
| StoreKit integration tests | Purchases, cancellations, refunds, and unfinished transactions | Every merge |
| External environment acceptance tests | Product configuration, notifications, and receipt flow | Before release |
The purpose of offline testing is to shorten the feedback loop, not to disguise every transaction risk as a green check mark.
Fix products and test entry points
Create a StoreKit Configuration File in Xcode and include only the products required by the tests. Product identifiers must match the constants in the code. Prices should be used only for UI testing; business logic must not determine entitlements from localized price strings.
Add the configuration file to the test Scheme and create a dedicated test plan, such as StoreKitRegression.xctestplan. Do not reuse the Scheme developers run every day, because a manual change could silently alter CI conditions.
The test target can recreate the session in setUpWithError():
import StoreKitTest
import XCTest
final class PurchaseRegressionTests: XCTestCase {
private var session: SKTestSession!
override func setUpWithError() throws {
session = try SKTestSession(configurationFileNamed: "Products")
session.disableDialogs = true
session.clearTransactions()
session.failTransactionsEnabled = false
}
override func tearDownWithError() throws {
session.clearTransactions()
session = nil
}
}
Specify the configuration filename without its extension. If initialization fails, first confirm that the file belongs to the test target and verify that the Scheme uses the same configuration.
Split transaction branches into separate test cases
Do not test success, refunds, and cancellations sequentially in one long test case. The more states a test covers, the harder it is to identify which step caused contamination after a failure. At minimum, create separate cases for the following scenarios:
Successful purchases and restoration
Verify that entitlements refresh immediately after a purchase and remain available after recreating the business-layer object. Before the test ends, confirm that the app has processed and finished the transaction so the next test case does not receive an old update.
Cancellation and failure injection
A cancellation must not display a “payment failed” message or write an unlocked state. Network and generic transaction errors should preserve an option to retry. After using session properties to inject an error, restore the default values before the current test ends rather than relying on the next test case to clean them up.
Refunds and unfinished transactions
After a refund, revoke the corresponding entitlement without mistakenly removing other products that remain valid. For unfinished transactions, verify that processing continues after the app restarts instead of checking only a single button callback.
Tests that modify the state of the same product should run serially. Parallel tests sharing a simulator and configuration can easily clear one another’s transactions, causing intermittent failures that appear only in CI.
Keep command-line execution conditions fixed
First inspect the available simulators, then store the device name, system version, and Xcode path in CI variables. Do not let xcodebuild select an arbitrary destination automatically.
set -euo pipefail
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
RESULT_DIR="$PWD/TestResults"
rm -rf "$RESULT_DIR"
mkdir -p "$RESULT_DIR"
xcodebuild test \
-workspace App.xcworkspace \
-scheme App-StoreKitTests \
-testPlan StoreKitRegression \
-destination 'platform=iOS Simulator,name=iPhone 16' \
-resultBundlePath "$RESULT_DIR/StoreKit.xcresult"
When running multiple pipelines concurrently on OakVM, assign each job its own working directory and simulator. Do not share DerivedData, result directories, or booted devices. You can run xcrun simctl shutdown all before starting, but this affects other jobs on the same node, so it is appropriate only when a node is dedicated to a single job.
Archive evidence and define failure thresholds
When a test fails, saving only the final few dozen console lines is usually insufficient. Archive the xcresult, commit hash, StoreKit configuration file checksum, Xcode version, and simulator runtime version. Changes to the configuration file should also go through code review to prevent product identifiers from being removed accidentally.
Start by recording an environment summary:
xcodebuild -version
xcrun simctl list runtimes
shasum -a 256 Tests/StoreKit/Products.storekit
The checklist should include:
- Whether each test creates and cleans up its own
SKTestSession - Whether automatic dialogs are disabled to prevent unattended jobs from blocking
- Whether cancellation, failure, and refund scenarios each assert both UI and entitlement states
- Whether tests depend on execution order or a previous transaction
- Whether
xcresultis still uploaded after a failure - Whether logs exclude sensitive credentials and complete transaction data
Once these conditions are fixed, in-app purchase regression testing no longer depends on manual clicking. The external environment handles only what truly requires external verification, while fast, isolated, and traceable tests protect the client-side state machine on every routine commit.
Frequently asked questions
Can offline StoreKit tests replace validation in a real transaction environment?
No. They validate client state handling, product mapping, and failure branches. Live product configuration, server notifications, receipt processing, and payment UI still require separate environment testing.
Why do purchase tests pass alone but fail intermittently in the full suite?
Check for transaction state leaking between tests. Create a fresh SKTestSession per test, clear transactions, disable dialogs, and serialize cases that mutate the same product state.
What evidence should CI retain after an in-app purchase test failure?
Keep the xcresult bundle, test log, StoreKit configuration revision, commit hash, Xcode version, and simulator runtime version without recording sensitive credentials or complete transaction data.
Run your next build on a dedicated physical node
Choose OakVM M4 or OakVM M4 Pro and rent a cloud Mac by the day, week, month, or quarter. Node availability is reported in real time by the control panel.