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
| Concept | Context | Why it matters | Common confusion |
|---|---|---|---|
XCTestCase subclass | One file per type under test | Discoverability | One huge test class for everything |
setUp vs setUpWithError | Per-test fixture | throws lets you try in setup | Putting fixture creation in init (works but unconventional) |
XCTAssertEqual family | Value comparison | Clear diffs on failure | Using XCTAssertTrue(a == b) — loses diff |
async throws tests | Default for async code | First-class concurrency support | XCTestExpectation callbacks for async (legacy) |
XCTestExpectation | Notification/callback-driven | Still needed for non-async APIs | Using it for async/await — overkill |
XCTUnwrap | Optional that must exist | Auto-fails with line number | Force 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
@Testmacro 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
- “
setUpruns once per class.” No — it runs before each test method. UsesetUp(class:)or static fixtures for once-per-class. - “
XCTAssertTrue(a == b)is the same asXCTAssertEqual(a, b).” Wrong — the equality version shows the actual and expected values in the failure message. The boolean version just says “false.” - “
asynctests 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. - “Force-unwrap is fine in tests.” A crash in
setUpkills the whole test class. UseXCTUnwrapso other tests still report. - “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