A small SwiftUI chat app whose screens are covered by
swift-snapshot-testing
snapshots, wired up to Screenshotbot. The Xcode
project is not checked in — it is generated by Tuist from
Project.swift.
If you already have a Tuist project, the short version is: nothing about
Screenshotbot changes. Snapshots are written next to your test sources, so the
recorder uploads them the same way it would from a hand-maintained .xcodeproj.
The only new requirement is that CI runs tuist install && tuist generate
before it builds.
mise install # installs the Tuist version pinned in .mise.toml
tuist install # resolves the Swift package dependencies
tuist generate # writes SimpleProject.xcworkspace and opens itmise will ask you to mise trust this repo the first time. If you'd rather not
use mise, any Tuist 4.x install works.
Run the tests from the AllTests scheme in Xcode, or:
bundle exec fastlane tests # tuist test, with selective testingRe-record every snapshot with RECORD_SNAPSHOTS=1 in the test action's
environment (see the header of SimpleProjectTests/SnapshotSupport.swift).
| File | Role |
|---|---|
Tuist.swift |
Project-wide Tuist configuration. |
Project.swift |
The app, every test target, and the schemes. This replaces SimpleProject.xcodeproj. |
Tuist/Package.swift |
External Swift package dependencies — here, Screenshotbot's fork of swift-snapshot-testing. |
.mise.toml |
Pins the Tuist version so every machine and CI runner generates the same project. |
fastlane/Fastfile |
generate → tests → upload to Screenshotbot. |
.circleci/config.yml |
CircleCI: installs mise, then runs the screenshotbot_ci lane. |
ci_scripts/ |
The equivalent hooks for Xcode Cloud. |
SimpleProject.xcodeproj, SimpleProject.xcworkspace, Derived/ and
Tuist/.build are all generated, and all git-ignored.
Screenshotbot uses a fork of swift-snapshot-testing that does not fail a test
run when a reference image is missing — comparison happens on Screenshotbot, not
on the CI machine. With Tuist it's declared in Tuist/Package.swift:
.package(url: "https://github.com/tdrhq/swift-snapshot-testing", branch: "main"),and consumed by the test target in Project.swift as
.external(name: "SnapshotTesting").
Twelve of them, laid out so the interesting cases are all represented:
| Targets | Snapshots? | Tagged? | Reaches SnapshotTesting |
|---|---|---|---|
SimpleProjectTests |
yes | no | directly |
InboxSnapshotTests, ThreadSnapshotTests, BubbleSnapshotTests, ComponentsSnapshotTests, OnboardingSnapshotTests |
yes | screenshotbot |
through SnapshotSupport |
ChatModelsTests, ChatClockTests, SampleDataTests, StringsTests, PaletteTests |
no | no | not at all |
SimpleProjectUITests |
no | no | not at all |
Each snapshot target writes its own __Snapshots__ directory and therefore
becomes its own Screenshotbot channel, while the five plain unit test targets
produce nothing to upload — so a run where only those changed uploads nothing at
all.
The SnapshotSupport framework is the point of the middle row: real
multi-module apps wrap the snapshot library in their own helpers, so tooling
that classifies targets has to walk the dependency graph transitively rather
than look for a direct edge. Tagging with TargetMetadata is the explicit
alternative:
metadata: .metadata(tags: ["screenshotbot"])SimpleProjectTests is deliberately left untagged, so both routes — inference
and declaration — are exercised.
Tuist doesn't autogenerate a scheme for test targets, and nothing in a Tuist repo
should depend on Xcode autocreating one. Project.swift declares them
explicitly; AllTests covers every unit test target and is the scheme CI runs.
One scheme rather than twelve is deliberate — selective testing skips per target
within a run, so this is what makes partial skips visible.
Both CI setups do the same three things: install Tuist, generate the project, then run and upload the snapshots.
CircleCI (.circleci/config.yml + fastlane/Fastfile) — the setup
command installs mise and the pinned Tuist, and the screenshotbot_ci lane runs:
lane :screenshotbot_ci do
run_tuist "install" # resolve the Swift package dependencies
tests # tuist test — generates, then runs, AllTests
fetch_screenshotbot # install the recorder
screenshotbot_upload_all
endtuist test produces an .xcresult rather than the JUnit XML fastlane's scan
used to emit, so CircleCI keeps the result bundle as a build artifact instead of
feeding store_test_results.
Xcode Cloud (ci_scripts/) — ci_post_clone.sh installs Tuist and generates
the workspace before the build starts; ci_post_xcodebuild.sh uploads the
.xcresult to Screenshotbot. In the Xcode Cloud UI, point the workflow at
SimpleProject.xcworkspace and the AllTests scheme — both exist by
the time ci_post_clone.sh finishes.
The tests lane runs tuist test rather than xcodebuild test. Tuist hashes
each target — sources, resources, settings, dependencies, manifest — and skips
test targets whose hash matches an earlier successful run:
tuist test AllTests --device "iPhone SE (3rd generation)"
tuist test AllTests --no-selective-testing # force a full runSelective testing and snapshot testing want opposite things, so the lane runs
tuist test twice:
snapshot_targets = snapshot_test_targets # read out of the Tuist graph
# 1. everything else, selectively
run_tuist "test AllTests … " + snapshot_targets.map { |t| "--skip-test-targets #{t}" }.join(" ")
# 2. the snapshot targets, always
run_tuist "test AllTests --no-selective-testing … " + snapshot_targets.map { |t| "--test-targets #{t}" }.join(" ")Snapshot targets always run, so every channel gets screenshots on every commit and no marker bookkeeping is needed. Everything else is skipped when unchanged, which is where the time was going anyway.
snapshot_test_targets doesn't hardcode a list — it shells out to tuist graph --format json and picks targets that are tagged screenshotbot or reach
SnapshotTesting transitively, so adding a snapshot target to Project.swift
is all it takes for CI to treat it correctly.
Two things to know if you adapt this:
- The flags are repeated, not comma-separated.
--test-targets A,Bis parsed as one target name and fails with "The following targets were not found: A,B". tuist graphconstructs the full graph, so this costs one extra graph load per CI run on top of the two test invocations.
Those hashes are cached locally by default, which is no use to a CI runner that
starts empty every time. Sharing them across machines is what the tuist.dev
account is for — a fullHandle, plus a project token on CI:
tuist auth login
tuist organization create screenshotbot # or use your personal handle
tuist project create screenshotbot/tuist-example
tuist project tokens create screenshotbot/tuist-exampleThe token goes into CircleCI's project-level environment variables as
TUIST_TOKEN — not into .circleci/config.yml, which is committed.
You don't need any of this to use this repo. A project that declares a
fullHandle refuses to generate unless the caller is authenticated against that
tuist.dev project, which would mean requiring an account just to clone the
sample and read it. So Tuist.swift takes the handle from the environment:
let fullHandle = Environment.fullHandle.getString(default: "")
let tuist = fullHandle.isEmpty ? Tuist() : Tuist(fullHandle: fullHandle)The CircleCI job sets TUIST_FULL_HANDLE (Environment.fullHandle reads
TUIST_FULL_HANDLE) next to its token. Everywhere else — a fresh clone, Xcode
Cloud, a fork's PR — generation works offline and every test simply runs.
Selective testing is a Tuist feature, not a Screenshotbot requirement. Swap the lane back to
xcodebuild testand the snapshot workflow below is unchanged.
A skipped target writes no snapshots. Since the snapshots aren't committed, a
fresh CI clone starts with an empty __Snapshots__ directory — and uploading an
empty directory tells Screenshotbot that every screenshot in the channel was
deleted, not that nothing changed.
The two-pass lane above is what keeps that from happening: snapshot targets are never skipped, so every channel is uploaded on every commit. The upload lane still skips empty directories as a backstop, in case a snapshot target is excluded some other way.
The alternative — letting snapshot targets be skipped and telling Screenshotbot
"this channel is unchanged from commit X" with --mark-unchanged-from — needs
an anchor commit that the build system can't give you. Tuist's stored hashes are
project-global, so a skip can be justified by a run from any ancestor, or from
another branch entirely; the parent commit is not a safe assumption. Always
running the snapshot targets sidesteps the whole question.
Tuist checks Swift package dependencies out into Tuist/.build, and
swift-snapshot-testing ships __Snapshots__ directories of its own. A naive
"upload every __Snapshots__ directory" sweep would turn those into
Screenshotbot channels, so the upload lane skips anything under Tuist/:
Dir.glob("../**/__Snapshots__/").reject { |dir| dir.include?("/Tuist/") }Each remaining directory becomes one channel, named after the directory that
contains it — SimpleProjectTests, InboxSnapshotTests, and so on, one channel per snapshot target.
Six snapshot targets means six channels, and without batching that's six
separate build statuses per commit. --batch groups them:
sh "~/screenshotbot/recorder --channel #{channel_name} --batch sample-app " \
"--directory #{dir} --recursive"The batch name is shared across the per-channel invocations, and that's what ties them into one status. It matters more the more targets you have — a monorepo with a channel per feature module wants exactly one notification per commit, not forty.