```html SwiftUI: How to Build Test Coverage (iOS 17+, 2026)

How to Build Test Coverage in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: Xcode/Coverage Updated: May 11, 2026
TL;DR

Enable Code Coverage in your scheme's Test action, write XCTest cases against @Observable ViewModels, run ⌘U, and read line-level percentages in the Report Navigator → Coverage tab — no third-party tools required.

// 1. In Xcode: Product → Scheme → Edit Scheme → Test → ✓ Code Coverage

// 2. CounterViewModel.swift (in app target)
@Observable
final class CounterViewModel {
    var count = 0
    func increment() { count += 1 }
    func reset()     { count = 0  }
}

// 3. CounterViewModelTests.swift (in test target)
@testable import MyApp
import XCTest

final class CounterViewModelTests: XCTestCase {
    func test_increment_addsOne() {
        let vm = CounterViewModel()
        vm.increment()
        XCTAssertEqual(vm.count, 1)
    }
}

Full implementation

The key to measurable coverage in SwiftUI is separating business logic into @Observable ViewModels that XCTest can instantiate directly — no UIHostingController required. Below is a small feature — a paginated list ViewModel — alongside a complete test suite that demonstrates branch, line, and async coverage. Tick Code Coverage in the scheme once; every subsequent ⌘U run records data automatically.

// MARK: - App target -----------------------------------------------

// Item.swift
import Foundation

struct Item: Identifiable, Equatable {
    let id: UUID
    var title: String
    var isFavorite: Bool
}

// ItemListViewModel.swift
import Foundation
import Observation

enum LoadState: Equatable {
    case idle, loading, loaded, failed(String)
}

@Observable
@MainActor
final class ItemListViewModel {
    private(set) var items: [Item] = []
    private(set) var loadState: LoadState = .idle
    private(set) var searchQuery = ""

    var filtered: [Item] {
        searchQuery.isEmpty
            ? items
            : items.filter { $0.title.localizedCaseInsensitiveContains(searchQuery) }
    }

    // Injected so tests can supply a fake loader
    private let loader: () async throws -> [Item]

    init(loader: @escaping () async throws -> [Item]) {
        self.loader = loader
    }

    func load() async {
        loadState = .loading
        do {
            items = try await loader()
            loadState = .loaded
        } catch {
            loadState = .failed(error.localizedDescription)
        }
    }

    func toggleFavorite(id: UUID) {
        guard let idx = items.firstIndex(where: { $0.id == id }) else { return }
        items[idx].isFavorite.toggle()
    }
}

// ItemListView.swift
import SwiftUI

struct ItemListView: View {
    @State private var vm: ItemListViewModel

    init(vm: ItemListViewModel) { _vm = State(initialValue: vm) }

    var body: some View {
        NavigationStack {
            Group {
                switch vm.loadState {
                case .idle, .loading:
                    ProgressView("Loading…")
                case .loaded:
                    List(vm.filtered) { item in
                        HStack {
                            Text(item.title)
                            Spacer()
                            Image(systemName: item.isFavorite ? "star.fill" : "star")
                                .foregroundStyle(item.isFavorite ? .yellow : .secondary)
                        }
                        .accessibilityLabel("\(item.title)\(item.isFavorite ? ", favorited" : "")")
                    }
                    .searchable(text: Binding(
                        get: { vm.searchQuery },
                        set: { vm.searchQuery = $0 }
                    ))
                case .failed(let msg):
                    ContentUnavailableView("Load failed", systemImage: "exclamationmark.triangle", description: Text(msg))
                }
            }
            .navigationTitle("Items")
            .task { await vm.load() }
        }
    }
}

#Preview {
    ItemListView(vm: ItemListViewModel {
        try await Task.sleep(for: .milliseconds(300))
        return [
            Item(id: UUID(), title: "Sunrise Run",    isFavorite: true),
            Item(id: UUID(), title: "Grocery List",   isFavorite: false),
            Item(id: UUID(), title: "Read SwiftUI docs", isFavorite: false),
        ]
    })
}

// MARK: - Test target -----------------------------------------------

// ItemListViewModelTests.swift
@testable import MyApp
import XCTest

@MainActor
final class ItemListViewModelTests: XCTestCase {

    // MARK: Helpers

    private func makeVM(items: [Item] = sampleItems,
                        shouldFail: Bool = false) -> ItemListViewModel {
        ItemListViewModel {
            if shouldFail { throw URLError(.notConnectedToInternet) }
            return items
        }
    }

    private static let sampleItems = [
        Item(id: UUID(), title: "Alpha", isFavorite: false),
        Item(id: UUID(), title: "Beta",  isFavorite: true),
        Item(id: UUID(), title: "Gamma", isFavorite: false),
    ]

    // MARK: Tests

    func test_initialState_isIdle() {
        let vm = makeVM()
        XCTAssertEqual(vm.loadState, .idle)
        XCTAssertTrue(vm.items.isEmpty)
    }

    func test_load_setsLoadedState() async {
        let vm = makeVM()
        await vm.load()
        XCTAssertEqual(vm.loadState, .loaded)
        XCTAssertEqual(vm.items.count, 3)
    }

    func test_load_setsFailedState() async {
        let vm = makeVM(shouldFail: true)
        await vm.load()
        if case .failed = vm.loadState { /* pass */ }
        else { XCTFail("Expected .failed, got \(vm.loadState)") }
    }

    func test_filtered_returnsAllWhenQueryEmpty() async {
        let vm = makeVM()
        await vm.load()
        XCTAssertEqual(vm.filtered.count, 3)
    }

    func test_filtered_narrowsByQuery() async {
        let vm = makeVM()
        await vm.load()
        vm.searchQuery = "alp"
        XCTAssertEqual(vm.filtered.map(\.title), ["Alpha"])
    }

    func test_toggleFavorite_flipsState() async {
        let vm = makeVM()
        await vm.load()
        let id = vm.items[0].id          // Alpha, isFavorite = false
        vm.toggleFavorite(id: id)
        XCTAssertTrue(vm.items[0].isFavorite)
        vm.toggleFavorite(id: id)
        XCTAssertFalse(vm.items[0].isFavorite)
    }

    func test_toggleFavorite_unknownId_noChange() async {
        let vm = makeVM()
        await vm.load()
        vm.toggleFavorite(id: UUID())    // unknown id — guard branch
        XCTAssertEqual(vm.items, Self.sampleItems)
    }
}

How it works

  1. Scheme flag enables instrumentation. Ticking Code Coverage in Edit Scheme → Test action tells the compiler to insert counters at every reachable statement. Without it, ⌘U runs tests but records nothing. Coverage data is written to a .xccovreport file next to the test log.
  2. @Observable decouples logic from views. Because ItemListViewModel is a plain Swift class (no UIHostingController, no XCUIApplication), XCTest can instantiate and exercise it directly. Every loadState transition and every branch in toggleFavorite is reachable from unit tests in milliseconds.
  3. Injected loader enables fake data. Passing the async loader as a closure lets test_load_setsFailedState() throw a URLError without touching the network. This covers the .failed branch that a happy-path integration test would miss.
  4. The guard branch needs its own test. test_toggleFavorite_unknownId_noChange() calls the method with a random UUID that won't match any item, exercising the early-return path in the guard let idx statement. Without it, the line shows as uncovered (red) in the Coverage report.
  5. Reading the report. After ⌘U, open Report Navigator (⌘9) → the latest test run → Coverage. Click the disclosure arrow on any file to see line percentages. Double-click a line count to jump directly to uncovered code highlighted in red in the source editor.

Variants

Export coverage as JSON for CI

# Run in CI (GitHub Actions, Xcode Cloud custom script, etc.)
xcodebuild test \
  -scheme MyApp \
  -destination "platform=iOS Simulator,name=iPhone 16 Pro" \
  -enableCodeCoverage YES \
  -resultBundlePath TestResults.xcresult

# Export JSON report
xcrun xccov view --report --json TestResults.xcresult > coverage.json

# Fail CI if line coverage < 80 %
python3 - <<'EOF'
import json, sys
data = json.load(open("coverage.json"))
pct  = data["lineCoverage"] * 100
print(f"Line coverage: {pct:.1f}%")
sys.exit(0 if pct >= 80 else 1)
EOF

Exclude generated or boilerplate files

Add a COVERAGE_FILE_EXCLUSION_PATTERNS build setting (or use the Gather coverage for dropdown in the scheme to target specific targets instead of "All targets"). Common exclusions: auto-generated Core Data NSManagedObject subclasses, SwiftGen output, and AppDelegate.swift. In .xccovignore (place it at the project root) list glob patterns — one per line — e.g. **/Generated/**. Xcode 16 respects this file automatically during test runs.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement test coverage in SwiftUI for iOS 17+.
Use Xcode/Coverage (scheme flag + xcrun xccov).
Make the ViewModel @MainActor and inject dependencies as closures.
Write XCTest cases covering every branch, including guard early-returns.
Make it accessible (VoiceOver labels on interactive elements).
Add a #Preview with realistic sample data.

In the Soarias Build phase, paste this prompt after scaffolding your feature screen so Claude Code generates the ViewModel, tests, and CI export script as a single coherent unit — then verify the coverage badge before moving to the Polish phase.

Related

FAQ

Does this work on iOS 16?

Xcode coverage instrumentation works on any deployment target — the coverage tooling lives in the compiler and host machine, not on the device. The @Observable macro used in the ViewModel examples requires iOS 17+, but you can swap it for ObservableObject / @Published if you need iOS 16 support while keeping the same test patterns.

What coverage percentage should I aim for?

There's no universal answer, but 70–80 % line coverage on business-logic files (ViewModels, services, parsers) is a practical baseline for shipping iOS apps. Coverage of SwiftUI view body code is less valuable because conditional rendering is better validated by UI tests or Previews. Focus your coverage budget on code that makes decisions or transforms data.

UIKit equivalent?

The Xcode coverage workflow is identical for UIKit — enable the scheme flag, write XCTest cases, run ⌘U. The main difference is architecture: UIKit UIViewController subclasses mix logic and view code, making them harder to unit-test in isolation. Extracting logic into a plain Swift object (the same ViewModel pattern shown above) is the UIKit equivalent of what @Observable gives you in SwiftUI.

Last reviewed: 2026-05-11 by the Soarias team.

```