iOS Testing
Irbisa · cheatsheetSeptember 13, 2026

iOS Testing

XCTest, Swift Testing, XCUITest, test doubles, async tests, snapshots, coverage, flaky tests

Middle Developer16 itemscompressed for a skim
  1. 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?

    Easy

    XCTest 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 {
    …
  2. 02

    Your CI log says only "XCTAssertTrue failed" — what would #expect have told you instead, and when do you need #require?

    Easy

    #expect is a macro, so it expands the expression you wrote and reports the value of every sub-expression; XCTAssertTrue only ever receives a Bool and has nothing left to report but false.

    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
    …
  3. 03

    You are porting an XCTestCase suite to Swift Testing — what replaces setUp, what becomes a trait, and what has to stay in XCTest?

    Easy

    The class becomes a @Suite (usually a struct), setUp becomes init(), tearDown becomes deinit on a class suite or a defer in 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())
    …
  4. 04

    Your async test calls wait(for:timeout:) and now hangs until the run is killed instead of failing — what should it call instead?

    Medium

    wait(for:timeout:) blocks the thread it is called on, and in an async test that thread belongs to the cooperative pool, so the work that would fulfil the expectation can never be scheduled — the async-safe form is await 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
    }
    …
  5. 05

    There is no Mockito for Swift — so how do you fake a dependency, and when is a protocol per dependency the wrong call?

    Medium

    Swift 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 {
    …
  6. 06

    A test calls load() on a @MainActor view model and then asserts, but the state is still .idle — what is going on?

    Medium

    load() almost certainly starts a Task and 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>?
    …
  7. 07

    How do you prove an analytics event was never sent, without every run paying a five-second inverted-expectation wait?

    Medium

    Most 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)
    …
  8. 08

    Snapshot tests pass on your Mac and fail on the CI runner with a barely visible diff — what do you actually fix?

    Medium

    A 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'
    …
  9. 09

    Your XCUITest suite is green in English and red the morning the German build lands — what were the queries matching on?

    Medium

    Almost 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. 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?

    Medium

    Pass 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. 11

    How do you make a pull request go red when cold launch regresses by 20%, and where does the "before" number actually live?

    Medium

    A 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. 12

    Turning on parallel testing took the suite from 20 minutes to 6 and made four tests fail at random — what did parallelism change?

    Medium

    Your 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. 13

    The view model sits at 87% coverage and the bug still shipped — what is that number actually measuring?

    Hard

    That 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. 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?

    Hard

    Make 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. 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?

    Hard

    Not with anything Apple ships: body returns an opaque some View, the resolved view tree is private, and @State has 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. 16

    A 900-line view controller calls AnalyticsManager.shared, URLSession.shared, UserDefaults.standard and Date() — how do you get it under test this week?

    Hard

    Cut 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)
    …