DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Testing (Unit, UI & Async Tests)

Testing SwiftUI used to feel confusing — views are declarative, state is reactive, and async code is everywhere.

But now, testing SwiftUI is clean, predictable, and powerful, as long as you follow the right patterns.

This guide covers:

  • testing ViewModels
  • testing async/await
  • mocking services
  • dependency injection
  • testing navigation
  • testing SwiftUI views
  • UI tests (XCUI)
  • snapshot testing

Let’s level up your SwiftUI engineering discipline. 🚀


🧱 1. Test the ViewModel First (Always)

SwiftUI views are simple when ViewModels do the work.

Here’s a typical testable ViewModel:

@Observable
class ProfileViewModel {
    var user: User?
    var isLoading = false

    let service: UserServiceProtocol

    init(service: UserServiceProtocol) {
        self.service = service
    }

    func load() async {
        isLoading = true
        defer { isLoading = false }

        user = try? await service.fetchUser()
    }
}
Enter fullscreen mode Exit fullscreen mode

Test with a mock service:

struct MockUserService: UserServiceProtocol {
    var userToReturn: User?

    func fetchUser() async throws -> User {
        return userToReturn!
    }
}
Enter fullscreen mode Exit fullscreen mode

Test file:

func test_profile_loads_user() async {
    let mock = MockUserService(userToReturn: User(name: "Seb"))
    let vm = ProfileViewModel(service: mock)

    await vm.load()

    XCTAssertEqual(vm.user?.name, "Seb")
    XCTAssertFalse(vm.isLoading)
}
Enter fullscreen mode Exit fullscreen mode

Rule:
📌 If your ViewModel is testable, your UI becomes trivial.


⚡ 2. Testing Async Code (Best Practice)

You no longer need expectations.

Just do:

func test_async_search() async {
    let results = await search("swift")
    XCTAssertEqual(results.count, 3)
}
Enter fullscreen mode Exit fullscreen mode

Test cancellation:

func test_task_cancels() async {
    let task = Task {
        try await asyncWork()
    }

    task.cancel()

    do {
        _ = try await task.value
        XCTFail("Should cancel")
    } catch {
        XCTAssertTrue(error is CancellationError)
    }
}
Enter fullscreen mode Exit fullscreen mode

Async testing is now beautifully simple.


🔌 3. Dependency Injection = Testability

Never do this:

class APIService {
    let session = URLSession.shared  // ❌ impossible to mock
}
Enter fullscreen mode Exit fullscreen mode

Use protocols:

protocol APIClient {
    func get(_ route: String) async throws -> Data
}
Enter fullscreen mode Exit fullscreen mode

Inject:

init(api: APIClient) {
    self.api = api
}
Enter fullscreen mode Exit fullscreen mode

Mock it:

struct MockAPI: APIClient {
    var response: Data
    func get(_ route: String) async throws -> Data { response }
}
Enter fullscreen mode Exit fullscreen mode

Now you can test anything.


🧪 4. Testing SwiftUI Views (The Right Way)

SwiftUI views are functions.
You test their logic, not screenshots.

Example:

struct GreetingView: View {
    let name: String

    var body: some View {
        Text("Hello \(name)")
    }
}
Enter fullscreen mode Exit fullscreen mode

Test by inspecting the view:

Use the ViewInspector library (industry standard).

func test_greeting() throws {
    let view = GreetingView(name: "Seb")
    let text = try view.inspect().text().string()

    XCTAssertEqual(text, "Hello Seb")
}
Enter fullscreen mode Exit fullscreen mode

You can inspect:

  • text
  • buttons
  • toggles
  • navigation links
  • bindings
  • async action triggers

🔍 5. Navigation Tests

Example:

NavigationStack {
    ContentView()
}
Enter fullscreen mode Exit fullscreen mode

Test that a button triggers navigation:

try view.inspect().find(button: "Open Profile").tap()

let destination = try view.inspect().find(ProfileView.self)
XCTAssertNotNil(destination)
Enter fullscreen mode Exit fullscreen mode

Navigation is now fully testable.


🧷 6. UI Tests (XCUI) — Best Practices

Basic structure:

func test_login_flow() {
    let app = XCUIApplication()
    app.launch()

    app.textFields["email"].tap()
    app.typeText("test@example.com")

    app.secureTextFields["password"].tap()
    app.typeText("password")

    app.buttons["Login"].tap()

   XCTAssertTrue(app.staticTexts["Welcome"].waitForExistence(timeout: 2))
}
Enter fullscreen mode Exit fullscreen mode

Rules for stable UI tests:

  • give accessibility identifiers to every interactive element
  • avoid relying on index-based queries
  • use .waitForExistence
  • keep login API mocked for UI tests

📸 7. Snapshot Testing (Optional, But Powerful)

Use the SnapshotTesting library:

assertSnapshot(of: MyView(), as: .image(layout: .device(config: .iPhone17)))
Enter fullscreen mode Exit fullscreen mode

Great for:

  • card UIs
  • dashboards
  • marketing screens -animations (as GIFs)

🧰 8. Full Test Folder Structure

Tests/
│
├── ViewModels/
│   ├── ProfileViewModelTests.swift
│   ├── HomeViewModelTests.swift
│
├── Services/
│   ├── MockAPI.swift
│
├── Views/
│   ├── GreetingViewTests.swift
│
├── UITests/
│   ├── LoginFlowTests.swift
│
└── Snapshots/
    ├── CardView.snapshot.png
Enter fullscreen mode Exit fullscreen mode

Professional, scalable, clean.


🚀 Final Thoughts

Testing SwiftUI is:

  • easy
  • clean
  • async-friendly
  • mock-driven
  • architecture-boosting
  • team-ready

And it leads to:

  • fewer regressions
  • faster development
  • safer refactoring
  • more confidence
  • more maintainable apps

Top comments (0)