Est.

Testable SwiftUI ViewModels With Swift Testing Framework

Move ViewModel logic out of SwiftUI views to test it independently.

Staff Writer · · 11 min read
Cover illustration for “Testable SwiftUI ViewModels With Swift Testing Framework”
Swift Architecture · September 15, 2026 · 11 min read · 2,524 words

SwiftUI views can't be unit tested directly because they aren't objects you can inspect. A view struct is a description that the framework renders and discards on every update, so there's no persistent instance to reach into and check state on. That single fact drives almost every architectural decision covered here, from where logic lives to which testing framework fits the result.

Consider a SendMessageView that manages a sending flag, an error message, and a disabled state for its submit button, all as @State properties read directly inside body. John Sundell's write-up on Swift by Sundell uses roughly this case to make the point concrete: testing that button's disabled logic would mean instantiating the view, finding the button in the rendered hierarchy, and triggering a tap, none of which SwiftUI's testing tools support in any practical way. The workaround is a shortcut that avoids the real problem. It's not putting the logic there in the first place.

None of this comes from bad instinct. SwiftUI's syntax rewards putting logic inline: a computed property here, an async call in a .task modifier there, and the view compiles, runs, and looks correct in the preview. The cost shows up later, when a teammate changes the disabled condition and the only way to verify it still works is tapping through the app by hand. That's slow, it's error-prone, and it produces nothing a CI pipeline can check. Async work sitting in body, error state tracked with @State, disabled conditions computed inline: each is a small sign that logic has crossed from the view into territory that needs its own home.

What belongs in a ViewModel and what belongs in the View

Apple's own documentation never uses the term MVVM, and its sample code often injects model objects directly into views. That's arguably a rudimentary view-model already, just without the name. The pattern gets an explicit name and a separate type as soon as a screen needs async work, error handling, or any logic worth checking outside a simulator.

Sundell frames the goal precisely: the point is that SwiftUI apps should adopt whatever pattern fits, without treating MVVM as a mandatory rule. It's that the easiest way to unit test UI-related code is to move that code out of the view, so it no longer depends on any particular UI framework to run. Once logic lives in a plain Swift object, it can be tested the same way any other Swift code gets tested, with no rendering involved.

That gives each layer a distinct job. The View displays whatever the ViewModel hands it, forwards user actions back, and otherwise stays as thin as possible. The ViewModel holds state, owns async calls, and encodes the business rules, all without knowing SwiftUI exists. The Model is just data, plain structs that were never hard to test to begin with. A ViewModel built this way can be instantiated, exercised, and checked without a single pixel drawn.

Some practitioners push back on the class-based, @Observable ViewModel, arguing it sits awkwardly next to SwiftUI's own value-type paradigm. That's a legitimate style debate, not a right-versus-wrong split; protocol-oriented model conformance and class-based ViewModels both have working codebases behind them. But when async work enters the picture, the class-based ViewModel wins on testability, because it gives the test a concrete object to inject mocks into and inspect afterward.

How @Observable changes the structure of a testable ViewModel

Before iOS 17, testable ViewModels were built on ObservableObject with @Published properties, a pattern that depends on Combine and updates views at the level of the whole object rather than the specific property that changed. That's not free. It adds boilerplate, and some of that boilerplate leaks into tests that end up verifying Combine publishing behavior instead of the ViewModel's actual logic.

The @Observable macro, available from iOS 17 and macOS 14 onward, cuts that layer out. Views re-render only when they read a property that actually changed, and the ViewModel's stored properties become plain Swift values a test can read directly, with nothing routed through a publisher first. That's a real simplification, not just a syntax swap: tests no longer need to care whether a change notification fired correctly, only whether the value is correct.

One constraint matters here: @Observable only works on classes. Structs are value types, and SwiftUI already tracks their mutations through @State and @Binding without needing the Observation framework at all. There's also a trap: a computed property in body that reads five stored properties registers all five for observation, which quietly cancels out the performance gain the macro is supposed to provide. Keeping body reads narrow, and marking internal state that shouldn't drive UI updates with @ObservationIgnored, preserves the benefit.

Teams still targeting versions before iOS 17 aren't stuck. ObservableObject remains valid, and everything about structuring a testable ViewModel applies equally to it, the @Observable path is just cleaner going forward. In practice, a modern ViewModel tends to look like an @Observable class with clearly named stored properties, a ViewState<T> enum (idle, loading, success(T), failure(Error)) that rules out impossible states by construction, and async methods that mutate those properties as work completes.

Dependency injection as the prerequisite for mocking in tests

None of the structure above matters if the ViewModel still reaches out and calls a shared singleton like APIManager.shared directly. A singleton can't be swapped out, so any test that touches that method hits the real network, whatever that happens to return, whenever it happens to respond.

The fix is a protocol for each external dependency, something like UserServiceProtocol, with a LiveUserService for production and a MockUserService for tests, both injected through the ViewModel's initializer. The ViewModel then holds a reference to the protocol type, never the concrete implementation, and that reference is the seam a test needs to substitute a mock. Inheritance can look like a shortcut here, subclassing a service and overriding a method, but it tends to create side effects once the base class's internal logic gets complex. Protocol conformance is more explicit about exactly what's being replaced.

With a mock in place, the ViewModel gets isolated from the network, the database, or whatever else it would normally depend on, and a test can check the ViewModel's logic and nothing else. The shape ends up consistent: the ViewModel receives a protocol-typed service in init, its async methods call await service.fetch(), state moves through the ViewState<T> enum once that await returns, and the mock either hands back canned data or throws a controlled error on command.

A Repository pattern extends this cleanly for production use. Instead of the ViewModel calling a service directly, it talks to a Repository that abstracts over the data source, whether that's a network call, a database, or a cache, and the Repository conforms to a protocol just like the service did, so it's equally easy to mock. Once this is wired up, a test instantiates the ViewModel with a mock, calls a method, and reads a property back. No SwiftUI. No simulator. No network request leaving the machine.

What Swift Testing changes and why it fits this architecture

Swift Testing arrived at WWDC 2024, shipping with Xcode 16 and Swift 6, and Apple's sample projects since then default to it for Swift code. XCTest hasn't been deprecated, but it's no longer the starting point for new unit tests, and the two frameworks are meant to coexist rather than compete for the same job.

Five differences from XCTest matter directly for testing ViewModels. Tests are plain functions marked @Test, not methods on an XCTestCase subclass, so there's no inheritance chain and no requirement that the function name start with "test." A fresh suite instance gets created for every single test, which means state never leaks between tests by default, exactly the guarantee a dependency-injected ViewModel needs to stay predictable. Tests run in parallel by default, using Swift Concurrency underneath, so race conditions hiding in shared state tend to surface immediately instead of staying quiet behind serial execution. Assertions collapse down to two macros: #expect, which records a failure and lets the test keep running, and #require, which stops the test immediately and is the tool for unwrapping optionals safely. And @Test supports a human-readable name, like @Test("Cart total updates when item is added"), replacing what used to be a cryptic function name in the test output.

XCTest's specific friction points motivated a lot of this. XCTAssertEqual(a, b, "message") reports the runtime values on both sides of a failed comparison but throws away the original Swift expression, so debugging often means reconstructing what the test was actually checking from the surrounding code. Configuration through subclassing means a new setup/teardown variation needs a new subclass. Swift Testing's macros expand at compile time and can capture the source expression itself, so a failure message shows what was actually compared, not just the resulting values.

Not everything moved over. UI automation through XCUIApplication and performance testing through XCTMetric stay in XCTest for now, and both frameworks run fine in the same test target. They don't talk to each other. XCTFail called inside a Swift Testing test doesn't register, and #expect called inside an XCTestCase doesn't trigger a failure either, so mixing the two APIs inside the wrong host produces a test that silently reports success when it shouldn't. Apple has continued extending Swift Testing with additional capabilities since its initial release.

The fit with everything above is direct. An @Observable ViewModel built with dependency injection is already a plain Swift object with no ties to SwiftUI's rendering. Swift Testing's function-based, concurrency-native design asks nothing more of that object than to be instantiated and called.

Structuring a Swift Testing suite for a ViewModel

@Suite groups related tests together, and it can wrap a struct, a class, or an actor. Guidance from resources like drizz.dev leans toward struct for its value semantics, though a class or actor is needed if the suite requires a deinit for cleanup.

Setup now happens in init rather than in a setUp() override. A suite testing a CheckoutViewModel, for instance, would instantiate that ViewModel with a MockAPI() right in the suite's initializer, similar to the pattern described on drizz.dev, where a test like @Test func cartTotalUpdates() then checks #expect(viewModel.total == 100) against a freshly built instance. Because Swift Testing creates a new suite instance per test, every test starts from that same clean state without any explicit teardown step.

Async methods need nothing beyond standard Swift Concurrency. A test function marked async throws can call await viewModel.someAsyncMethod() directly, then read whatever property changed as a result. Testing an error path works the same way: the mock throws a controlled error, and the test checks that the ViewModel landed in .failure(Error), using #require to unwrap the associated error value if the test needs to inspect it further.

A handful of traits are worth building into a ViewModel suite from the start. .tags() groups tests by feature area, useful for filtering what runs in CI. .disabled("reason") skips a test with an explicit, visible reason, instead of XCTest's habit of renaming a method to make it stop running. .serialized forces sequential execution and should be reserved for suites that genuinely can't isolate their state, since parallel execution by default is the better starting point. .timeLimit() guards against an async test hanging indefinitely on a mock that never resolves.

That default parallelism is probably the most disruptive part of migrating existing suites. Any suite that mutates a shared singleton or touches global state will start producing flaky results once tests run concurrently. The fix isn't sprinkling .serialized across the suite until the flakiness goes away. It's the dependency injection covered earlier, giving every test its own isolated instances instead of a shared one.

Parameterized tests for ViewModel logic with multiple inputs

XCTest never had built-in parameterization. Teams worked around it with loops inside a single test method or by generating test code, and neither approach produced individually reportable, individually re-runnable results; a failure inside a loop failed the whole test, making it harder to isolate the specific input that caused it.

Swift Testing makes this a core feature. @Test(arguments: [...]) takes a collection and runs the test body once per element, and each run shows up as its own separate result in Xcode's Test Navigator. For a ViewModel, that's a natural fit for checking state transitions across a range of inputs: different cart quantities, different error codes coming back from a mock, different combinations of form field validity. Each argument gets its own pass or fail, and a failure message names the specific input that caused it, so re-running just that one case is a single click in Xcode rather than a rebuild-and-guess cycle.

This pays off most at the edges: minimum and maximum values, empty input, malformed data, the cases that are always correct in theory and always the ones someone forgets to write a dedicated test for. One practical caution: keep the argument list readable. A parameterized test with a long, dense list of inputs is only useful if a failure clearly identifies which one broke, otherwise it's just a loop wearing a nicer syntax. Paired with the ViewState<T> pattern, one parameterized test can cover a full matrix, some inputs expected to resolve to .success, others expected to land on .failure, all checked in a single, readable test definition.

Testing async ViewModel methods without test-specific workarounds

XCTest's original story for async code involved XCTestExpectation and waitForExpectations(timeout:), or hand-built wrapper utilities, before Swift 5.5 added native async support to test methods. Each of those added ceremony that sat between the test and what it was actually trying to check, and that ceremony had nothing to do with the ViewModel's logic.

Swift Testing functions are just native async functions. await viewModel.loadData() reads exactly like the equivalent line would read in production code, and the assertion that follows checks whatever state property that call was supposed to update. As one write-up on blog.jacobstechtavern.com puts it, the foundation is the same dependency injection discussed earlier: separate concerns, mock the injected services, and write async tests that confirm the right service methods got called and that the ViewModel ended up in the right success or failure state. The same approach extends to async let and to TaskGroup and ThrowingTaskGroup when a ViewModel coordinates multiple concurrent operations.

Testing the intermediate .loading state takes a bit more thought. Because a test that awaits viewModel.loadData() only resumes once that call finishes, it never observes the .loading state that existed in between, it sees whatever state the method settled into at the end. Catching that transition requires either exposing a hook the test can check before the await completes, or deciding, deliberately, that the loading state isn't worth testing directly. Either is defensible. Leaving it untested by accident, without ever making that call, is the outcome worth avoiding.

Cancellation is worth testing the same deliberate way. Inject a mock that delays its response, cancel the Task running the ViewModel's method from within the test, and check that the ViewModel lands in a predictable, defined state rather than an inconsistent one. Swift Concurrency's cooperative cancellation model gives the ViewModel every opportunity to check Task.isCancelled and respond cleanly, and a test built this way confirms it actually does.

Sources

  1. Swift Testing Framework: A Guide for IOS Teams
  2. Writing testable code when using SwiftUI | Swift by Sundell
  3. avanderlee.com
  4. blog.jacobstechtavern.com
  5. github.com
  6. swiftwithmajid.com

More in Swift Architecture