Irbisa · Cheatsheet

iOS Testing

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

Middle Developer · 25 questions

  1. 01

    What is the difference between unit, integration and UI tests, and why write tests for an iOS app at all?

    A unit test checks one type in isolation with its dependencies faked and runs in milliseconds; an integration test runs several real pieces together; a UI test drives the built app through its screens the way a user does.

  2. 02

    What does a good unit test look like, and what is Arrange-Act-Assert?

    A good unit test checks one behaviour, is fast and deterministic, and reads in three blocks: Arrange the inputs and fakes, Act by calling the one method under test, Assert on the observable result.

    @Test("a coupon below its minimum spend is ignored")func couponBelowMinimum() {  // Arrange  var cart = Cart(items: [Item(price: 20)])  let coupon = Coupon(code: "SPRING", discount: 10, minimumSpend: 50)…
  3. 03

    What does @testable import do, and what does it require of the build?

    @testable import MyApp lets the test target use the module's internal declarations as if they were public, so you can test without widening access in production code.

  4. 04

    What do setUp and tearDown do in XCTest, and how many instances of the test-case class does XCTest create?

    XCTest creates a separate instance of your XCTestCase subclass for every test method and calls setUp before and tearDown after each one, so instance properties always start fresh.

    final class CartTests: XCTestCase {  private var defaults: UserDefaults!  private var sut: Cart!   override func setUpWithError() throws {    // A private suite, not .standard: nothing bleeds into the next test…
  5. 05

    This test is green even though FailingAPI always fails. Why do the assertions never fail?

    final class ProfileViewModelTests: XCTestCase {  func testLoadReportsOffline() {    let sut = ProfileViewModel(api: FailingAPI(error: .offline))     sut.load { result in      XCTAssertEqual(result, .failure(.offline))      XCTAssertEqual(sut.state, .error)    }  }} // ProfileViewModel: the completion fires after an awaitfunc load(completion: @escaping (Result<Profile, APIError>) -> Void) {  Task { completion(await api.fetch()) }}

    load calls its completion later, asynchronously, so the test method returns before the closure runs; XCTest considers the test finished and passed at that moment, and the assertions inside the closure are never checked for this test.

  6. 06

    What is the difference between #expect and #require in Swift Testing, and what do they report that XCTAssertTrue cannot?

    #expect records a failure and lets the test continue; try #require throws and stops the test, which is right for a precondition such as unwrapping an optional.

    import Testing @Suite("Cart")struct CartTests {  @Test func total() throws {    let cart = Cart(items: [Item(price: 10), Item(price: 15)])…
  7. 07

    How does an XCUITest UI test interact with your app, and how is it different from a unit test?

    A UI test runs in its own runner process and drives the installed app from outside through the accessibility layer, like a user; it cannot call your code or read its state.

    final class CheckoutUITests: XCTestCase {  func testPayShowsConfirmation() {    let app = XCUIApplication()    app.launchArguments = ["-uiTesting"]    app.launch()…
  8. 08

    How does Swift Testing differ from XCTest, and what has to stay in XCTest when you migrate?

    Swift Testing replaces XCTestCase subclasses and the XCTAssert family with @Test functions, the #expect and #require macros and traits, and runs tests in parallel inside one process by default.

    // Before: XCTestfinal class CartTests: XCTestCase {  private var sut: Cart!  override func setUp() { sut = Cart(pricing: StubPricing()) }   func testDiscount() {…
  9. 09

    How many test cases does each of these parameterised tests run, and what goes wrong if the two lists passed to zip differ in length?

    enum Plan: CaseIterable { case free, pro, team } @Test(arguments: Plan.allCases, [1, 5, 50])func priceIsNeverNegative(plan: Plan, seats: Int) {  #expect(Pricing.price(plan, seats: seats) >= 0)} @Test(arguments: zip(Plan.allCases, [0, 9, 29]))func basePrice(plan: Plan, expected: Int) {  #expect(Pricing.price(plan, seats: 1) == expected)}

    The first runs 9 cases, because two argument collections produce every combination (3 × 3); the second runs 3, because zip pairs elements up.

  10. 10

    What are a stub, a spy, a mock and a fake, and how do you build them in Swift without a mocking library?

    All four stand in for a real dependency: a stub returns canned answers, a spy also records its calls, a mock verifies expectations set in advance, and a fake is a working lightweight implementation.

    protocol ProfileLoading {  func profile(id: String) async throws -> Profile} // Stub + spy: returns what the test needs and records the callsfinal class ProfileLoaderSpy: ProfileLoading {…
  11. 11

    Find the bug: this test fails even though CheckoutViewModel.confirm() does call track("purchase").

    protocol Analytics { mutating func track(_ event: String) }struct AnalyticsSpy: Analytics {  private(set) var events: [String] = []  mutating func track(_ event: String) { events.append(event) }}final class CheckoutViewModel {  private var analytics: any Analytics  init(analytics: any Analytics) { self.analytics = analytics }  func confirm() { analytics.track("purchase") }}@Test func confirmTracksPurchase() {  let spy = AnalyticsSpy()  let sut = CheckoutViewModel(analytics: spy)  sut.confirm()  #expect(spy.events == ["purchase"])}

    AnalyticsSpy is a struct, so handing it to CheckoutViewModel copies it: confirm() records "purchase" in the view model's copy, while the test's spy keeps an empty events array.

  12. 12

    Why does wait(for:timeout:) hang in an async XCTest, and when do you still need an expectation with async/await?

    wait(for:timeout:) blocks the thread it runs on, and in an async test that thread belongs to the cooperative pool the awaited work needs, so the test can deadlock; in an async test use await fulfillment(of:timeout:), which suspends instead.

    // Wrong: blocks the thread the callback needsfunc testRefreshes_hangs() async {  let exp = expectation(description: "refreshed")  sut.refresh { _ in exp.fulfill() }  wait(for: [exp], timeout: 1)}…
  13. 13

    A test calls load() on a @MainActor view model and asserts right away, but the state is still .idle. Why, and how do you fix the test?

    load() almost certainly starts a Task and returns immediately, so the assertion runs before the task body has executed even once.

    @MainActor @Observablefinal class ProfileViewModel {  private(set) var state: State = .idle  private(set) var loadTask: Task<Void, Never>?  private let loader: ProfileLoading…
  14. 14

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

    Usually you do not wait at all: inject a spy for the analytics sink, drive the code to a point you can await, and assert the spy recorded nothing, which is exact and instant.

    final class AnalyticsSpy: AnalyticsTracking {  private(set) var events: [Event] = []  var onTrack: ((Event) -> Void)?   func track(_ event: Event) {    events.append(event)…
  15. 15

    What does the teardown block in this test check, and why does it fail for this view model?

    final class FeedViewModel {  private let service: FeedService  private(set) var items: [Post] = []   init(service: FeedService) {    self.service = service    service.onUpdate = { posts in self.items = posts }  }} func testFeedViewModelIsReleased() {  let sut = FeedViewModel(service: FeedService())  addTeardownBlock { [weak sut] in    XCTAssertNil(sut, "FeedViewModel leaked")  }}

    It checks for a memory leak: once the test method has returned, nothing but the weak capture should point at sut, so it must be nil.

  16. 16

    How do you test a SwiftUI view, given that you cannot inspect what body rendered in a unit test?

    Move the decisions out of the view into a model and unit-test that; then check pixels with snapshot tests and behaviour with XCUITest.

    @Observablefinal class CartModel {  private(set) var items: [Item] = []  var isSubmitting = false   var canBuy: Bool { !items.isEmpty && !isSubmitting }…
  17. 17

    What are snapshot tests, and why do they so often fail on CI with a barely visible diff?

    A snapshot test renders a view and compares it with a reference image or text dump committed to the repo, catching visual regressions no assertion would.

    import SnapshotTestingimport XCTest final class ProfileHeaderSnapshotTests: XCTestCase {  // Same destination locally and on CI, e.g.  // -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.4'…
  18. 18

    Your XCUITest suite is green in English and red the day the German build lands. What were the queries matching on, and what should they match?

    They were matching the label, which is the translated, user-visible text.

    struct CheckoutView: View {  let total: Decimal   var body: some View {    VStack {      Text(total, format: .currency(code: "EUR"))…
  19. 19

    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?

    Pass a scenario in at launch and let the app build itself from stubs: the UI test runs in a different process, so launchArguments and launchEnvironment are the main channel into the app.

    // Test targetfunc testCheckoutFromSeededCart() {  let app = XCUIApplication()  app.launchArguments = ["-uiTesting"]  app.launchEnvironment = ["SCENARIO": "signed_in_cart_3"]  app.launch()…
  20. 20

    What does Xcode's code coverage number actually measure, and why can a view model at 87% coverage still ship a bug?

    It measures which code regions ran at least once during the tests, and nothing about whether anything was asserted, or which combinations of inputs ran.

  21. 21

    What is TDD, and where does it pay off in iOS development and where does it not?

    Test-driven development means writing a failing test first, then the minimum code that makes it pass, then refactoring with the tests green: red, green, refactor.

  22. 22

    How do you set up the test jobs of an iOS pipeline: what runs on every pull request and what on merge or nightly?

    Run the fast, deterministic suites on every pull request (unit and integration tests under a Smoke test plan) and move the slow ones (full UI, snapshots, performance) to merge or nightly.

  23. 23

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

    Your tests stopped having the machine to themselves: XCTest distributes test classes across cloned processes and simulators, and Swift Testing also runs tests concurrently inside one process.

    // Wrong: a global the tests mutate; in parallel they raceenum Session { static var current: User? } @Test func signInStoresUser() {  Session.current = User(id: "1")  #expect(Session.current?.id == "1")   // fails whenever another test wins…
  24. 24

    One XCUITest fails about once in eight CI runs and never on your laptop. How do you tell a real race from a missing wait?

    Make it fail on demand first, then change one thing: if a longer wait fixes it, it was synchronisation; if it still fails with a 30-second timeout, the app reached the wrong state and you have a real race.

    final class CheckoutUITests: XCTestCase {  private var app: XCUIApplication!   override func setUp() {    continueAfterFailure = false    app = XCUIApplication()…
  25. 25

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

    Cut a seam at each of those four calls and change nothing else: the goal is an object you can construct in a test without globals, not a rewritten screen.

    // After: seams only, the singletons stay as they areprotocol Analytics { func track(_ event: Event) }extension AnalyticsManager: Analytics {} final class ReceiptPresenter {  private let session: URLSession…