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 Repository | With Repository |
|---|---|
| ViewModel knows about URLSession | ViewModel only knows protocol |
| Hard to test network errors | Set errorToThrow in mock |
| Can't verify API calls | Check fetchArticlesCallCount |
| Slow tests with real network | Instant mock responses |
The repository becomes a clean boundary between your business logic and data layer.