import XCTest
import Combine
@testable import MyApp
class PostsViewModelTests: XCTestCase {
var viewModel: PostsViewModel!
var mockNetworkService: MockNetworkService!
var cancellables: Set<AnyCancellable>!
override func setUp() {
super.setUp()
mockNetworkService = MockNetworkService()
viewModel = PostsViewModel(networkService: mockNetworkService)
cancellables = []
}
override func tearDown() {
viewModel = nil
mockNetworkService = nil
cancellables = nil
super.tearDown()
}
func testLoadPostsSuccess() {
// Arrange
let expectedPosts = [
Post(id: 1, title: "Test Post", body: "Body", authorId: 1)
]
mockNetworkService.postsToReturn = expectedPosts
let expectation = XCTestExpectation(description: "Posts loaded")
viewModel.$posts
.dropFirst() // Skip initial empty state
.sink { posts in
XCTAssertEqual(posts.count, 1)
XCTAssertEqual(posts.first?.title, "Test Post")
expectation.fulfill()
}
.store(in: &cancellables)
// Act
viewModel.loadPosts()
// Assert
wait(for: [expectation], timeout: 1.0)
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.errorMessage)
}
func testLoadPostsFailure() {
// Arrange
mockNetworkService.shouldFail = true
let expectation = XCTestExpectation(description: "Error received")
viewModel.$errorMessage
.dropFirst()
.sink { error in
if error != nil {
expectation.fulfill()
}
}
.store(in: &cancellables)
// Act
viewModel.loadPosts()
// Assert
wait(for: [expectation], timeout: 1.0)
XCTAssertNotNil(viewModel.errorMessage)
XCTAssertTrue(viewModel.posts.isEmpty)
}
func testSearchDebounce() {
// Test that search is debounced
let expectation = XCTestExpectation(description: "Search executed once")
expectation.expectedFulfillmentCount = 1
mockNetworkService.searchCallCount = 0
// Act - rapid changes
viewModel.searchQuery = "t"
viewModel.searchQuery = "te"
viewModel.searchQuery = "tes"
viewModel.searchQuery = "test"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// Assert - only one search call after debounce
XCTAssertEqual(self.mockNetworkService.searchCallCount, 1)
expectation.fulfill()
}
wait(for: [expectation], timeout: 1.0)
}
}
import Foundation
import Combine
class MockNetworkService: NetworkServiceProtocol {
var postsToReturn: [Post] = []
var shouldFail = false
var searchCallCount = 0
func fetchPosts() -> AnyPublisher<[Post], Error> {
if shouldFail {
return Fail(error: URLError(.badServerResponse))
.eraseToAnyPublisher()
}
return Just(postsToReturn)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
func searchPosts(query: String) -> AnyPublisher<[Post], Error> {
searchCallCount += 1
if shouldFail {
return Fail(error: URLError(.badServerResponse))
.eraseToAnyPublisher()
}
let filtered = postsToReturn.filter { post in
post.title.localizedCaseInsensitiveContains(query)
}
return Just(filtered)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
Unit tests verify code behavior in isolation using XCTest framework. I create test classes inheriting from XCTestCase with test prefixed methods. Each test has arrange-act-assert structure: set up dependencies, execute code, verify results with XCTAssert methods. For testing ViewModels, I inject mock dependencies through initializers to control external behavior. Mocks implement protocols, returning predetermined values or tracking method calls. Async tests use expectations—create with expectation(description:), fulfill when async completes, wait with waitForExpectations. Test-driven development guides design toward testable, decoupled code. Running tests frequently catches regressions early.