arrow_back Back to Blog

๐Ÿงช iOS Unit Testing & Mock Objects

Best practices for testing iOS applications with mock objects

schedule 9 min read calendar_today 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