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.

  1. 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
    
  2. Configure Package.swift: Open the generated Package.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"),
                ]
            ),
        ]
    )
    
  3. Create a View and a Test: Create a simple SwiftUI view in Sources/FeedComponents/InfoPill.swift and a corresponding test file in Tests/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

  1. 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.

  2. Assert Against the Reference: Change record: true back to false. From now on, every time you run the xcodebuild 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.

Blackbelt Agent

Hello! How can I assist you with Blackbelt Labs today? Whether you need information on our app development, AI solutions, or have other inquiries, I'm here to help.

Powered by CopilotKit