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:
| Aspect | Unit Tests | Integration Tests |
|---|---|---|
| Scope | Single unit in isolation | Multiple components together |
| Dependencies | Mocked | Real (or partially real) |
| Speed | Fast | Slower |
| Purpose | Test specific logic | Test 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:
-
Instruments - Leaks: Profile with Leaks instrument to find objects allocated but never deallocated
-
Memory Graph Debugger: Debug Navigator → Memory shows retain cycles visually
-
Deinit logging:
deinit {
print("\(Self.self) deallocated")
}
- 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
| Concept | Purpose |
|---|---|
| Unit tests | Test isolated logic with mocks |
| Integration tests | Verify component interaction |
| UI tests | Validate critical user flows |
| Protocol-based mocking | Enable dependency injection |
| XCTExpectation | Test async callbacks |
| async test methods | Modern async/await testing |
| Instruments | Profile performance, find leaks |
| Test pyramid | Optimize 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.