Unit Testing ViewModels in Swift
In an MVVM SwiftUI app, the ViewModel is where business logic, state transformations, and side-effect orchestration live — which makes it the highest-value target for unit tests. Because a well-designed ViewModel doesn't import SwiftUI or reference any View directly, it can be instantiated and exercised entirely in isolation: you create it, call its methods, and assert on its published state, with no view hierarchy, no simulator rendering, and no UI involved. This is what makes ViewModel tests fast and reliable compared to UI tests, which are inherently slower and more brittle.
Cricket analogy: A team's strategy analyst (ViewModel) works entirely from stats sheets, never setting foot on the pitch, so their models can be validated in a meeting room instantly, unlike judging a real match which requires the full stadium, players, and weather.
Dependency Injection for Testability
A ViewModel that directly calls URLSession.shared or a concrete database type is hard to test deterministically, because tests would depend on real network or disk state. The standard fix is to define a protocol for the dependency (e.g., a UserServicing protocol with a fetchUser(id:) async throws -> User method), have the ViewModel depend on that protocol rather than a concrete type, and inject a real implementation in production while injecting a fake or mock implementation in tests. This single change — depending on an abstraction, not a concretion — is usually the difference between a ViewModel that's testable and one that isn't.
Cricket analogy: A coach who insists on scouting only via live stadium visits (concrete URLSession) can't prep for an opponent between series; defining a ScoutingService protocol lets them use real match footage in season and a fake highlight reel for practice drills.
protocol UserServicing {
func fetchUser(id: Int) async throws -> User
}
@Observable
final class ProfileViewModel {
private(set) var user: User?
private(set) var errorMessage: String?
private let service: UserServicing
init(service: UserServicing) {
self.service = service
}
func load(userId: Int) async {
do {
user = try await service.fetchUser(id: userId)
errorMessage = nil
} catch {
errorMessage = "Failed to load user."
}
}
}Writing the Test
With the protocol in place, a test creates a fake UserServicing implementation that returns canned data (or throws a canned error) and injects it into the ViewModel under test. Because load(userId:) is async, the test function itself must be async and use await to call it — XCTest's async test methods and the newer Swift Testing framework's @Test functions both support this directly, without needing expectations or completion-handler gymnastics that older asynchronous tests required.
Cricket analogy: A fake scouting report generator (test double) hands back a canned opponent-averages-35 figure instead of a live feed; because reviewing the report is itself a waiting process (async), the analyst's test session must also allow for that wait, no need for old-style walkie-talkie relay confirmations.
import Testing
@testable import MyApp
struct FakeUserService: UserServicing {
var result: Result<User, Error>
func fetchUser(id: Int) async throws -> User {
try result.get()
}
}
@Test func loadingUserPopulatesState() async {
let expected = User(id: 1, name: "Ada")
let viewModel = ProfileViewModel(service: FakeUserService(result: .success(expected)))
await viewModel.load(userId: 1)
#expect(viewModel.user == expected)
#expect(viewModel.errorMessage == nil)
}
@Test func loadingFailureSetsErrorMessage() async {
let viewModel = ProfileViewModel(service: FakeUserService(result: .failure(URLError(.badServerResponse))))
await viewModel.load(userId: 1)
#expect(viewModel.user == nil)
#expect(viewModel.errorMessage != nil)
}What to Test — and What Not To
Good ViewModel tests focus on observable state transitions: given this input and this mocked dependency behavior, does the published state end up correct? They should not assert on SwiftUI view structure or rendering — that's the domain of UI/snapshot tests. Favor testing behavior ("loading fails gracefully and sets an error message") over implementation details ("the private retry counter is incremented"), since behavior-focused tests survive refactors that internal-detail tests would break on.
Cricket analogy: Good analyst tests check that a rain delay correctly updates the match status state to suspended, not that a specific internal counter of umpire consultations incremented, behavior over implementation, since the counter's mechanics may change.
Swift Testing's @Test functions run in parallel by default and use macros like #expect and #require instead of XCTest's XCTAssert family, but the two frameworks can coexist in the same target during a gradual migration.
A ViewModel that reaches directly into a concrete networking or persistence type (rather than a protocol) cannot be unit tested without hitting real infrastructure — tests will be slow, flaky, and dependent on network/database state. Introduce a protocol seam before writing tests, not after.
- ViewModels are the highest-leverage unit test target in MVVM because they hold logic without depending on SwiftUI views.
- Depending on a protocol instead of a concrete service/network type is the key enabler of testability.
- Fake or mock implementations of that protocol let tests control return values and error conditions deterministically.
- Async ViewModel methods require async test functions, supported natively by both XCTest and Swift Testing.
- Tests should assert on observable state outcomes, not private implementation details.
- Swift Testing uses
@Testand#expect/#require; it can coexist with XCTest in the same target.
Practice what you learned
1. What design choice most enables unit testing a ViewModel's networking logic?
2. Why must a test function calling an async ViewModel method itself be marked async?
3. What should a well-written ViewModel unit test primarily assert on?
4. In Swift Testing, what macro replaces XCTest's XCTAssertEqual for making assertions?
5. Why is it problematic for a ViewModel to call URLSession.shared directly rather than a protocol-typed dependency?
Was this page helpful?
You May Also Like
ViewModel Basics in SwiftUI
A hands-on look at how to actually write, own, and wire up a ViewModel class in a SwiftUI app, using @Observable and @MainActor correctly.
Networking with URLSession
Understand how URLSession performs HTTP requests in Swift, including async/await APIs, request configuration, and handling responses safely.
Dependency Injection in SwiftUI
Learn how to supply view models and services to SwiftUI views through initializers and the environment, keeping views testable and decoupled from concrete implementations.
SwiftUI Previews and UI Testing
Understand how Xcode Previews accelerate SwiftUI development and how XCUITest complements ViewModel unit tests by verifying real UI behavior.