The Ultimate Guide to Building and Testing SwiftUI Components
In modern app development, the ability to build, test, and iterate on UI components quickly and reliably is paramount. A disorganized project can lead to slow build times, inconsistent UIs, and a frustrating developer experience.
This guide will walk you through a professional, end-to-end workflow for developing SwiftUI components in isolation using Swift Packages. We'll cover everything from the initial setup to a fully automated CI/CD pipeline on GitHub that allows for visual testing, a seamless review process, and integration with external automation tools.
Chapter 1: The Power of Isolation with Swift Packages
The foundation of this workflow is the Swift Package Manager (SPM). Instead of building your UI components inside your main app target, we will isolate them in their own dedicated Swift Packages.
Why Isolate Components?
- Speed: Compiling a small, focused package is dramatically faster than building your entire application. This means quicker previews and faster test runs, enabling rapid iteration.
- Clarity: It enforces a clean separation of concerns. A component package has no knowledge of your app's business logic, networking, or data models.
- Reusability: Once a component is in a package, it can be easily shared across multiple app targets (e.g., your main app and a watchOS app) or even across different projects.
The "Magic" of Xcode and SPM
One of the best features of this approach is its simplicity. You don't need to create complex Xcode projects or workspaces to manage your packages.
You can simply open the folder containing your Package.swift
file directly in Xcode. Xcode will read the manifest, resolve dependencies, and provide you with a full-featured development environment, including live previews, code completion, and a test runner.
Chapter 2: The Minimalist Setup
Let's start by creating a new package from the command line.
-
Create the Package: Open your terminal, navigate to your project's root, and create a directory to hold your packages.
mkdir -p Packages/FeedComponents cd Packages/FeedComponents swift package init --type library
-
Configure
Package.swift
: Open the generatedPackage.swift
file. We'll configure it for an iOS-only target and add our snapshot testing dependency.// swift-tools-version: 5.10 import PackageDescription let package = Package( name: "FeedComponents", platforms: [ .iOS(.v15) ], products: [ .library(name: "FeedComponents", targets: ["FeedComponents"]), ], dependencies: [ .package(url: "[https://github.com/pointfreeco/swift-snapshot-testing.git](https://github.com/pointfreeco/swift-snapshot-testing.git)", from: "1.16.0"), ], targets: [ .target(name: "FeedComponents"), .testTarget( name: "FeedComponentsTests", dependencies: [ "FeedComponents", .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), ] ), ] )
-
Create a View and a Test: Create a simple SwiftUI view in
Sources/FeedComponents/InfoPill.swift
and a corresponding test file inTests/FeedComponentsTests/InfoPillTests.swift
.InfoPill.swift:
import SwiftUI public struct InfoPill: View { let title: String public init(title: String) { self.title = title } public var body: some View { Text(title) .font(.footnote.bold()) .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.blue, in: Capsule()) } }
InfoPillTests.swift:
import XCTest import SwiftUI import SnapshotTesting @testable import FeedComponents final class InfoPillTests: XCTestCase { func testInfoPill() { let pillView = InfoPill(title: "New Update") assertSnapshot(of: pillView, as: .image) } }
Chapter 3: Mastering Snapshot Testing from the CLI
To ensure our components look exactly as intended, we'll use snapshot testing. The key is to run these tests in a real iOS Simulator to get pixel-perfect results.
The Correct Command
The swift test
command is not sufficient for this. You must use xcodebuild
to specify the simulator destination.
xcodebuild test \
-scheme FeedComponents \
-destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=latest'
Recording and Asserting Snapshots
-
Record the Reference: The first time you run a test, you need to record the "correct" image. Edit your test file to set
record: true
.// In InfoPillTests.swift assertSnapshot(of: pillView, as: .image, record: true)
Now, run the
xcodebuild
command. A reference image will be saved in a__Snapshots__
folder. -
Assert Against the Reference: Change
record: true
back tofalse
. From now on, every time you run thexcodebuild
command, the test will compare the current rendering of the view against the saved image and fail if there are any differences.
Testing Multiple Devices
You can easily test different device configurations by changing the snapshot strategy.
func testInfoPill_Devices() {
let pillView = InfoPill(title: "New Update")
// Test on an iPhone in dark mode
assertSnapshot(
of: pillView,
as: .image(layout: .device(config: .iPhone16Pro.darkMode)))
// Test on an iPad
assertSnapshot(
of: pillView,
as: .image(layout: .device(config: .iPadPro11Inch)))
}
Chapter 4: Automating with GitHub Actions
Now, let's automate this entire process with GitHub Actions. This workflow will run tests for multiple packages on every pull request.
Create a file at .github/workflows/swift_package_tests.yml
:
name: Swift Package Snapshot Tests
on:
pull_request:
branches: [ "main" ]
workflow_dispatch:
inputs:
package_to_test:
description: 'Which package to test? (e.g., FeedComponents, FlowComponents, or all)'
required: true
default: 'all'
type: 'choice'
options: [all, FeedComponents, FlowComponents]
simulator_name:
description: 'The name of the iOS Simulator to use'
required: true
default: 'iPhone 16 Pro'
type: 'string'
jobs:
run-snapshot-tests:
name: Run iOS Snapshot Tests
runs-on: macos-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.2.0'
- name: Run FeedComponents Tests
if: github.event_name == 'pull_request' || github.event.inputs.package_to_test == 'all' || github.event.inputs.package_to_test == 'FeedComponents'
working-directory: ./FeedComponents
run: |
xcodebuild test \
-scheme FeedComponents \
-destination 'platform=iOS Simulator,name=${{ github.event.inputs.simulator_name || 'iPhone 16 Pro' }},OS=latest' \
|| true
- name: Run FlowComponents Tests
if: github.event_name == 'pull_request' || github.event.inputs.package_to_test == 'all' || github.event.inputs.package_to_test == 'FlowComponents'
working-directory: ./FlowComponents
run: |
xcodebuild test \
-scheme FlowComponents \
-destination 'platform=iOS Simulator,name=${{ github.event.inputs.simulator_name || 'iPhone 16 Pro' }},OS=latest' \
|| true
- name: Upload Snapshot Diffs
uses: actions/upload-artifact@v4
if: failure()
with:
name: snapshot-diffs
path: /Users/runner/Library/Developer/Xcode/DerivedData/**/Attachments/*.png
retention-days: 7
This workflow automatically tests your packages and, if a snapshot test fails, uploads the image diffs as an artifact to the pull request for easy visual review.
Chapter 5: Closing the Loop: Approving Snapshots via PR Comment
The final piece of the puzzle is creating a way to "accept" new snapshots when a visual change is intentional. This second workflow will listen for a command in a PR comment and automatically commit the new snapshots.
Create a file at .github/workflows/accept_snapshots.yml
:
name: Accept New Snapshots
on:
issue_comment:
types: [created]
repository_dispatch:
types: [accept-snapshots]
jobs:
accept-snapshots:
if: |
(github.event.issue.pull_request && contains(github.event.comment.body, '/accept-snapshots')) ||
github.event_name == 'repository_dispatch'
runs-on: macos-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout PR Branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.issue.pull_request.head.ref || github.event.client_payload.branch }}
- name: Download Snapshot Diffs
uses: actions/download-artifact@v4
with:
name: snapshot-diffs
path: .
- name: Overwrite Reference Snapshots
run: |
find . -type d -name "__Snapshots__" | while read snapshot_dir; do
attachment_dir=$(find . -type d -path "*/Attachments/$(basename "$snapshot_dir")")
if [ -d "$attachment_dir" ]; then
cp -v "$attachment_dir"/*.png "$snapshot_dir"/
fi
done
- name: Commit Updated Snapshots
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git add '**/__Snapshots__/*.png'
if git diff --staged --quiet; then
echo "No new snapshots to commit."
else
git commit -m "ci: Accept new snapshot test results"
git push
fi
- name: Comment on PR
if: github.event_name == 'issue_comment'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '✅ New snapshots have been accepted and committed to the branch.'
})
Now, to approve a change, you simply comment /accept-snapshots
on the pull request.
Chapter 6: Advanced Integration with Webhooks and APIs
You can trigger both of these workflows from an external service like n8n or a custom backend by making a call to the GitHub API. This allows for powerful custom automation.
Prerequisites
You will need a GitHub Personal Access Token (PAT) with the repo
scope to authorize the API calls. Store this securely.
Triggering the Main Test Workflow
The workflow_dispatch
event in our main test workflow is designed for this. You send a POST
request to the /dispatches
endpoint for that specific workflow file.
Example curl
command:
curl \
-X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token YOUR_GITHUB_TOKEN" \
[https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO/actions/workflows/swift_package_tests.yml/dispatches](https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO/actions/workflows/swift_package_tests.yml/dispatches) \
-d '{
"ref": "main",
"inputs": {
"package_to_test": "FeedComponents",
"simulator_name": "iPhone 16 Pro Max"
}
}'
The inputs
object allows you to pass the parameters defined in the workflow file.
Triggering the "Accept Snapshots" Workflow
The "accept" workflow uses the repository_dispatch
event. This is a more general-purpose webhook that you can send custom data to.
Example curl
command:
curl \
-X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token YOUR_GITHUB_TOKEN" \
[https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO/dispatches](https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO/dispatches) \
-d '{
"event_type": "accept-snapshots",
"client_payload": {
"branch": "feature/new-component-styles",
"pr_number": 123
}
}'
event_type
: Must match one of the types listed in your workflow file (accept-snapshots
).client_payload
: An object where you can send any custom data your workflow needs, like the branch to check out.
Integrating with n8n
In your n8n workflow, you would use the HTTP Request node to make these API calls.
- Authentication:
Generic Credential Type
->Header Auth
. - Name:
Authorization
- Value:
token YOUR_GITHUB_TOKEN
- Method:
POST
- URL: The GitHub API endpoint URL.
- Body Content Type:
JSON
- Body: Paste the JSON payload from the
curl
examples above.
This setup allows you to build powerful external tools that can trigger and control your entire snapshot testing and approval process.
Conclusion
By combining the power of Swift Packages for isolation, xcodebuild
for precise testing, and GitHub Actions for automation, you can create a robust, scalable, and collaborative workflow for your SwiftUI components. This setup not only improves code quality and developer velocity but also makes the process of visual review and external integration a seamless part of your development cycle.