Testing Mastery

Repository Pattern for Testable ViewModels

April 10, 2026
5 min read
Featured image for blog post: Repository Pattern for Testable ViewModels

The Repository pattern separates data fetching from your ViewModel, making both easier to test and maintain.

Follow along with the code: iOS-Practice on GitHub

The Repository Protocol

protocol ArticleRepository {
    func fetchArticles() async throws -> [Article]
    func fetchArticle(id: String) async throws -> Article
}

Keep repositories focused. One repository per domain entity is a good starting point.

Production Implementation

class APIArticleRepository: ArticleRepository {
    private let httpClient: HTTPClient
    private let baseURL: URL

    init(httpClient: HTTPClient = URLSessionHTTPClient(), baseURL: URL) {
        self.httpClient = httpClient
        self.baseURL = baseURL
    }

    func fetchArticles() async throws -> [Article] {
        let url = baseURL.appendingPathComponent("articles")
        let (data, response) = try await httpClient.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw RepositoryError.invalidResponse
        }

        return try JSONDecoder().decode([Article].self, from: data)
    }

    func fetchArticle(id: String) async throws -> Article {
        let url = baseURL.appendingPathComponent("articles/\(id)")
        let (data, response) = try await httpClient.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw RepositoryError.invalidResponse
        }

        return try JSONDecoder().decode(Article.self, from: data)
    }
}

enum RepositoryError: Error {
    case invalidResponse
    case notFound
}

Mock Repository for Tests

class MockArticleRepository: ArticleRepository {
    var articlesToReturn: [Article] = []
    var articleToReturn: Article?
    var errorToThrow: Error?
    var fetchArticlesCallCount = 0
    var fetchArticleCallCount = 0
    var fetchDelay: TimeInterval = 0

    func fetchArticles() async throws -> [Article] {
        fetchArticlesCallCount += 1

        if fetchDelay > 0 {
            try await Task.sleep(nanoseconds: UInt64(fetchDelay * 1_000_000_000))
        }

        if let error = errorToThrow { throw error }
        return articlesToReturn
    }

    func fetchArticle(id: String) async throws -> Article {
        fetchArticleCallCount += 1
        if let error = errorToThrow { throw error }
        return articleToReturn!
    }

    // Test data factory
    static func makeArticle(
        id: String = "1",
        title: String = "Test Article",
        content: String = "Test content"
    ) -> Article {
        Article(
            id: id,
            title: title,
            content: content,
            author: "Test Author",
            publishedAt: Date()
        )
    }
}

Testing State Transitions

@MainActor
class ArticleListViewModelTests: XCTestCase {
    var mockRepository: MockArticleRepository!
    var sut: ArticleListViewModel!
    var cancellables: Set<AnyCancellable>!

    override func setUp() async throws {
        mockRepository = MockArticleRepository()
        sut = ArticleListViewModel(repository: mockRepository)
        cancellables = []
    }

    func test_loadArticles_setsLoadingState() async {
        mockRepository.fetchDelay = 0.1
        mockRepository.articlesToReturn = []

        var states: [LoadingState<[Article]>] = []
        sut.$state
            .sink { states.append($0) }
            .store(in: &cancellables)

        let task = Task { await sut.loadArticles() }
        try? await Task.sleep(nanoseconds: 50_000_000)

        XCTAssertTrue(states.contains(.loading))
        await task.value
    }

    func test_loadArticles_callsRepository() async {
        await sut.loadArticles()
        XCTAssertEqual(mockRepository.fetchArticlesCallCount, 1)
    }

    func test_refresh_reloadsArticles() async {
        mockRepository.articlesToReturn = [MockArticleRepository.makeArticle()]

        await sut.refresh()
        await sut.refresh()

        XCTAssertEqual(mockRepository.fetchArticlesCallCount, 2)
    }
}

Benefits

Without RepositoryWith Repository
ViewModel knows about URLSessionViewModel only knows protocol
Hard to test network errorsSet errorToThrow in mock
Can't verify API callsCheck fetchArticlesCallCount
Slow tests with real networkInstant mock responses

The repository becomes a clean boundary between your business logic and data layer.

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