Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

iOS Testing and Debugging Essentials

May 21, 2025 · 9 min read

iOS, Testing, XCTest, Debugging, Interview Prep

Introduction

Testing is where senior iOS developers distinguish themselves. Interviewers want to see that you write testable code, understand the test pyramid, and can debug production issues. With Swift's modern concurrency, testing async code is a frequent interview topic.

Building maintainable apps taught me that good tests catch bugs before they reach users. Here's what matters for interviews.


Unit Testing with XCTest

Basic Test Structure

import XCTest
@testable import MyApp

final class MealViewModelTests: XCTestCase {
    var sut: MealListViewModel!  // System Under Test
    var mockRepository: MockMealRepository!

    override func setUp() {
        super.setUp()
        mockRepository = MockMealRepository()
        sut = MealListViewModel(repository: mockRepository)
    }

    override func tearDown() {
        sut = nil
        mockRepository = nil
        super.tearDown()
    }

    func test_loadMeals_success_updatesMealsArray() async {
        // Given
        let expectedMeals = [
            Meal.mock(name: "Breakfast"),
            Meal.mock(name: "Lunch")
        ]
        mockRepository.mealsToReturn = expectedMeals

        // When
        await sut.loadMeals()

        // Then
        XCTAssertEqual(sut.meals.count, 2)
        XCTAssertEqual(sut.meals[0].name, "Breakfast")
        XCTAssertFalse(sut.isLoading)
        XCTAssertNil(sut.error)
    }

    func test_loadMeals_failure_setsError() async {
        // Given
        mockRepository.shouldThrowError = true

        // When
        await sut.loadMeals()

        // Then
        XCTAssertTrue(sut.meals.isEmpty)
        XCTAssertNotNil(sut.error)
        XCTAssertFalse(sut.isLoading)
    }

    func test_totalCalories_sumsAllMealCalories() {
        // Given
        sut.meals = [
            Meal.mock(calories: 300),
            Meal.mock(calories: 500),
            Meal.mock(calories: 200)
        ]

        // When
        let total = sut.totalCalories

        // Then
        XCTAssertEqual(total, 1000)
    }
}

Given-When-Then Pattern

Structure tests with Given (setup), When (action), Then (assertions). This makes tests readable and their intent clear. Some teams use Arrange-Act-Assert - same concept, different naming.


Mock Objects

Protocol-Based Mocking

// Protocol for dependency injection
protocol MealRepositoryProtocol {
    func fetchMeals() async throws -> [Meal]
    func save(_ meal: Meal) async throws -> Meal
    func delete(_ meal: Meal) async throws
}

// Mock implementation for testing
class MockMealRepository: MealRepositoryProtocol {
    var mealsToReturn: [Meal] = []
    var shouldThrowError = false
    var savedMeals: [Meal] = []
    var deletedMealIds: [UUID] = []

    func fetchMeals() async throws -> [Meal] {
        if shouldThrowError {
            throw TestError.mockError
        }
        return mealsToReturn
    }

    func save(_ meal: Meal) async throws -> Meal {
        if shouldThrowError {
            throw TestError.mockError
        }
        savedMeals.append(meal)
        return meal
    }

    func delete(_ meal: Meal) async throws {
        if shouldThrowError {
            throw TestError.mockError
        }
        deletedMealIds.append(meal.id)
    }
}

enum TestError: Error {
    case mockError
}

Test Data Factory

extension Meal {
    static func mock(
        id: UUID = UUID(),
        name: String = "Test Meal",
        calories: Int = 500,
        protein: Double = 25,
        carbs: Double = 50,
        fat: Double = 20,
        mealType: MealType = .lunch,
        loggedAt: Date = Date()
    ) -> Meal {
        Meal(
            id: id,
            name: name,
            calories: calories,
            protein: protein,
            carbs: carbs,
            fat: fat,
            mealType: mealType,
            loggedAt: loggedAt
        )
    }
}

Testing Async Code

Modern Async Tests

final class AsyncTests: XCTestCase {
    // Direct async testing
    func test_fetchUser_returnsUser() async throws {
        let service = UserService(apiClient: MockAPIClient())

        let user = try await service.fetchUser(id: "123")

        XCTAssertEqual(user.id, "123")
    }

    // Test with expectations for callbacks
    func test_callbackAPI_callsCompletion() {
        let expectation = expectation(description: "Callback called")

        service.fetchData { result in
            XCTAssertNotNil(result)
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 5.0)
    }

    // Multiple expectations
    func test_multipleAsyncOperations() async {
        let exp1 = expectation(description: "First completes")
        let exp2 = expectation(description: "Second completes")

        Task {
            await operation1()
            exp1.fulfill()
        }

        Task {
            await operation2()
            exp2.fulfill()
        }

        await fulfillment(of: [exp1, exp2], timeout: 5.0)
    }
}

Testing Combine Publishers

func test_publisher_emitsExpectedValues() {
    let publisher = [1, 2, 3].publisher
    var received: [Int] = []

    let expectation = expectation(description: "Publisher completes")

    let cancellable = publisher.sink(
        receiveCompletion: { _ in expectation.fulfill() },
        receiveValue: { received.append($0) }
    )

    wait(for: [expectation], timeout: 1.0)

    XCTAssertEqual(received, [1, 2, 3])
    cancellable.cancel()
}

UI Testing

import XCTest

final class MealLoggingUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["UI_TESTING"]
        app.launch()
    }

    func test_addMeal_showsMealInList() {
        // Navigate to meals tab
        app.tabBars.buttons["Meals"].tap()

        // Tap add button
        app.buttons["Add Meal"].tap()

        // Fill form
        let nameField = app.textFields["Meal Name"]
        nameField.tap()
        nameField.typeText("Test Lunch")

        let caloriesField = app.textFields["Calories"]
        caloriesField.tap()
        caloriesField.typeText("450")

        // Save
        app.buttons["Save"].tap()

        // Verify meal appears in list
        XCTAssertTrue(app.staticTexts["Test Lunch"].exists)
    }

    func test_deleteMeal_removesMealFromList() {
        app.tabBars.buttons["Meals"].tap()

        // Swipe to delete
        let cell = app.cells.containing(.staticText, identifier: "Test Meal").firstMatch
        cell.swipeLeft()
        app.buttons["Delete"].tap()

        // Confirm deletion
        app.alerts.buttons["Delete"].tap()

        // Verify removal
        XCTAssertFalse(app.staticTexts["Test Meal"].exists)
    }
}

Avoid Flaky UI Tests

UI tests are inherently slower and more prone to flakiness. Always use waitForExistence(timeout:) instead of assuming elements appear instantly. Minimize UI tests - save them for critical user flows only.


Integration Testing

final class MealRepositoryIntegrationTests: XCTestCase {
    var repository: CoreDataMealRepository!
    var coreDataStack: TestCoreDataStack!

    override func setUp() {
        super.setUp()
        coreDataStack = TestCoreDataStack()  // In-memory store
        repository = CoreDataMealRepository(coreDataStack: coreDataStack)
    }

    func test_saveThenFetch_returnsSavedMeal() async throws {
        // Given
        let meal = Meal.mock(name: "Integration Test Meal")

        // When
        let saved = try await repository.save(meal)
        let fetched = try await repository.fetch(id: saved.id)

        // Then
        XCTAssertEqual(fetched?.name, "Integration Test Meal")
    }
}

// In-memory Core Data stack for tests
class TestCoreDataStack: CoreDataStack {
    override init() {
        super.init()

        let description = NSPersistentStoreDescription()
        description.type = NSInMemoryStoreType

        persistentContainer.persistentStoreDescriptions = [description]
        persistentContainer.loadPersistentStores { _, error in
            if let error = error {
                fatalError("Test Core Data failed: \(error)")
            }
        }
    }
}

Performance Testing

final class PerformanceTests: XCTestCase {
    func test_mealFilterPerformance() {
        let viewModel = MealListViewModel()

        measure {
            for _ in 0..<1000 {
                _ = viewModel.filterMeals(by: .breakfast)
            }
        }
    }

    func test_nutritionCalculation_performance() {
        let meals = (0..<10000).map { _ in Meal.mock() }

        measure(metrics: [XCTCPUMetric(), XCTMemoryMetric()]) {
            _ = NutritionCalculator.calculateTotals(for: meals)
        }
    }
}

Debugging with Instruments

Signposts for Performance Profiling

import os.signpost

let log = OSLog(subsystem: "com.yourapp", category: "Performance")

func loadMeals() async {
    let signpostID = OSSignpostID(log: log)

    os_signpost(.begin, log: log, name: "Load Meals", signpostID: signpostID)

    // Work being measured
    let meals = try? await repository.fetchMeals()

    os_signpost(.end, log: log, name: "Load Meals", signpostID: signpostID)
}

Memory Leak Detection

// Common leak pattern
class LeakyViewModel {
    var closure: (() -> Void)?

    init() {
        // ❌ Strong reference cycle
        closure = {
            self.doSomething()  // self captured strongly
        }
    }

    // ✅ Weak capture breaks the cycle
    func setupCorrectly() {
        closure = { [weak self] in
            self?.doSomething()
        }
    }

    // Verify deallocation in debug builds
    deinit {
        print("\(Self.self) deallocated")
    }
}

Interview Questions

Q1: What's the difference between unit tests and integration tests?

Answer:

AspectUnit TestsIntegration Tests
ScopeSingle unit in isolationMultiple components together
DependenciesMockedReal (or partially real)
SpeedFastSlower
PurposeTest specific logicTest component interaction
// Unit test - isolated
func test_calculateTotal_sumsCorrectly() {
    let calculator = NutritionCalculator()
    let total = calculator.calculateTotal([100, 200, 300])
    XCTAssertEqual(total, 600)
}

// Integration test - real database
func test_saveMeal_persistsToDatabase() async throws {
    let repository = CoreDataMealRepository()  // Real implementation
    let meal = Meal.mock()

    let saved = try await repository.save(meal)
    let fetched = try await repository.fetch(id: saved.id)

    XCTAssertEqual(fetched?.name, meal.name)
}

Q2: How do you test async code in Swift?

Answer:

// Method 1: async test function
func test_asyncFunction() async throws {
    let result = try await service.fetchData()
    XCTAssertNotNil(result)
}

// Method 2: Expectations for callbacks
func test_callback() {
    let expectation = expectation(description: "Callback called")

    service.fetchData { result in
        XCTAssertNotNil(result)
        expectation.fulfill()
    }

    wait(for: [expectation], timeout: 5.0)
}

// Method 3: fulfillment for async context
func test_multipleCallbacks() async {
    let exp = expectation(description: "Completes")

    // ... async work that fulfills expectation

    await fulfillment(of: [exp], timeout: 5.0)
}

Q3: How do you debug memory leaks in iOS?

Answer: Multiple approaches:

  1. Instruments - Leaks: Profile with Leaks instrument to find objects allocated but never deallocated

  2. Memory Graph Debugger: Debug Navigator → Memory shows retain cycles visually

  3. Deinit logging:

deinit {
    print("\(Self.self) deallocated")
}
  1. Weak self audit: Check all closures for strong captures
closure = { [weak self] in
    self?.doSomething()
}

Q4: What's the test pyramid and why does it matter?

Answer: The test pyramid suggests having many unit tests, fewer integration tests, and minimal UI tests:

       /\
      /  \     UI Tests (5%)
     /----\    - Slow, flaky, expensive
    /      \   Integration Tests (25%)
   /--------\  - Medium speed, test component interaction
  /          \ Unit Tests (70%)
 /____________\ - Fast, reliable, cheap

Unit tests are fast and reliable. UI tests are slow and prone to flakiness. The pyramid optimizes for fast feedback while still ensuring end-to-end coverage.


Common Mistakes

1. Testing Implementation Instead of Behavior

// ❌ Testing internal state
func test_loading() {
    viewModel.loadMeals()
    XCTAssertTrue(viewModel.isLoading)
}

// ✅ Testing observable behavior
func test_loadMeals_showsResults() async {
    await viewModel.loadMeals()
    XCTAssertFalse(viewModel.meals.isEmpty)
    XCTAssertFalse(viewModel.isLoading)
}

2. Flaky UI Tests

// ❌ Race condition
app.buttons["Save"].tap()
XCTAssertTrue(app.staticTexts["Saved"].exists)  // May fail!

// ✅ Wait for element
app.buttons["Save"].tap()
let savedLabel = app.staticTexts["Saved"]
XCTAssertTrue(savedLabel.waitForExistence(timeout: 5))

3. Not Cleaning Up Test State

// ❌ State leaks between tests
class MyTests: XCTestCase {
    let viewModel = ViewModel()  // Shared across tests!
}

// ✅ Fresh state each test
class MyTests: XCTestCase {
    var viewModel: ViewModel!

    override func setUp() {
        viewModel = ViewModel()
    }

    override func tearDown() {
        viewModel = nil
    }
}

4. Missing Mock Verification

// ❌ Only checks result, not that repository was called
func test_save_succeeds() async {
    await viewModel.saveMeal(meal)
    XCTAssertFalse(viewModel.isLoading)
}

// ✅ Verify the interaction
func test_save_callsRepository() async {
    await viewModel.saveMeal(meal)
    XCTAssertTrue(mockRepository.savedMeals.contains(where: { $0.id == meal.id }))
}

Summary

ConceptPurpose
Unit testsTest isolated logic with mocks
Integration testsVerify component interaction
UI testsValidate critical user flows
Protocol-based mockingEnable dependency injection
XCTExpectationTest async callbacks
async test methodsModern async/await testing
InstrumentsProfile performance, find leaks
Test pyramidOptimize test distribution

Good tests give you confidence to refactor and ship. Follow the test pyramid, mock at protocol boundaries, and use Instruments when production issues arise.


Part 9 of the iOS Interview Prep series.

← Previous

iOS App Store Deployment and StoreKit 2

Next →

iOS System Integrations: HealthKit, Widgets, and More