How to Build Test Coverage in SwiftUI
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
-
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
.xccovreportfile next to the test log. -
@Observabledecouples logic from views. BecauseItemListViewModelis a plain Swift class (noUIHostingController, noXCUIApplication), XCTest can instantiate and exercise it directly. EveryloadStatetransition and every branch intoggleFavoriteis reachable from unit tests in milliseconds. -
Injected loader enables fake data. Passing the async loader as a closure
lets
test_load_setsFailedState()throw aURLErrorwithout touching the network. This covers the.failedbranch that a happy-path integration test would miss. -
The guard branch needs its own test.
test_toggleFavorite_unknownId_noChange()calls the method with a randomUUIDthat won't match any item, exercising the early-return path in theguard let idxstatement. Without it, the line shows as uncovered (red) in the Coverage report. - 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
- Coverage flag is per-scheme, not global. If you have separate Debug and Release schemes, you must enable the flag in each one. New schemes added by Swift Package Manager targets also start with coverage disabled.
-
Testing
@MainActorViewModels requires annotating the test class. IfItemListViewModelis@MainActorisolated but your test class is not, calls will hop actors and you'll see runtime warnings or data-race crashes under Swift 6 strict concurrency. Annotate theXCTestCasesubclass@MainActoras shown above. -
High coverage ≠ good tests. A test that calls every method but makes
no assertions inflates the percentage without catching regressions. Pair coverage data
with mutation testing (or at minimum, meaningful
XCTAssertcalls on every branch outcome) to trust the number.
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.