Testing ViewModels with @Published

Your ViewModel fetches data directly from URLSession. You can't test loading states, error handling, or verify the right endpoints are called.
Follow along with the code: iOS-Practice on GitHub
The Problem
class ArticleListViewModelBefore: ObservableObject {
@Published var articles: [Article] = []
@Published var isLoading = false
@Published var errorMessage: String?
func loadArticles() async {
isLoading = true
errorMessage = nil
// Direct URLSession call - untestable
guard let url = URL(string: "https://api.example.com/articles") else { return }
do {
let (data, _) = try await URLSession.shared.data(from: url)
articles = try JSONDecoder().decode([Article].self, from: data)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
Want to try it yourself? In ViewModelExerciseView.swift, delete everything from line 83 onwards (the // MARK: - AFTER section) and try to refactor ArticleListViewModelBefore to be testable. Your goal: make all tests in ViewModelExerciseTests.swift pass.
Why This Is Hard to Test
- Can't control responses: You need real network calls or complex mocking
- Can't test loading states: The async operation completes too fast to observe
- Can't verify behavior: No way to check if the right URL was called
- Flaky tests: Network conditions affect results
The Fix: Use LoadingState Enum
First, model your states explicitly:
enum LoadingState<T: Equatable>: Equatable {
case idle
case loading
case loaded(T)
case error(String)
}
Inject a Repository
protocol ArticleRepository {
func fetchArticles() async throws -> [Article]
func fetchArticle(id: String) async throws -> Article
}
@MainActor
class ArticleListViewModel: ObservableObject {
@Published private(set) var state: LoadingState<[Article]> = .idle
@Published private(set) var selectedArticle: Article?
private let repository: ArticleRepository
var articles: [Article] {
if case .loaded(let articles) = state { return articles }
return []
}
var isLoading: Bool {
if case .loading = state { return true }
return false
}
var errorMessage: String? {
if case .error(let message) = state { return message }
return nil
}
init(repository: ArticleRepository) {
self.repository = repository
}
func loadArticles() async {
state = .loading
do {
let articles = try await repository.fetchArticles()
state = .loaded(articles)
} catch {
state = .error(error.localizedDescription)
}
}
}
Testing with Mock Repository
class MockArticleRepository: ArticleRepository {
var articlesToReturn: [Article] = []
var errorToThrow: Error?
var fetchArticlesCallCount = 0
func fetchArticles() async throws -> [Article] {
fetchArticlesCallCount += 1
if let error = errorToThrow { throw error }
return articlesToReturn
}
func fetchArticle(id: String) async throws -> Article {
fatalError("Not needed for this test")
}
}
@MainActor
class ArticleListViewModelTests: XCTestCase {
var mockRepository: MockArticleRepository!
var sut: ArticleListViewModel!
override func setUp() async throws {
mockRepository = MockArticleRepository()
sut = ArticleListViewModel(repository: mockRepository)
}
func test_initialState_isIdle() {
XCTAssertEqual(sut.state, .idle)
XCTAssertTrue(sut.articles.isEmpty)
}
func test_loadArticles_success_setsLoadedState() async {
let expectedArticles = [
Article(id: "1", title: "Test", content: "Content", author: "Author", publishedAt: Date())
]
mockRepository.articlesToReturn = expectedArticles
await sut.loadArticles()
XCTAssertEqual(sut.state, .loaded(expectedArticles))
XCTAssertEqual(sut.articles.count, 1)
}
func test_loadArticles_failure_setsErrorState() async {
mockRepository.errorToThrow = NSError(domain: "Test", code: 500, userInfo: [NSLocalizedDescriptionKey: "Server error"])
await sut.loadArticles()
XCTAssertEqual(sut.errorMessage, "Server error")
}
}
The LoadingState enum makes state transitions explicit and testable.