Testing Mastery

Testing ViewModels with @Published

April 7, 2026
6 min read
Featured image for blog post: 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

  1. Can't control responses: You need real network calls or complex mocking
  2. Can't test loading states: The async operation completes too fast to observe
  3. Can't verify behavior: No way to check if the right URL was called
  4. 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch