```html SwiftUI: How to Implement Unit Tests (iOS 17+, 2026)

How to implement unit tests in SwiftUI

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

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

  1. @Suite groups related tests. Like XCTest's class-based grouping but lighter — no subclassing required. Each @Test function is independently discovered and run; the suite name shows up in Xcode's test navigator.
  2. #expect replaces XCTAssert*. 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 #require when a failing condition should abort the test immediately (equivalent to XCTUnwrap).
  3. Protocol injection via ScoreServiceProtocol keeps tests fast and deterministic. StubScoreService returns a pre-set Result in microseconds, so tests never hit the network and never flake.
  4. Async tests just work. Marking a @Test function async is all that's needed — Swift Testing runs it on its own concurrency context. There's no XCTestExpectation or waitForExpectations boilerplate.
  5. 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

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.

```