Skip to content
intermediate Phase 10 · Testing & Debugging

XCTest & Unit Testing

Write unit tests with XCTest, assertions, test doubles, and test organization.

50m
3 problems
Topic Progress 0%

XCTest Basics

What is XCTest?

XCTest is Apple's native testing framework for Swift and Objective-C. It integrates directly into Xcode and provides everything you need to write and run unit tests, performance tests, and UI tests. Every iOS project should include tests to ensure code quality and prevent regressions.

Creating a Test Target

In Xcode, go to File > New > Target and select 'iOS Unit Testing Bundle'. This creates a new target with a test file that imports XCTest:

import XCTest
@testable import MyApp

class CalculatorTests: XCTestCase {
    func testAddition() {
        let calculator = Calculator()
        let result = calculator.add(2, 3)
        XCTAssertEqual(result, 5)
    }
}

The @testable import gives your test access to internal types and methods in your app module.

XCTestCase Lifecycle

XCTestCase provides a well-defined lifecycle for each test method:

  • setUpWithError(): Called before each test method. Use this to create fresh instances of objects under test.
  • tearDownWithError(): Called after each test method. Clean up resources here.
  • setUp() / tearDown(): Synchronous versions (without throws).
class UserRepositoryTests: XCTestCase {
    var repository: UserRepository!
    var mockNetworkService: MockNetworkService!
    
    override func setUpWithError() throws {
        mockNetworkService = MockNetworkService()
        repository = UserRepository(networkService: mockNetworkService)
    }
    
    override func tearDownWithError() throws {
        repository = nil
        mockNetworkService = nil
    }
    
    func testFetchUsersReturnsData() async throws {
        mockNetworkService.usersToReturn = [
            User(id: 1, name: "Alice")
        ]
        let users = try await repository.fetchUsers()
        XCTAssertEqual(users.count, 1)
    }
}

Each test method runs in isolation with fresh setUp/tearDown calls, preventing state leakage between tests.

Test Method Naming

Use descriptive names that explain what is being tested and expected behavior:

// Good naming
class LoginViewModelTests: XCTestCase {
    func testLogin_withValidCredentials_returnsSuccess() { }
    func testLogin_withInvalidPassword_returnsError() { }
    func testLogin_withEmptyEmail_returnsValidationError() { }
}

// Bad naming
class LoginViewModelTests: XCTestCase {
    func testLogin() { }
    func test2() { }
}

A common convention is test_<unit under test>_<scenario>_<expected result>. This makes test failures self-documenting and helps developers quickly understand what broke.

Assertions & Matchers

Core Assertions

XCTest provides a rich set of assertions to validate test conditions. The most commonly used are:

func testAssertions() {
    // Equality
    XCTAssertEqual(2 + 2, 4, "Basic math should work")
    XCTAssertNotEqual(2 + 2, 5)
    
    // Boolean checks
    XCTAssertTrue(user.isActive)
    XCTAssertFalse(user.isBanned)
    
    // Nil checks
    XCTAssertNotNil(result)
    XCTAssertNil(optionalValue)
    
    // Comparison
    XCTAssertGreaterThan(age, 18)
    XCTAssertLessThanOrEqual(price, 100.0)
    
    // Identity
    XCTAssertEqualObjects(obj1, obj2) // Reference equality
}

Throwing and Async Assertions

Modern Swift code uses async/throws extensively. XCTest supports both:

// Testing thrown errors
func testParseInvalidJSON_throwsError() {
    let invalidJSON = "not json".data(using: .utf8)!
    XCTAssertThrowsError(try JSONDecoder().decode(User.self, from: invalidJSON)) { error in
        guard let decodingError = error as? DecodingError else {
            XCTFail("Expected DecodingError")
            return
        }
        // Assert on specific error details
    }
}

// Testing async code
func testFetchData_returnsNonEmptyArray() async throws {
    let data = try await apiClient.fetchData()
    XCTAssertFalse(data.isEmpty, "API should return data")
}

Custom Assertions

For complex validation logic, create custom assertion functions to reduce test boilerplate:

extension XCTestCase {
    func assertUser(_ user: User,
                    hasName expectedName: String,
                    file: StaticString = #file,
                    line: UInt = #line) {
        XCTAssertEqual(user.name, expectedName,
                       "User name should be \(expectedName)",
                       file: file, line: line)
    }
    
    func assertResponse<T: Equatable>(
        _ response: APIResponse<T>,
        isSuccess expected: Bool,
        file: StaticString = #file,
        line: UInt = #line) {
        XCTAssertEqual(response.isSuccess, expected,
                       file: file, line: line)
    }
}

Using #file and #line parameters ensures failure messages point to the actual test location, not the helper function.

Performance Tests

XCTest includes built-in performance measurement:

func testJSONParsingPerformance() {
    let jsonData = loadFixture("large_response.json")
    measure {
        for _ in 0..<1000 {
            _ = try! JSONDecoder().decode([User].self, from: jsonData)
        }
    }
}

The measure block runs the closure multiple times and reports statistics including average time, standard deviation, and whether performance improved or degraded compared to the baseline.

Test Organization

Test Suites and Structure

Organize tests to mirror your source code structure. A common pattern is to create a test file for each source file:

MyApp/
├── Models/
│   └── User.swift
├── ViewModels/
│   └── UserViewModel.swift
└── Services/
    └── UserService.swift

MyAppTests/
├── Models/
│   └── UserTests.swift
├── ViewModels/
    └── UserViewModelTests.swift
    └── UserViewModelTests+Validation.swift
└── Services/
    └── UserServiceTests.swift

Test Groups with Nested Classes

Use nested classes or extensions to logically group related tests:

class UserViewModelTests: XCTestCase {
    var viewModel: UserViewModel!
    
    override func setUp() { viewModel = UserViewModel() }
    
    // MARK: - Validation Tests
    class ValidationTests: UserViewModelTests {
        func testEmailValidation_rejectsInvalidFormat() { }
        func testPasswordValidation_requiresMinimumLength() { }
        func testUsernameValidation_rejectsSpecialCharacters() { }
    }
    
    // MARK: - Network Tests
    class NetworkTests: UserViewModelTests {
        func testFetchProfile_updatesUserOnSuccess() { }
        func testFetchProfile_showsErrorOnFailure() { }
        func testFetchProfile_cachesResult() { }
    }
}

Asynchronous Testing

Test async operations with expectations:

func testAsyncOperation() {
    let expectation = expectation(description: "Network request completes")
    
    service.fetchData { result in
        switch result {
        case .success(let data):
            XCTAssertNotNil(data)
        case .failure(let error):
            XCTFail("Expected success, got \(error)")
        }
        expectation.fulfill()
    }
    
    waitForExpectations(timeout: 5) { error in
        if let error = error {
            XCTFail("Timeout: \(error.localizedDescription)")
        }
    }
}

Test Coverage

Enable code coverage in Xcode by going to Scheme > Edit Scheme > Test > Options > Code Coverage. Aim for meaningful coverage—focus on critical business logic rather than 100% coverage of trivial code. Use the Coverage Report (Cmd+U, then Report navigator) to identify untested paths.

CI/CD Integration

Run tests automatically with xcodebuild test in your CI pipeline:

xcodebuild test \
  -project MyApp.xcodeproj \
  -scheme MyApp \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -resultBundlePath test_results.xcresult

This generates an .xcresult bundle that can be parsed for test summaries and failure details.

Quiz

1. What does `@testable import MyApp` do in a test file?

Question 1 options

2. How often is setUp() called when running 5 test methods?

Question 2 options

3. Which assertion checks that two values are NOT equal?

Question 3 options

4. What is the purpose of `measure { }` in XCTest?

Question 4 options

Flashcards

Question

What is the difference between setUp() and setUpWithError()?

Answer

setUp() is synchronous and cannot throw errors. setUpWithError() is marked with throws, allowing test setup to propagate errors that automatically fail the test.

Question

What does XCTAssertEqual vs XCTAssertTrue do?

Answer

XCTAssertEqual checks that two values are equal. XCTAssertTrue checks that an expression evaluates to true. Use XCTAssertEqual for value comparisons and XCTAssertTrue for boolean conditions.

Question

How do you test async code in XCTest?

Answer

Mark the test method as async throws and use await on async calls. Alternatively, use XCTestExpectation with waitForExpectations for callback-based async code.

Question

What is the naming convention for test methods?

Answer

test_<unitUnderTest>_<scenario>_<expectedResult>, e.g., testLogin_withInvalidPassword_returnsError. This makes test failures self-documenting.

Revision Notes

Key Takeaways

  • 1. Each test method runs in isolation with fresh setUp/tearDown
  • 2. Use @testable import to test internal types and methods
  • 3. Name tests descriptively: test_<what>_<scenario>_<expected>
  • 4. Enable code coverage to find untested code paths
  • 5. Use expectations for async testing, not sleep()

Interview Tips

  • Explain the difference between unit tests, integration tests, and UI tests
  • Describe how you would test a ViewModel with network dependencies
  • Discuss test coverage metrics and why 100% isn't always the goal
  • Walk through writing a test for a specific feature using TDD

Cheat Sheet

XCTest Quick Reference

  • @testable import MyApp to access internal APIs
  • setUpWithError() / tearDownWithError() for per-test setup/cleanup
  • XCTAssertEqual(a, b) - values equal
  • XCTAssertTrue/False(expr) - boolean checks
  • XCTAssertNil/NotNil(val) - nil checks
  • measure { } for performance testing
  • expectation + waitForExpectations for async