iOS Testing
XCTest, Swift Testing, XCUITest, test doubles, async tests, snapshots, coverage, flaky tests
- 01
Two tests in the same XCTestCase pass on their own but fail when the class runs in order — what does XCTest actually reset between them?
EasyXCTest builds a brand-new instance of the test class for every test method, so instance properties are already fresh — anything that survives the boundary is shared state living outside the instance.
final class CartTests: XCTestCase { private static let suiteName = "CartTests" private var sut: Cart! private var defaults: UserDefaults! override func setUpWithError() throws { … - 02
Your CI log says only "XCTAssertTrue failed" — what would #expect have told you instead, and when do you need #require?
Easy#expectis a macro, so it expands the expression you wrote and reports the value of every sub-expression;XCTAssertTrueonly ever receives aBooland has nothing left to report butfalse.import XCTest final class CartTests_XCTest: XCTestCase { func testTotal() throws { let cart = Cart(items: [Item(price: 10), Item(price: 15)]) // Failure reads: "XCTAssertTrue failed" — the value is gone … - 03
You are porting an XCTestCase suite to Swift Testing — what replaces setUp, what becomes a trait, and what has to stay in XCTest?
EasyThe class becomes a
@Suite(usually a struct),setUpbecomesinit(),tearDownbecomesdeiniton a class suite or adeferin the test, and loops over test data become@Test(arguments:).// BEFORE — XCTest final class CartTests: XCTestCase { private var sut: Cart! override func setUpWithError() throws { sut = Cart(pricing: StubPricing()) … - 04
Your async test calls wait(for:timeout:) and now hangs until the run is killed instead of failing — what should it call instead?
Mediumwait(for:timeout:)blocks the thread it is called on, and in anasynctest that thread belongs to the cooperative pool, so the work that would fulfil the expectation can never be scheduled — the async-safe form isawait fulfillment(of:timeout:).// WRONG — deadlocks: the blocking wait owns the thread the callback needs func testRefreshes() async { let exp = expectation(description: "refreshed") sut.refresh { exp.fulfill() } wait(for: [exp], timeout: 1.0) // never returns in an async test } … - 05
There is no Mockito for Swift — so how do you fake a dependency, and when is a protocol per dependency the wrong call?
MediumSwift cannot synthesise a conformance at runtime, so a test double is a type you write by hand, a type a code generator writes for you, or a struct of closures you override per test.
// 1. Hand-written fake: conforms, records, returns what the test needs protocol ProfileLoading { func profile(id: String) async throws -> Profile } final class ProfileLoaderSpy: ProfileLoading { … - 06
A test calls load() on a @MainActor view model and then asserts, but the state is still .idle — what is going on?
Mediumload()almost certainly starts aTaskand returns immediately, so the assertion runs before the task body has had a single chance to execute.@MainActor @Observable final class ProfileViewModel { private(set) var state: State = .idle private let loader: ProfileLoading private(set) var loadTask: Task<Void, Never>? … - 07
How do you prove an analytics event was never sent, without every run paying a five-second inverted-expectation wait?
MediumMost of the time you should not wait at all: inject a spy for the analytics sink, drive the code to a point you can await deterministically, and assert the spy recorded nothing.
final class AnalyticsSpy: AnalyticsTracking { private(set) var events: [Event] = [] var onTrack: ((Event) -> Void)? func track(_ event: Event) { events.append(event) … - 08
Snapshot tests pass on your Mac and fail on the CI runner with a barely visible diff — what do you actually fix?
MediumA snapshot is a rendering of your view by one OS on one machine, so the fix is to pin that machine — a single simulator model and OS version used for both recording and verifying — not to re-record until CI is green.
import SnapshotTesting import XCTest final class ProfileHeaderSnapshotTests: XCTestCase { // CI and local runs must use the same destination: // xcodebuild test -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.4' … - 09
Your XCUITest suite is green in English and red the morning the German build lands — what were the queries matching on?
MediumAlmost certainly the label, which is the localised user-facing string; queries should match an
accessibilityIdentifier, which never gets translated.// WRONG — matches the localised label; dies the day de.lproj ships func testPay_localeFragile() { let app = XCUIApplication() app.launch() app.buttons["Continue"].tap() // "Weiter" in German XCTAssertTrue(app.staticTexts["Order placed"].exists) … - 10
A UI test needs a signed-in user with three items in the cart — how do you get the app into that state without tapping through sign-in?
MediumPass the scenario in at launch and let the app build itself from stubs: the UI test runs in a different process, so launch arguments and the launch environment are the only channel you have.
// ---- test target ---- final class CartUITests: XCTestCase { func testCheckoutFromSeededCart() { let app = XCUIApplication() app.launchArguments = ["-uiTesting", "-ResetState"] app.launchEnvironment = ["SCENARIO": "signed_in_cart_3"] … - 11
How do you make a pull request go red when cold launch regresses by 20%, and where does the "before" number actually live?
MediumA UI test that measures
XCTApplicationLaunchMetric, plus a baseline recorded into the project — and the baseline is keyed by device configuration, which is the part that bites.import XCTest // Baseline is stored at // MyApp.xcodeproj/xcshareddata/xcbaselines/<uuid>.xcbaseline/Info.plist // keyed by device model + OS — record it on the machine CI actually uses. final class LaunchPerformanceTests: XCTestCase { … - 12
Turning on parallel testing took the suite from 20 minutes to 6 and made four tests fail at random — what did parallelism change?
MediumYour tests stopped having the machine to themselves: XCTest parallelises by distributing test classes across cloned processes and cloned simulators, and Swift Testing goes further by running tests concurrently inside one process.
import Testing import Foundation // WRONG — a global the tests mutate. Sequentially it "works"; // under Swift Testing's default parallelism the two race. enum Session { static var current: User? } … - 13
The view model sits at 87% coverage and the bug still shipped — what is that number actually measuring?
HardThat 87% of the code's regions were executed at least once by some test — nothing about whether anything was asserted, and nothing about which combinations of inputs ran.
// 100% region coverage, zero confidence. struct Cart { var items: [Item] var promo: Promo? // The bug: a promo below its minimum spend must not apply. … - 14
One XCUITest fails about once every eight CI runs and never on your laptop — how do you tell a real race from a missing wait?
HardMake it fail on demand first, then change exactly one thing: if a longer wait fixes it, it was synchronisation; if it still fails with a thirty-second timeout, the app was in the wrong state and you have a real race.
// WRONG — two bugs in three lines func testPay_flaky() { let app = XCUIApplication() app.launch() sleep(2) // a constant vs. a loaded CI runner app.buttons["checkout.pay"].tap() … - 15
Product wants a test proving the Buy button is disabled while the cart is empty — can you assert that without launching the app in XCUITest?
HardNot with anything Apple ships:
bodyreturns an opaquesome View, the resolved view tree is private, and@Statehas no storage until SwiftUI installs it — so constructing the struct and reading its properties tells you nothing about what rendered.// The rule lives in the model — the part worth a fast test @Observable final class CartModel { private(set) var items: [Item] = [] var isSubmitting = false … - 16
A 900-line view controller calls AnalyticsManager.shared, URLSession.shared, UserDefaults.standard and Date() — how do you get it under test this week?
HardCut a seam at each of those four calls and change nothing else: the goal is an object you can construct in a test with no globals, not a rewritten screen.
// BEFORE — nothing here can be constructed in a test final class ReceiptViewController: UIViewController { func showReceipt(id: String) { Task { let (data, _) = try await URLSession.shared.data(from: .receipt(id)) let receipt = try JSONDecoder().decode(Receipt.self, from: data) …