StoreKit testing that survives expo prebuild
Testing in-app purchases in an Expo app meant clicking through Xcode after every prebuild, and a clean prebuild wiped it all. A small config plugin now references the .storekit files and generates a StoreKit testing scheme, gated by one environment variable.
StoreKit testing is one of the nicer parts of iOS development: you describe your products in a .storekit file, attach it to the Xcode scheme, and the simulator lets you buy your products without App Store Connect, sandbox accounts or real money. You can even make every purchase fail on purpose to check your error handling.
In an Expo app, there's a catch. The ios folder is generated. Every expo prebuild --clean throws it away and writes a fresh Xcode project, and everything you set up by hand in Xcode is gone.
Six steps after every prebuild
The README of QRcard, our React Native app, had this checklist for testing purchases:
- Right-click the project in Xcode's project navigator.
- Click "Add files to …".
- Select the
.storekitfile, reference it in place and tick the app target. - Open Product → Scheme → Edit Scheme.
- Select Run and open the Options tab.
- Pick the file under StoreKit Configuration.
Then build and run from Xcode. And after testing, run a clean prebuild again, because the .storekit file was now part of the app target and would end up in the bundle.
Nobody enjoys this, so purchases got tested less often than they should. It's also exactly the kind of change Expo has an answer for: a config plugin, which edits the native project during prebuild.
The idea: opt in with one variable
The plugin should change nothing for normal builds, including EAS builds for the App Store. So it only runs when an environment variable names a configuration:
STOREKIT_CONFIG=default pnpm prebuild -p iosWith a configuration, it does two things:
- It references every
.storekitfile fromtest/storekitin a StoreKitTesting group of the Xcode project. The files aren't added to any target, so they're never bundled. They only need to be in the project so the scheme editor can offer them. - It generates a second scheme, "<project> StoreKit Testing", a copy of the default scheme with the chosen configuration attached to Run.
Without the variable, the plugin returns the config untouched. An unknown name fails prebuild instead of producing a scheme that silently points nowhere.
The plugin is registered in app.config.ts, which passes the variable in:
plugins: [
// StoreKit testing in Xcode, e.g. `STOREKIT_CONFIG=purchase-error pnpm prebuild -p ios`
['./expo/config-plugins/storekit-testing.ts', { configuration: process.env.STOREKIT_CONFIG }],
],The plugin
Here's all of it:
import fs from 'node:fs';
import path from 'node:path';
import { type ConfigPlugin, withDangerousMod, withXcodeProject } from '@expo/config-plugins';
type StoreKitTestingProps = {
/** Name of a `.storekit` file in `test/storekit` (without extension), e.g. `default` */
configuration?: string;
};
const storeKitDir = path.join(__dirname, '../../test/storekit');
/**
* Sets up StoreKit testing for Xcode runs: references all `.storekit` files in `test/storekit` in
* the Xcode project (to switch between them in the scheme options) and adds a "<project> StoreKit
* Testing" scheme that uses `<configuration>.storekit`. Does nothing without a configuration, so
* regular (and EAS) builds are unaffected.
*/
const withStoreKitTesting: ConfigPlugin<StoreKitTestingProps | void> = (config, props) => {
const configuration = props?.configuration;
if (!configuration) return config;
const storeKitFiles = fs
.readdirSync(storeKitDir)
.filter((file) => file.endsWith('.storekit'))
.map((file) => path.join(storeKitDir, file));
const storeKitFile = path.join(storeKitDir, `${configuration}.storekit`);
if (!storeKitFiles.includes(storeKitFile)) {
throw new Error(`StoreKit configuration not found: ${storeKitFile}`);
}
// Reference the files in a "StoreKitTesting" group of the project (without adding them to a
// target, so they're never bundled)
config = withXcodeProject(config, (mod) => {
const project = mod.modResults;
const mainGroup = project.getFirstProject().firstProject.mainGroup;
const groupPath = path.relative(mod.modRequest.platformProjectRoot, storeKitDir);
const { uuid: group } = project.addPbxGroup([], 'StoreKitTesting', groupPath, '"<group>"');
project.addToPbxGroup({ fileRef: group, basename: 'StoreKitTesting' }, mainGroup);
for (const file of storeKitFiles) {
const { fileRef } = project.addFile(path.basename(file), group, {
lastKnownFileType: 'text',
});
// `xcode` writes `explicitFileType = undefined` otherwise
delete project.pbxFileReferenceSection()[fileRef].explicitFileType;
}
return mod;
});
// Add a scheme based on the default one, with the StoreKit configuration attached to "Run"
return withDangerousMod(config, [
'ios',
(mod) => {
const { platformProjectRoot, projectName } = mod.modRequest;
const projectDir = path.join(platformProjectRoot, `${projectName}.xcodeproj`);
const schemesDir = path.join(projectDir, 'xcshareddata/xcschemes');
const defaultScheme = fs.readFileSync(
path.join(schemesDir, `${projectName}.xcscheme`),
'utf8',
);
// Xcode resolves the reference relative to the .xcodeproj bundle
const reference = `
<StoreKitConfigurationFileReference
identifier = "${path.relative(projectDir, storeKitFile)}">
</StoreKitConfigurationFileReference>
</LaunchAction>`;
fs.writeFileSync(
path.join(schemesDir, `${projectName} StoreKit Testing.xcscheme`),
defaultScheme.replace(/\n\s*<\/LaunchAction>/, reference),
);
return mod;
},
]);
};
module.exports = withStoreKitTesting;A few parts deserve a closer look.
withXcodeProject for the file references
withXcodeProject hands you the parsed project.pbxproj (through the xcode npm package) and writes it back after all plugins have run. The plugin creates a group whose path points from the ios folder to test/storekit, adds it to the project's main group, and adds each file to that group. Calling addFile instead of something like addResourceFile is the important bit: it creates the file reference without a build phase, so nothing is copied into the app.
explicitFileType = undefined
Without the extra delete, the project file gets this line in every .storekit reference:
explicitFileType = undefined;The xcode library sets the property even when it has no value and then serializes JavaScript's undefined literally. Passing lastKnownFileType: 'text' and deleting explicitFileType from the file reference afterwards gives Xcode the entry it expects.
withDangerousMod for the scheme
Expo has no mod for schemes, so the plugin uses withDangerousMod, which just runs code during prebuild with the paths of the generated project. Prebuild has already written the default scheme at that point, so the plugin reads it, inserts a StoreKitConfigurationFileReference right before the closing </LaunchAction> tag, and saves the result as a new shared scheme. The default scheme stays as it is.
Paths relative to the .xcodeproj
The path in StoreKitConfigurationFileReference isn't relative to the workspace or the ios folder, but to the .xcodeproj bundle itself. So the plugin computes it from <platformProjectRoot>/<projectName>.xcodeproj, which gives something like ../../test/storekit/default.storekit.
Using it
We keep two configurations in test/storekit:
default.storekit: all products, purchases succeed.purchase-error.storekit: every purchase fails with a network error.
To test, prebuild with one of them, open the workspace and run the "<project> StoreKit Testing" scheme:
STOREKIT_CONFIG=purchase-error pnpm prebuild -p ios
open ios/YourApp.xcworkspaceRun the app from Xcode: StoreKit testing only works for apps that Xcode launches.
Because both files are in the project, switching doesn't need another prebuild: Product → Scheme → Edit Scheme → Run → Options → StoreKit Configuration.
With purchase-error, StoreKit first shows its own "Unable to Complete Purchase" alert. Your app's error handling only runs after you dismiss it, so don't mistake the alert for your own UI.
One more thing: Xcode's StoreKit editor writes changes, like simulated failures, back into the .storekit file. That's handy for trying things out, but revert them before you commit.
Takeaways
- If you set something up by hand in Xcode, a clean prebuild will remove it. A config plugin is usually less work than the checklist.
- Gate development-only native changes behind an environment variable, so production builds never see them.
withDangerousModcovers what Expo has no mod for, like schemes. Build on the file prebuild already generated instead of writing your own from scratch.- Check the generated
project.pbxprojonce. Thexcodelibrary can write values Xcode doesn't expect.
The plugin isn't specific to QRcard, so I'll probably publish it to npm at some point. Until then, copy it into your project and point it at the folder with your .storekit files.