Selective Testing with SnapshotPreviews
Render only selected SnapshotPreviews groups in CI while keeping Sentry aware of the full tracked image set.
Selective testing is an advanced workflow for large suites where rendering every preview on every pull request is too expensive. On each run you render and upload only a subset of your previews, and Sentry diffs that subset against the baseline.
There are two approaches:
- Approach A — upload just the subset. Simplest to set up. Any image you don't upload counts as skipped, so Sentry can't detect deletions or renames.
- Approach B — upload the subset plus the full list of tracked image names. Sentry can then tell apart snapshots that were rendered, skipped, or removed/renamed.
For both implementations, you first organize snapshots into groups and upload a baseline build, covered in the next two sections. The approaches differ only in the final render-and-upload step.
Create one SnapshotTest subclass per group CI can select. Inclusion filters (snapshotPreviewModules(), snapshotPreviews()) define what each group renders.
MyAppSnapshotTests.swiftimport SnapshottingTests
final class ModuleASnapshots: SnapshotTest {
override class func snapshotPreviewModules() -> [String]? {
["ModuleA"]
}
}
final class ModuleBSnapshots: SnapshotTest {
override class func snapshotPreviewModules() -> [String]? {
["ModuleB"]
}
}
import SnapshottingTests
final class ModuleASnapshots: SnapshotTest {
override class func snapshotPreviewModules() -> [String]? {
["ModuleA"]
}
}
final class ModuleBSnapshots: SnapshotTest {
override class func snapshotPreviewModules() -> [String]? {
["ModuleB"]
}
}
Render and upload the full suite on your base branch — typically via a CI workflow that runs on every merge.
TEST_RUNNER_SNAPSHOTS_EXPORT_DIR="/tmp/base_snapshots" \
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'
sentry-cli snapshots upload "/tmp/base_snapshots" \
--app-id com.example.MyApp
TEST_RUNNER_SNAPSHOTS_EXPORT_DIR="/tmp/base_snapshots" \
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'
sentry-cli snapshots upload "/tmp/base_snapshots" \
--app-id com.example.MyApp
In this example, the full suite produces this set of tracked images:
/tmp/base_snapshots/
├── ModuleA_HomeView.swift_Light.png
├── ModuleA_HomeView.swift_Dark.png
├── ModuleA_ProfileView.swift_Profile.png
├── ModuleB_CartView.swift_Cart.png
├── ModuleB_CheckoutView.swift_Checkout.png
└── ModuleB_SettingsView.swift_Settings.png
/tmp/base_snapshots/
├── ModuleA_HomeView.swift_Light.png
├── ModuleA_HomeView.swift_Dark.png
├── ModuleA_ProfileView.swift_Profile.png
├── ModuleB_CartView.swift_Cart.png
├── ModuleB_CheckoutView.swift_Checkout.png
└── ModuleB_SettingsView.swift_Settings.png
Note that we also have a .json sidecar for each image (not shown here for brevity).
On each pull request, render the selected subset of previews and upload it. Your CI decides which groups to render — by changed files, code ownership, shard assignment, or whatever fits your project. In the examples below, we render the Module A group and skip Module B.
Pick one of the two approaches below, depending on whether you need removal and rename detection.
Render the selected group with xcodebuild test:
TEST_RUNNER_SNAPSHOTS_EXPORT_DIR="/tmp/head_snapshots" \
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleASnapshots
TEST_RUNNER_SNAPSHOTS_EXPORT_DIR="/tmp/head_snapshots" \
xcodebuild test \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleASnapshots
This produces a set of only Module A's rendered images:
/tmp/head_snapshots/
├── ModuleA_HomeView.swift_Light.png
├── ModuleA_HomeView.swift_Dark.png
└── ModuleA_ProfileView.swift_Profile.png
/tmp/head_snapshots/
├── ModuleA_HomeView.swift_Light.png
├── ModuleA_HomeView.swift_Dark.png
└── ModuleA_ProfileView.swift_Profile.png
Upload the subset with the --selective flag:
sentry-cli snapshots upload "/tmp/head_snapshots" \
--app-id com.example.MyApp \
--selective
sentry-cli snapshots upload "/tmp/head_snapshots" \
--app-id com.example.MyApp \
--selective
With --selective, Sentry reports every tracked image you didn't upload as skipped — including ones that might have been removed or renamed.
Alongside the rendered subset, upload a file listing every tracked image name. With that image-name file, Sentry distinguishes images you rendered (diffed against the baseline) from ones skipped this run, and flags any baseline image absent from the file as removed or renamed.
Rendering and name generation can't share a run, so in this example we compile the app once up front with build-for-testing:
xcodebuild build-for-testing \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-derivedDataPath /tmp/DerivedData
xcodebuild build-for-testing \
-project MyApp.xcodeproj \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-derivedDataPath /tmp/DerivedData
Steps 2 and 3 then run against the generated .xctestrun with test-without-building, so neither recompiles:
/tmp/DerivedData/Build/Products/<generated>.xctestrun
/tmp/DerivedData/Build/Products/<generated>.xctestrun
Render the selected group with test-without-building:
TEST_RUNNER_SNAPSHOTS_EXPORT_DIR="/tmp/head_snapshots" \
xcodebuild test-without-building \
-xctestrun /tmp/DerivedData/Build/Products/<generated>.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleASnapshots
TEST_RUNNER_SNAPSHOTS_EXPORT_DIR="/tmp/head_snapshots" \
xcodebuild test-without-building \
-xctestrun /tmp/DerivedData/Build/Products/<generated>.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleASnapshots
This produces a set of only Module A's rendered images:
/tmp/head_snapshots/
├── ModuleA_HomeView.swift_Light.png
├── ModuleA_HomeView.swift_Dark.png
└── ModuleA_ProfileView.swift_Profile.png
/tmp/head_snapshots/
├── ModuleA_HomeView.swift_Light.png
├── ModuleA_HomeView.swift_Dark.png
└── ModuleA_ProfileView.swift_Profile.png
Run each group in name-only mode by setting the environment variable TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE to point to a file in the path you want to write the names to. Run each group in its own invocation — passing multiple SnapshotTest classes to a single name-generation run isn't supported.
In the example below, we generate a name file for each group — including Module B, which we didn't render — so the list covers every tracked image, not just the rendered subset.
TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE="/tmp/names_1.txt" \
xcodebuild test-without-building \
-xctestrun /tmp/DerivedData/Build/Products/<generated>.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleASnapshots
TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE="/tmp/names_2.txt" \
xcodebuild test-without-building \
-xctestrun /tmp/DerivedData/Build/Products/<generated>.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleBSnapshots
TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE="/tmp/names_1.txt" \
xcodebuild test-without-building \
-xctestrun /tmp/DerivedData/Build/Products/<generated>.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleASnapshots
TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILE="/tmp/names_2.txt" \
xcodebuild test-without-building \
-xctestrun /tmp/DerivedData/Build/Products/<generated>.xctestrun \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:MyAppTests/ModuleBSnapshots
# names_1.txt
ModuleA_HomeView.swift_Light.png
ModuleA_HomeView.swift_Dark.png
ModuleA_ProfileView.swift_Profile.png
# names_2.txt
ModuleB_CartView.swift_Cart.png
ModuleB_CheckoutView.swift_Checkout.png
ModuleB_SettingsView.swift_Settings.png
Next we need to combine the per-group files into one de-duplicated list; here we join them with cat and sort them with sort -u:
cat /tmp/names_1.txt \
/tmp/names_2.txt \
| sort -u > /tmp/names_all.txt
cat /tmp/names_1.txt \
/tmp/names_2.txt \
| sort -u > /tmp/names_all.txt
The result is a single file containing the full tracked set of image names:
names_all.txtModuleA_HomeView.swift_Dark.png
ModuleA_HomeView.swift_Light.png
ModuleA_ProfileView.swift_Profile.png
ModuleB_CartView.swift_Cart.png
ModuleB_CheckoutView.swift_Checkout.png
ModuleB_SettingsView.swift_Settings.png
ModuleA_HomeView.swift_Dark.png
ModuleA_HomeView.swift_Light.png
ModuleA_ProfileView.swift_Profile.png
ModuleB_CartView.swift_Cart.png
ModuleB_CheckoutView.swift_Checkout.png
ModuleB_SettingsView.swift_Settings.png
Add the flag --all-image-file-names-file <path> with your image-name file when uploading.
sentry-cli snapshots upload "/tmp/head_snapshots" \
--app-id com.example.MyApp \
--all-image-file-names-file /tmp/names_all.txt
sentry-cli snapshots upload "/tmp/head_snapshots" \
--app-id com.example.MyApp \
--all-image-file-names-file /tmp/names_all.txt
Alternatively you can pass the image filenames as a comma-separated list by using --all-image-file-names <names> instead of --all-image-file-names-file <path>.
sentry-cli snapshots upload "/tmp/head_snapshots" \
--app-id com.example.MyApp \
--all-image-file-names "ModuleA_HomeView.swift_Light.png,ModuleA_HomeView.swift_Dark.png,ModuleA_ProfileView.swift_Profile.png,ModuleB_CartView.swift_Cart.png,ModuleB_CheckoutView.swift_Checkout.png,ModuleB_SettingsView.swift_Settings.png"
sentry-cli snapshots upload "/tmp/head_snapshots" \
--app-id com.example.MyApp \
--all-image-file-names "ModuleA_HomeView.swift_Light.png,ModuleA_HomeView.swift_Dark.png,ModuleA_ProfileView.swift_Profile.png,ModuleB_CartView.swift_Cart.png,ModuleB_CheckoutView.swift_Checkout.png,ModuleB_SettingsView.swift_Settings.png"
And that's it! Sentry will now diff the uploaded subset against the baseline and report any changes.
The inclusion overrides (snapshotPreviews(), snapshotPreviewModules()) choose what each group renders — use them to shard your suite. The exclusion overrides (excludedSnapshotPreviews(), excludedSnapshotPreviewModules()) remove matching previews from the tracked set entirely, both from the rendered images and from the image-name file. Use them to drop previews you don't want tracked, such as flaky ones or whole modules.
For example, to stop tracking a flaky Module A preview:
MyAppSnapshotTests.swiftfinal class ModuleASnapshots: SnapshotTest {
override class func snapshotPreviewModules() -> [String]? {
["ModuleA"]
}
override class func excludedSnapshotPreviews() -> [String]? {
["ModuleA/HomeView.swift:Dark"]
}
}
final class ModuleASnapshots: SnapshotTest {
override class func snapshotPreviewModules() -> [String]? {
["ModuleA"]
}
override class func excludedSnapshotPreviews() -> [String]? {
["ModuleA/HomeView.swift:Dark"]
}
}
Because exclusions drop previews from the image-name file too, a preview that's in the baseline but excluded on a pull request is reported as removed under Approach B. (Approach A has no image-name file, so it's reported as skipped instead.)
TEST_RUNNER_SNAPSHOTS_ALL_IMAGE_NAMES_FILEandTEST_RUNNER_SNAPSHOTS_EXPORT_DIRare mutually exclusive. One run writes names; another renders images.- Passing an image-name file (
--all-image-file-names-file, or--all-image-file-namesfor an inline list) marks the upload as selective on its own, so you don't also pass--selective. - SnapshotPreviews writes each image as
<Module>_<File>.swift_<Preview>.pngwith no directory prefix. If your upload root nests images in per-simulator subfolders (for exampleiphone/andipad/), prefix every entry in the image-name file with that subfolder.
For a complete working example, see the SnapshotPreviews MultiModuleDemo app, CI workflow, and a pull request that triggers a selective run:
Our documentation is open source and available on GitHub. Your contributions are welcome, whether fixing a typo (drat!) or suggesting an update ("yeah, this would be better").