🌙
☀️ Dark
PART 14

Testing

XCTest, UI tests, mocks, snapshot testing, and test architecture.

Intermediate 45 min read

Chapter 14: Testing — Verifying Correctness at Scale

Clear, memorable, technically accurate.

Learning Objectives

By the end of this chapter, you will understand how to guarantee the correctness of your application programmatically. You will learn how to write unit tests with XCTest and Swift Testing, UI tests, and integration tests. You will be able to refactor untestable code using dependency injection, mocks, and fakes. Finally, you will master testing async operations, networking, and persistence.

Prerequisites

Before reading this chapter, you must understand protocols, dependency injection, Swift Concurrency (async/await), and basic iOS architecture (like MVVM or MVC).

Why Does This Exist?

Imagine you have a complex financial application. You've just added a new feature to support multiple currencies. You run the app, click a few buttons, and it seems to work. You push to the App Store. A week later, you realize that while the new feature works, the old feature for calculating tax completely broke because they shared a utility function. Testing exists so that humans don't have to repeatedly verify that old code still works when new code is written.

The Problem Before the Solution

Before automated testing, the "QA Process" involved developers compiling the app, navigating to the specific screen, tapping buttons, and checking if the output looked right. When a bug was found, the developer would tweak the code, recompile, and tap through the app again.

Why the Old Approach Breaks

Manual testing does not scale. As an application grows, the number of possible states and user flows grows exponentially. A human cannot reliably execute a 500-step regression test suite every time a single line of code changes. Without automated testing, developers become afraid to refactor. Fear of breaking existing features leads to fragile, legacy codebases.

History

Unit testing originated with frameworks like SUnit for Smalltalk and JUnit for Java. Apple introduced OCUnit, which eventually evolved into XCTest. Recently, Apple introduced Swift Testing—a modern, macro-driven testing framework designed specifically for Swift, taking advantage of its type system and concurrency models.

Mental Model

Think of automated testing like a spelling and grammar checker in a word processor. You write a sentence (your code), and the checker instantly underlines errors (your tests failing). Now, remove the analogy. In iOS, tests are just separate target executables that import your application's code, instantiate your objects, call their methods, and assert that the returned values match expected values.

Internal Working

When you run a test target, Xcode builds your application and then builds a separate test bundle. This bundle is injected into the application process (for app tests) or runs on its own (for logic tests). The test runner reflects over your test classes, finds methods starting with test (in XCTest) or annotated with @Test (in Swift Testing), executes them synchronously or asynchronously, and traps any failed assertions.

Visual Explanation

Test Target (TestRunner)
│
├── Finds Test Cases
│   ├── testLoginSuccess()
│   ├── testLoginFailure()
│   └── testDataParsing()
│
├── Injects into App Process
│
└── Executes Methods
    ├── Setup (Given)
    ├── Action (When)
    └── Assertion (Then)
        └── ✅ Pass or ❌ Fail
        

Syntax

Using XCTest, a test case is a subclass of XCTestCase. Using the newer Swift Testing, tests are defined with the @Test macro.

// XCTest
import XCTest
@testable import MyApp

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

// Swift Testing
import Testing
@testable import MyApp

@Test func addition() {
    let calc = Calculator()
    #expect(calc.add(2, 3) == 5)
}

Tiny Example

Testing a pure function is the easiest form of testing.

struct MathEngine {
    func multiply(_ a: Int, _ b: Int) -> Int { return a * b }
}

import XCTest

final class MathEngineTests: XCTestCase {
    func testMultiplication() {
        let engine = MathEngine()
        let result = engine.multiply(4, 5)
        XCTAssertEqual(result, 20, "Multiplication should yield 20")
    }
}

Walkthrough

In the tiny example above, we import XCTest. We instantiate the object under test, MathEngine. We perform the action, which is calling multiply. Finally, we assert the expected outcome. The test runner catches the XCTAssertEqual and verifies it.

Break It

What happens if we test code that relies on a hidden dependency, like a singleton network manager?

func testFetchUser() async throws {
    let manager = UserManager() 
    // This hits the real production server!
    let user = try await manager.fetchUser()
    XCTAssertEqual(user.name, "Alice")
}

This test is flaky. If the network is down, the test fails. If Alice changes her name, the test fails. This is not a unit test.

Debug It

To fix the flaky network test, we must use Dependency Injection and create a Mock or Fake.

protocol NetworkProvider {
    func fetchData() async throws -> Data
}

class MockNetworkProvider: NetworkProvider {
    var stubbedData: Data = Data()
    func fetchData() async throws -> Data { return stubbedData }
}

Now, inject the MockNetworkProvider into your UserManager during testing. The test becomes fast, deterministic, and isolated.

Mini Project (20-30 min)

Build a CartManager for an e-commerce app. Implement an add(item:) method and a totalPrice computed property. Write a test to verify that adding two items correctly updates the total price.

View Solution
swift
swift
import XCTest

struct Item {
    let name: String
    let price: Double
}

class CartManager {
    private(set) var items: [Item] = []
    
    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price }
    }
    
    func add(item: Item) {
        items.append(item)
    }
}

final class CartManagerTests: XCTestCase {
    func testAddItemsUpdatesTotalPrice() {
        let cart = CartManager()
        cart.add(item: Item(name: "Apple", price: 1.5))
        cart.add(item: Item(name: "Banana", price: 2.0))
        
        XCTAssertEqual(cart.totalPrice, 3.5, "Total price should be the sum of item prices")
    }
}

Real Application Feature

Let's architect an offline-first, testable network layer.

We need to test async networking, local persistence fallback, and UI mapping.

Production Implementation

We use protocols to define our boundaries.

protocol APIClient {
    func request(endpoint: String) async throws -> Data
}

protocol PersistenceClient {
    func loadCache() throws -> Data
    func save(_ data: Data) throws
}

class FeedRepository {
    let api: APIClient
    let cache: PersistenceClient
    
    init(api: APIClient, cache: PersistenceClient) {
        self.api = api
        self.cache = cache
    }
    
    func getFeed() async throws -> Feed { ... }
}

In production, we inject real instances. In tests, we inject a MockAPIClient and an InMemoryCache.

Production Usage

In massive apps, engineers rely on Integration Tests to ensure these layers work together, and UI Tests to verify the user can actually tap the buttons. Snapshot testing is used to catch visual regressions (e.g., if a padding change breaks a layout). Swift Package Manager allows isolating domains into modules, meaning tests run much faster because you only test the modified module.

Performance

Tests must be fast. Slow tests ruin developer productivity. Avoid UI tests when a unit test can verify the logic. UI tests should be reserved for critical user journeys (e.g., login, checkout) because they take seconds to run, whereas unit tests take milliseconds.

Best Practices

  • Follow the Given-When-Then (Arrange-Act-Assert) pattern.
  • Test behavior, not implementation details.
  • Use Dependency Injection exclusively. Avoid Singletons.
  • Keep tests deterministic. Do not rely on external APIs or real databases.
  • Use fakes over mocks where possible (a fake has working implementations, like an in-memory DB).

Interview Questions

Easy: What is the difference between a unit test and a UI test?

A unit test tests an isolated piece of logic (a function or class) very quickly. A UI test spins up the entire application simulator and simulates a user interacting with the screen.

Medium: What is dependency injection and why is it necessary for testing?

Dependency injection means providing an object's instance variables from the outside rather than creating them internally. It allows us to pass mock or fake dependencies during testing.

Hard: How do you unit test a view controller?

You isolate logic into a ViewModel or Interactor. For the VC itself, you can trigger lifecycle methods manually (e.g., loadViewIfNeeded) and assert that UI elements hold expected values, but ideally, the heavy logic is decoupled from UIKit entirely.

Bigger Project (1-2 hours)

Write an XCTest for an async function that may throw a network timeout error.

View Solution
swift
swift
import XCTest

enum NetworkError: Error, Equatable {
    case timeout
    case badURL
}

protocol APIClient {
    func fetch() async throws -> Data
}

class MockAPI: APIClient {
    let shouldDelay: Bool
    
    init(shouldDelay: Bool) {
        self.shouldDelay = shouldDelay
    }
    
    func fetch() async throws -> Data {
        if shouldDelay {
            throw NetworkError.timeout
        }
        return Data("Success".utf8)
    }
}

final class NetworkTests: XCTestCase {
    func testNetworkTimeout() async {
        let mockAPI = MockAPI(shouldDelay: true)
        do {
            _ = try await mockAPI.fetch()
            XCTFail("Expected timeout error to be thrown")
        } catch {
            XCTAssertEqual(error as? NetworkError, .timeout, "Error should be a timeout")
        }
    }
}

Revision Sheet

Unit Tests: Fast, isolated. Integration Tests: Connecting components. UI Tests: Slow, full app state. Mocks: Objects that record interactions. Fakes: Working implementations (like in-memory DB). XCTestCase: Classic framework. @Test: Modern macro framework. Arrange-Act-Assert: The holy trinity of testing.

Connections

Testing relies heavily on Protocols (Part 1) and Architecture (Part 8) for dependency injection. It acts as the backbone for CI/CD (Part 19) to ensure broken builds are never shipped to App Store Connect (Part 20).