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)
        }
        
        return try result.get()
    }
    
    func createUser(_ user: CreateUserRequest) async throws -> User {
        createUserCallCount += 1
        
        guard let result = createUserResult else {
            throw NSError(domain: "MockError", code: 0, userInfo: nil)
        }
        
        return try result.get()
    }
}





2. Testing ViewModels

// ViewModel under test
@MainActor
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
    }
}

// Test cases
class UserViewModelTests: XCTestCase {
    var viewModel: UserViewModel!
    var mockUserService: MockUserService!
    
    override func setUp() {
        super.setUp()
        mockUserService = MockUserService()
        viewModel = UserViewModel(userService: mockUserService)
    }
    
    @MainActor
    func testLoadUserSuccess() async {
        // Given
        let expectedUser = User(id: "1", name: "John Doe", email: "john@example.com")
        mockUserService.fetchUserResult = .success(expectedUser)
        
        // When
        await viewModel.loadUser(id: "1")
        
        // Then
        XCTAssertEqual(viewModel.user?.name, "John Doe")
        XCTAssertEqual(viewModel.user?.email, "john@example.com")
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertNil(viewModel.errorMessage)
        XCTAssertEqual(mockUserService.fetchUserCallCount, 1)
    }
    
    @MainActor
    func testLoadUserFailure() async {
        // Given
        let expectedError = NSError(domain: "TestError", code: 404, userInfo: [NSLocalizedDescriptionKey: "User not found"])
        mockUserService.fetchUserResult = .failure(expectedError)
        
        // When
        await viewModel.loadUser(id: "1")
        
        // Then
        XCTAssertNil(viewModel.user)
        XCTAssertFalse(viewModel.isLoading)
        XCTAssertEqual(viewModel.errorMessage, "User not found")
        XCTAssertEqual(mockUserService.fetchUserCallCount, 1)
    }
}





3. Advanced Mock Techniques

// Spy pattern for behavior verification
class SpyUserService: UserServiceProtocol {
    var capturedFetchUserIDs: [String] = []
    var capturedCreateUserRequests: [CreateUserRequest] = []
    
    func fetchUser(id: String) async throws -> User {
        capturedFetchUserIDs.append(id)
        return User(id: id, name: "Test User", email: "test@example.com")
    }
    
    func createUser(_ user: CreateUserRequest) async throws -> User {
        capturedCreateUserRequests.append(user)
        return User(id: "123", name: user.name, email: user.email)
    }
}

// Stub pattern for returning predefined data
class StubUserService: UserServiceProtocol {
    let stubbedUser = User(id: "1", name: "Stubbed User", email: "stub@example.com")
    
    func fetchUser(id: String) async throws -> User {
        return stubbedUser
    }
    
    func createUser(_ user: CreateUserRequest) async throws -> User {
        return stubbedUser
    }
}





4. Testing Async Operations

class AsyncTestingExamples: XCTestCase {
    func testAsyncOperationWithExpectation() {
        let expectation = expectation(description: "Data loaded")
        let mockService = MockUserService()
        mockService.fetchUserResult = .success(User(id: "1", name: "Test", email: "test@example.com"))
        
        Task {
            do {
                let user = try await mockService.fetchUser(id: "1")
                XCTAssertEqual(user.name, "Test")
                expectation.fulfill()
            } catch {
                XCTFail("Unexpected error: \(error)")
            }
        }
        
        wait(for: [expectation], timeout: 2.0)
    }
    
    func testAsyncOperationWithAsyncAwait() async throws {
        let mockService = MockUserService()
        mockService.fetchUserResult = .success(User(id: "1", name: "Test", email: "test@example.com"))
        
        let user = try await mockService.fetchUser(id: "1")
        XCTAssertEqual(user.name, "Test")
    }
}





5. Test-Driven Development (TDD)

// TDD Example: Red-Green-Refactor cycle
class CalculatorTests: XCTestCase {
    var calculator: Calculator!
    
    override func setUp() {
        super.setUp()
        calculator = Calculator()
    }
    
    // Red: Write failing test
    func testAddition() {
        let result = calculator.add(2, 3)
        XCTAssertEqual(result, 5)
    }
    
    // Green: Implement minimal code to pass
    func testSubtraction() {
        let result = calculator.subtract(5, 3)
        XCTAssertEqual(result, 2)
    }
    
    // Refactor: Improve code while keeping tests green
    func testMultiplication() {
        let result = calculator.multiply(4, 3)
        XCTAssertEqual(result, 12)
    }
}

class Calculator {
    func add(_ a: Int, _ b: Int) -> Int {
        return a + b
    }
    
    func subtract(_ a: Int, _ b: Int) -> Int {
        return a - b
    }
    
    func multiply(_ a: Int, _ b: Int) -> Int {
        return a * b
    }
}





6. Testing Best Practices

  • Use dependency injection to make testing easier
  • Follow the AAA pattern (Arrange, Act, Assert)
  • Test one thing at a time with focused, small tests
  • Use descriptive test names that explain what is being tested
  • Mock external dependencies but not internal logic
  • Test edge cases and error conditions