8.2 — XCTest Unit Testing

Opening scenario

You inherit a codebase with 800 tests written in five different styles: some use setUp, some use setUpWithError, some pre-create everything in init, some use synchronous expectations, some use async. Your new tests need to fit in. Which patterns are current Swift 6 + Xcode 16 best practice, and which are legacy noise?

XCTest has evolved across nine years. This chapter shows what to write in 2026.

Context taxonomy

ConceptContextWhy it mattersCommon confusion
XCTestCase subclassOne file per type under testDiscoverabilityOne huge test class for everything
setUp vs setUpWithErrorPer-test fixturethrows lets you try in setupPutting fixture creation in init (works but unconventional)
XCTAssertEqual familyValue comparisonClear diffs on failureUsing XCTAssertTrue(a == b) — loses diff
async throws testsDefault for async codeFirst-class concurrency supportXCTestExpectation callbacks for async (legacy)
XCTestExpectationNotification/callback-drivenStill needed for non-async APIsUsing it for async/await — overkill
XCTUnwrapOptional that must existAuto-fails with line numberForce unwrap with ! — crashes the suite

Concept → Why → How → Code

Concept: XCTestCase is an NSObject subclass; every method named test... becomes a test. Xcode discovers them via Objective-C runtime. Swift Testing (the new framework introduced in 2024) is replacing XCTest for new projects, but XCTest remains the production standard in 2026.

Why: XCTest is what every existing iOS codebase uses, ships with Xcode, runs on simulator + device + Mac + Linux (via swift-corelibs-xctest), and integrates with Xcode’s UI for one-click debugging into a failing assertion.

How: subclass XCTestCase, declare your subject under test (SUT) as a property, set it up in setUpWithError, tear it down in tearDownWithError, assert with XCTAssert* variants that produce diffs.

Code — canonical structure:

import XCTest
@testable import MyApp

final class CartTests: XCTestCase {
    private var sut: Cart!

    override func setUpWithError() throws {
        try super.setUpWithError()
        sut = Cart(items: [], discountPct: 0)
    }

    override func tearDownWithError() throws {
        sut = nil
        try super.tearDownWithError()
    }

    func test_total_emptyCart_returnsZero() {
        XCTAssertEqual(sut.total, 0)
    }

    func test_total_withItems_sumsCorrectly() {
        sut.add(Item(price: 10))
        sut.add(Item(price: 15))
        XCTAssertEqual(sut.total, 25)
    }
}

Async tests (the only way you should write them in 2026)

func test_loadUser_returnsUser() async throws {
    let service = UserService(api: MockAPI(stub: User(id: "1")))
    let user = try await service.loadUser(id: "1")
    XCTAssertEqual(user.id, "1")
}

No XCTestExpectation. No wait(for:). Just async throws. If you see expectation-based async tests in code review for new code, request changes.

When you DO still need XCTestExpectation

For APIs that take callbacks and have no async equivalent:

func test_notification_isPosted() {
    let expectation = expectation(forNotification: .userDidLogin, object: nil)
    AuthManager.shared.simulateLogin()
    wait(for: [expectation], timeout: 1.0)
}

Assertion variants — pick the right one

XCTAssertEqual(actual, expected)         // Diff on failure
XCTAssertNotEqual(actual, expected)
XCTAssertTrue(condition)                 // No diff, just true/false
XCTAssertNil(optional)
XCTAssertNotNil(optional)
XCTAssertThrowsError(try operation())    // Expects throw
XCTAssertNoThrow(try operation())        // Expects success
XCTAssertEqual(a, b, accuracy: 0.001)    // Floating-point compare
let unwrapped = try XCTUnwrap(optional)  // Optional → unwrapped or fail

XCTSkip — conditional skipping

func test_someThingThatNeedsM1() throws {
    try XCTSkipIf(ProcessInfo.processInfo.machineHardwareName != "arm64",
                  "Requires Apple Silicon")
    // ... rest of test
}

In the wild

  • swift-corelibs-xctest — Apple’s open-source XCTest implementation for Linux server-side Swift; same API.
  • swift-testing (the new framework) — uses @Test macro and #expect() macro. Apple introduced it at WWDC 2024 and ships it alongside XCTest. Most teams haven’t migrated yet — XCTest is still the safer choice for shipping today.
  • Quick/Nimble — once-popular BDD wrappers (describe, it, expect). Mostly legacy now; new projects rarely adopt them.

Common misconceptions

  1. setUp runs once per class.” No — it runs before each test method. Use setUp(class:) or static fixtures for once-per-class.
  2. XCTAssertTrue(a == b) is the same as XCTAssertEqual(a, b).” Wrong — the equality version shows the actual and expected values in the failure message. The boolean version just says “false.”
  3. async tests automatically run on the main actor.” No — they run on the global executor unless your test class is @MainActor. If you’re testing UIKit/SwiftUI code, mark the class or test @MainActor.
  4. “Force-unwrap is fine in tests.” A crash in setUp kills the whole test class. Use XCTUnwrap so other tests still report.
  5. “Order of test execution matters.” XCTest runs tests in alphabetical order by default, but never rely on this. Each test must be independent — that’s the contract.

Seasoned engineer’s take

Async/await is the single biggest improvement XCTest has seen. Convert callback-based tests to async throws whenever you touch them — your suite gets simpler and your error messages get better. Don’t migrate to swift-testing yet on shipping projects; the tooling (CI reporters, code coverage, third-party integrations) is still catching up. Wait until Xcode 17.

[!TIP] Add XCTContext.runActivity(named:) blocks inside long tests to make the Xcode test report readable. Each activity becomes a collapsible section with its own timing.

[!WARNING] Never store state in static or singleton properties touched by tests. Each test runs in a fresh instance of your test class, but global state survives — leading to tests that pass alone and fail in suites.

Interview corner

Junior — “How do you set up state before each test?” Override setUpWithError() and initialize properties there. tearDownWithError() runs after each test for cleanup. Both methods run once per test... method, not once per class.

Mid — “How do you test async code with XCTest?” Declare the test as async throws and use await directly. Use XCTestExpectation only for callback-based APIs that don’t have an async variant. For testing race conditions, use @MainActor annotations to control thread context.

Senior — “Walk me through diagnosing a flaky test.” First, run it 50 times in isolation with xcodebuild test -only-testing and -test-iterations 50. If it’s deterministic alone but flaky in the suite, the issue is shared state — singletons, file system, UserDefaults, or test ordering dependencies. If it flakes in isolation, look for real timers (DispatchQueue.main.asyncAfter, Task.sleep), real I/O, or async work that completes outside the awaited path. Replace real clocks with injected Clock protocols. Replace URLSession with a URLProtocol mock. If the test asserts on UI work, ensure the test is @MainActor. I’d also consider whether the test is asserting on observable behavior vs implementation detail — implementation-detail tests flake every time the implementation changes.

Red flag — “I just add XCTSkip to flaky tests.” That’s not fixing the test; it’s hiding the bug.

Lab preview

Lab 8.1 walks you through writing tests first in the XCTest style for a real NetworkClient, including async APIs and XCTUnwrap patterns.


Next: TDD in Swift