How to implement unit tests in SwiftUI
Use Apple's Swift Testing framework (Xcode 16+) — annotate test functions with @Test and assert with #expect. It composes naturally with @Observable view models and fully supports async/await, making it the modern default over bare XCTest.
import Testing
@Test func counterIncrementsCorrectly() {
let vm = CounterViewModel()
vm.increment()
vm.increment()
#expect(vm.count == 2)
}
@Test func counterDoesNotGoBelowZero() {
let vm = CounterViewModel()
vm.decrement()
#expect(vm.count == 0)
}
Full implementation
The example below tests a small @Observable view model that drives a counter screen. The test suite uses Swift Testing's @Suite annotation for grouping, shows synchronous and async test patterns, and demonstrates how to inject a lightweight protocol-based dependency so network or persistence calls can be faked without a full mock framework.
// MARK: - Production code (CounterViewModel.swift)
import Observation
import Foundation
protocol ScoreServiceProtocol {
func fetchHighScore() async throws -> Int
}
struct LiveScoreService: ScoreServiceProtocol {
func fetchHighScore() async throws -> Int {
// Real network call in production
try await Task.sleep(for: .seconds(1))
return 42
}
}
@Observable
final class CounterViewModel {
private(set) var count: Int = 0
private(set) var highScore: Int = 0
private(set) var isLoading = false
private(set) var errorMessage: String?
private let scoreService: ScoreServiceProtocol
init(scoreService: ScoreServiceProtocol = LiveScoreService()) {
self.scoreService = scoreService
}
func increment() {
count += 1
}
func decrement() {
guard count > 0 else { return }
count -= 1
}
func reset() {
count = 0
errorMessage = nil
}
func loadHighScore() async {
isLoading = true
defer { isLoading = false }
do {
highScore = try await scoreService.fetchHighScore()
} catch {
errorMessage = error.localizedDescription
}
}
}
// MARK: - Tests (CounterViewModelTests.swift)
// Add a Unit Testing Bundle target in Xcode, then import Testing.
import Testing
@testable import YourAppModule
// Stub — fast, no network, deterministic
struct StubScoreService: ScoreServiceProtocol {
var result: Result<Int, Error> = .success(100)
func fetchHighScore() async throws -> Int {
try result.get()
}
}
@Suite("CounterViewModel")
struct CounterViewModelTests {
// MARK: Synchronous logic
@Test func startsAtZero() {
let vm = CounterViewModel()
#expect(vm.count == 0)
}
@Test func incrementAddsOne() {
let vm = CounterViewModel()
vm.increment()
#expect(vm.count == 1)
}
@Test func decrementDoesNotGoBelowZero() {
let vm = CounterViewModel()
vm.decrement()
#expect(vm.count == 0)
}
@Test func resetClearsCount() {
let vm = CounterViewModel()
vm.increment()
vm.increment()
vm.reset()
#expect(vm.count == 0)
}
// MARK: Async + dependency injection
@Test func loadHighScorePopulatesValue() async {
let stub = StubScoreService(result: .success(99))
let vm = CounterViewModel(scoreService: stub)
await vm.loadHighScore()
#expect(vm.highScore == 99)
#expect(vm.errorMessage == nil)
}
@Test func loadHighScoreSetsErrorOnFailure() async {
struct FakeError: Error {}
let stub = StubScoreService(result: .failure(FakeError()))
let vm = CounterViewModel(scoreService: stub)
await vm.loadHighScore()
#expect(vm.errorMessage != nil)
#expect(vm.highScore == 0)
}
// MARK: Parameterized test (Swift Testing exclusive)
@Test("Increment n times", arguments: [1, 5, 10])
func incrementNTimes(n: Int) {
let vm = CounterViewModel()
for _ in 0..<n { vm.increment() }
#expect(vm.count == n)
}
}
How it works
-
@Suitegroups related tests. Like XCTest's class-based grouping but lighter — no subclassing required. Each@Testfunction is independently discovered and run; the suite name shows up in Xcode's test navigator. -
#expectreplacesXCTAssert*. On failure it prints the full expression with actual values — e.g. "#expect(vm.count == 2) → 1 == 2" — without requiring a separate message string. Use#requirewhen a failing condition should abort the test immediately (equivalent toXCTUnwrap). -
Protocol injection via
ScoreServiceProtocolkeeps tests fast and deterministic.StubScoreServicereturns a pre-setResultin microseconds, so tests never hit the network and never flake. -
Async tests just work. Marking a
@Testfunctionasyncis all that's needed — Swift Testing runs it on its own concurrency context. There's noXCTestExpectationorwaitForExpectationsboilerplate. -
Parameterized tests via
arguments:replace repeated boilerplate. The@Test("label", arguments: [...])overload iterates over the array and reports each value as a separate test case in the navigator, so failures are pinpointed to the exact input.
Variants
XCTest — when you need UI tests or Xcode 15 support
import XCTest
@testable import YourAppModule
final class CounterViewModelXCTests: XCTestCase {
func testIncrementAddsOne() {
let vm = CounterViewModel()
vm.increment()
XCTAssertEqual(vm.count, 1)
}
func testLoadHighScoreAsync() async throws {
let stub = StubScoreService(result: .success(77))
let vm = CounterViewModel(scoreService: stub)
await vm.loadHighScore()
XCTAssertEqual(vm.highScore, 77)
}
func testDecrementFloor() {
let vm = CounterViewModel()
vm.decrement()
XCTAssertEqual(vm.count, 0, "Count must not go negative")
}
}
Testing @Observable property changes with Combine
For tests that need to observe a sequence of state changes — e.g. verifying isLoading toggles on then off — use withObservationTracking (iOS 17+) to capture change notifications synchronously, or wrap expectations with confirmation() in Swift Testing:
import Testing
@Test func loadingStateToggles() async throws {
let stub = StubScoreService(result: .success(5))
let vm = CounterViewModel(scoreService: stub)
// Confirm loading flips to true then back to false
await confirmation("isLoading became true", expectedCount: 1) { confirmed in
let task = Task {
// Observe the first isLoading = true transition
withObservationTracking {
_ = vm.isLoading
} onChange: {
if vm.isLoading { confirmed() }
}
await vm.loadHighScore()
}
await task.value
}
#expect(vm.isLoading == false)
}
Common pitfalls
-
Swift Testing requires Xcode 16 / iOS 17 deployment target. If your project still supports iOS 16, add an XCTest bundle alongside your Swift Testing suite — the two can coexist in the same Xcode project. Mixing
import Testingandimport XCTestin the same file compiles but can cause confusing symbol collisions; keep them in separate files. -
Testing
@Observablestate directly is usually enough — don't over-mock. Because@Observableproperties are plain stored properties, you can readvm.countdirectly after calling a method without any observation subscription. Developers coming from Combine/MVVM often add needless publishers just to make state testable; with@Observable, that's unnecessary. -
Actor isolation can surprise you. If your view model is a
@MainActor-isolated class, every@Testthat touches it must beasyncand called withawait; otherwise you'll get a compiler error about crossing actor boundaries. Annotate test functions with@MainActoror call methods viaawait MainActor.run { ... }. -
Avoid testing SwiftUI
Viewbodies directly. Views are not directly unit-testable — extract all logic into a view model or domain layer and test that. For view-level assertions (layout, accessibility), use Xcode's UI Testing target instead.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement unit tests for the CounterViewModel in SwiftUI for iOS 17+. Use Swift Testing (@Test, @Suite, #expect, #require). Inject dependencies via protocol so services are stubbable. Cover: happy path, error path, async loading, and at least one parameterized @Test with arguments. Make the view model @Observable and @MainActor-isolated. Add a #Preview with realistic sample data for the associated view.
In the Soarias Build phase, paste this prompt after scaffolding your view model — Claude Code will generate both the production observable class and its matching test suite in one pass, wiring up the protocol stub automatically.
Related
FAQ
Does Swift Testing work on iOS 16?
No. Swift Testing is available starting with Xcode 16 and requires a Swift 6 / iOS 17 deployment target for the test bundle (not the app target). If you must support iOS 16 in your app, continue writing test logic with XCTest — it runs on any deployment target and both frameworks can live in the same project.
Should I use #expect or #require for optional unwrapping?
Use #require when a nil value would make all subsequent assertions meaningless — it throws and stops the test immediately, like XCTUnwrap. Use #expect when you just want to record a failure and continue: let value = try #require(optionalResult) gives you a non-optional value for the rest of the test body.
What's the UIKit / XCTest equivalent?
In UIKit-era projects, unit tests subclass XCTestCase, use XCTAssertEqual / XCTAssertNil for assertions, XCTestExpectation + waitForExpectations(timeout:) for async work, and XCTUnwrap for optional unwrapping. All of those patterns still compile and run fine in Xcode 16 — Swift Testing is an addition, not a replacement, so you can migrate tests incrementally.
Last reviewed: 2026-05-11 by the Soarias team.