๐งช iOS Unit Testing & Mock Objects
Best practices for testing iOS applications with mock objects
9 min read
December 22, 2023
Why Unit Testing Matters
Unit testing is essential for maintaining code quality and preventing regressions. Mock objects allow us to isolate the code under test and verify behavior without external dependencies.
1. Basic Mock Objects
// Protocol for dependency injection
protocol UserServiceProtocol {
func fetchUser(id: String) async throws -> User
func createUser(_ user: CreateUserRequest) async throws -> User
}
// Mock implementation
class MockUserService: UserServiceProtocol {
var fetchUserResult: Result?
var createUserResult: Result?
var fetchUserCallCount = 0
var createUserCallCount = 0
func fetchUser(id: String) async throws -> User {
fetchUserCallCount += 1
guard let result = fetchUserResult else {
throw NSError(domain: "MockError", code: 0, userInfo: nil)
}
switch result {
case .success(let user):
return user
case .failure(let error):
throw error
}
}
func createUser(_ user: CreateUserRequest) async throws -> User {
createUserCallCount += 1
guard let result = createUserResult else {
throw NSError(domain: "MockError", code: 0, userInfo: nil)
}
switch result {
case .success(let user):
return user
case .failure(let error):
throw error
}
}
}
2. Testing with Mocks
class UserViewModelTests: XCTestCase {
var viewModel: UserViewModel!
var mockUserService: MockUserService!
override func setUp() {
super.setUp()
mockUserService = MockUserService()
viewModel = UserViewModel(userService: mockUserService)
}
override func tearDown() {
viewModel = nil
mockUserService = nil
super.tearDown()
}
func testFetchUserSuccess() async {
// Given
let expectedUser = User(id: "123", name: "John Doe", email: "john@example.com")
mockUserService.fetchUserResult = .success(expectedUser)
// When
await viewModel.loadUser(id: "123")
// Then
XCTAssertEqual(viewModel.user?.id, "123")
XCTAssertEqual(viewModel.user?.name, "John Doe")
XCTAssertEqual(mockUserService.fetchUserCallCount, 1)
}
func testFetchUserFailure() async {
// Given
let expectedError = NetworkError.serverError
mockUserService.fetchUserResult = .failure(expectedError)
// When
await viewModel.loadUser(id: "123")
// Then
XCTAssertNil(viewModel.user)
XCTAssertEqual(viewModel.errorMessage, "Server error occurred")
XCTAssertEqual(mockUserService.fetchUserCallCount, 1)
}
}
3. Advanced Mock Patterns
Spy Objects
class SpyUserService: UserServiceProtocol {
private let realService: UserServiceProtocol
private(set) var fetchUserCalls: [(id: String)] = []
private(set) var createUserCalls: [CreateUserRequest] = []
init(realService: UserServiceProtocol) {
self.realService = realService
}
func fetchUser(id: String) async throws -> User {
fetchUserCalls.append((id: id))
return try await realService.fetchUser(id: id)
}
func createUser(_ user: CreateUserRequest) async throws -> User {
createUserCalls.append(user)
return try await realService.createUser(user)
}
}
// Usage in tests
func testUserServiceIntegration() async {
let realService = UserService()
let spyService = SpyUserService(realService: realService)
let viewModel = UserViewModel(userService: spyService)
await viewModel.loadUser(id: "123")
XCTAssertEqual(spyService.fetchUserCalls.count, 1)
XCTAssertEqual(spyService.fetchUserCalls.first?.id, "123")
}
Fake Objects
class FakeUserService: UserServiceProtocol {
private var users: [String: User] = [:]
private var nextId = 1
func fetchUser(id: String) async throws -> User {
guard let user = users[id] else {
throw UserError.userNotFound
}
return user
}
func createUser(_ user: CreateUserRequest) async throws -> User {
let newUser = User(
id: String(nextId),
name: user.name,
email: user.email
)
users[newUser.id] = newUser
nextId += 1
return newUser
}
// Helper methods for test setup
func addUser(_ user: User) {
users[user.id] = user
}
func clearUsers() {
users.removeAll()
}
}
4. Dependency Injection Patterns
Constructor Injection
class UserViewModel: ObservableObject {
@Published var user: User?
@Published var isLoading = false
@Published var errorMessage: String?
private let userService: UserServiceProtocol
init(userService: UserServiceProtocol) {
self.userService = userService
}
func loadUser(id: String) async {
isLoading = true
errorMessage = nil
do {
user = try await userService.fetchUser(id: id)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
Property Injection
class UserViewController: UIViewController {
var userService: UserServiceProtocol = UserService()
override func viewDidLoad() {
super.viewDidLoad()
loadUser()
}
private func loadUser() {
Task {
do {
let user = try await userService.fetchUser(id: "123")
await MainActor.run {
self.updateUI(with: user)
}
} catch {
await MainActor.run {
self.showError(error)
}
}
}
}
}
// In tests
func testViewController() {
let mockService = MockUserService()
let viewController = UserViewController()
viewController.userService = mockService
// Test the view controller
}
5. Testing Async Code
class AsyncUserServiceTests: XCTestCase {
func testFetchUserAsync() async throws {
// Given
let mockService = MockUserService()
let expectedUser = User(id: "123", name: "John", email: "john@example.com")
mockService.fetchUserResult = .success(expectedUser)
// When
let user = try await mockService.fetchUser(id: "123")
// Then
XCTAssertEqual(user.id, "123")
XCTAssertEqual(user.name, "John")
}
func testConcurrentFetchUsers() async throws {
// Given
let mockService = MockUserService()
let users = [
User(id: "1", name: "User 1", email: "user1@example.com"),
User(id: "2", name: "User 2", email: "user2@example.com"),
User(id: "3", name: "User 3", email: "user3@example.com")
]
mockService.fetchUserResult = .success(users[0])
// When
let results = await withTaskGroup(of: User?.self) { group in
var fetchedUsers: [User?] = []
for i in 0..<3 {
group.addTask {
try? await mockService.fetchUser(id: "\(i + 1)")
}
}
for await user in group {
fetchedUsers.append(user)
}
return fetchedUsers
}
// Then
XCTAssertEqual(results.count, 3)
XCTAssertEqual(mockService.fetchUserCallCount, 3)
}
}
6. Testing UI Components
class UserViewTests: XCTestCase {
func testUserViewLoadingState() {
// Given
let mockService = MockUserService()
let viewModel = UserViewModel(userService: mockService)
// When
viewModel.isLoading = true
// Then
let view = UserView(viewModel: viewModel)
let loadingIndicator = view.findSubview(ofType: UIActivityIndicatorView.self)
XCTAssertTrue(loadingIndicator?.isAnimating ?? false)
}
func testUserViewErrorState() {
// Given
let mockService = MockUserService()
let viewModel = UserViewModel(userService: mockService)
// When
viewModel.errorMessage = "Network error"
// Then
let view = UserView(viewModel: viewModel)
let errorLabel = view.findSubview(ofType: UILabel.self) { $0.text?.contains("Network error") == true }
XCTAssertNotNil(errorLabel)
}
}
// Helper extension for finding subviews
extension UIView {
func findSubview(ofType type: T.Type, where condition: (T) -> Bool = { _ in true }) -> T? {
if let view = self as? T, condition(view) {
return view
}
for subview in subviews {
if let found = subview.findSubview(ofType: type, where: condition) {
return found
}
}
return nil
}
}
7. Test Organization
Test Structure
class UserServiceTests: XCTestCase {
// MARK: - Properties
var sut: UserService! // System Under Test
var mockNetworkService: MockNetworkService!
// MARK: - Setup & Teardown
override func setUp() {
super.setUp()
mockNetworkService = MockNetworkService()
sut = UserService(networkService: mockNetworkService)
}
override func tearDown() {
sut = nil
mockNetworkService = nil
super.tearDown()
}
// MARK: - Success Cases
func testFetchUser_WithValidId_ReturnsUser() async throws {
// Given
let expectedUser = User(id: "123", name: "John", email: "john@example.com")
mockNetworkService.mockResponse = .success(expectedUser)
// When
let user = try await sut.fetchUser(id: "123")
// Then
XCTAssertEqual(user.id, "123")
XCTAssertEqual(user.name, "John")
}
// MARK: - Failure Cases
func testFetchUser_WithInvalidId_ThrowsError() async {
// Given
mockNetworkService.mockResponse = .failure(NetworkError.userNotFound)
// When & Then
do {
_ = try await sut.fetchUser(id: "invalid")
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(error is NetworkError)
}
}
// MARK: - Edge Cases
func testFetchUser_WithEmptyId_ThrowsError() async {
// Given
let emptyId = ""
// When & Then
do {
_ = try await sut.fetchUser(id: emptyId)
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(error is UserServiceError.invalidId)
}
}
}
8. Mock Libraries
Using Cuckoo
// Generate mocks with Cuckoo
// @Mock
protocol UserServiceProtocol {
func fetchUser(id: String) async throws -> User
}
// In tests
func testWithCuckoo() async {
let mockUserService = MockUserServiceProtocol()
let expectedUser = User(id: "123", name: "John", email: "john@example.com")
stub(mockUserService) { mock in
when(mock.fetchUser(id: any()))
.thenReturn(expectedUser)
}
let viewModel = UserViewModel(userService: mockUserService)
await viewModel.loadUser(id: "123")
verify(mockUserService).fetchUser(id: "123")
}
โ Best Practices
- Use protocols for dependency injection
- Keep mocks simple and focused
- Test one thing at a time
- Use descriptive test names
- Follow the Given-When-Then pattern
- Clean up after each test
โ Common Mistakes
- Testing implementation details instead of behavior
- Over-mocking (mocking everything)
- Not cleaning up test state
- Writing tests that are too complex
- Not testing error conditions