Irbisa · Cheatsheet
iOS Testing
XCTest, Swift Testing, XCUITest, test doubles, async tests, snapshots, coverage, flaky tests
What is the difference between unit, integration and UI tests, and why write tests for an iOS app at all?
What does a good unit test look like, and what is Arrange-Act-Assert?
@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)…What does
@testable importdo, and what does it require of the build?What do
setUpandtearDowndo in XCTest, and how many instances of the test-case class does XCTest create?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…This test is green even though
FailingAPIalways 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()) }}What is the difference between
#expectand#requirein Swift Testing, and what do they report thatXCTAssertTruecannot?import Testing @Suite("Cart")struct CartTests { @Test func total() throws { let cart = Cart(items: [Item(price: 10), Item(price: 15)])…How does an XCUITest UI test interact with your app, and how is it different from a unit test?
final class CheckoutUITests: XCTestCase { func testPayShowsConfirmation() { let app = XCUIApplication() app.launchArguments = ["-uiTesting"] app.launch()…How does Swift Testing differ from XCTest, and what has to stay in XCTest when you migrate?
// Before: XCTestfinal class CartTests: XCTestCase { private var sut: Cart! override func setUp() { sut = Cart(pricing: StubPricing()) } func testDiscount() {…How many test cases does each of these parameterised tests run, and what goes wrong if the two lists passed to
zipdiffer 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)}What are a stub, a spy, a mock and a fake, and how do you build them in Swift without a mocking library?
protocol ProfileLoading { func profile(id: String) async throws -> Profile} // Stub + spy: returns what the test needs and records the callsfinal class ProfileLoaderSpy: ProfileLoading {…Find the bug: this test fails even though
CheckoutViewModel.confirm()does calltrack("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"])}Why does
wait(for:timeout:)hang in anasyncXCTest, and when do you still need an expectation with async/await?// 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)}…A test calls
load()on a@MainActorview model and asserts right away, but the state is still.idle. Why, and how do you fix the test?@MainActor @Observablefinal class ProfileViewModel { private(set) var state: State = .idle private(set) var loadTask: Task<Void, Never>? private let loader: ProfileLoading…How do you prove an analytics event was never sent, without every run paying a five-second inverted-expectation wait?
final class AnalyticsSpy: AnalyticsTracking { private(set) var events: [Event] = [] var onTrack: ((Event) -> Void)? func track(_ event: Event) { events.append(event)…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") }}How do you test a SwiftUI view, given that you cannot inspect what
bodyrendered in a unit test?@Observablefinal class CartModel { private(set) var items: [Item] = [] var isSubmitting = false var canBuy: Bool { !items.isEmpty && !isSubmitting }…What are snapshot tests, and why do they so often fail on CI with a barely visible diff?
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'…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?
struct CheckoutView: View { let total: Decimal var body: some View { VStack { Text(total, format: .currency(code: "EUR"))…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?
// Test targetfunc testCheckoutFromSeededCart() { let app = XCUIApplication() app.launchArguments = ["-uiTesting"] app.launchEnvironment = ["SCENARIO": "signed_in_cart_3"] app.launch()…What does Xcode's code coverage number actually measure, and why can a view model at 87% coverage still ship a bug?
What is TDD, and where does it pay off in iOS development and where does it not?
How do you set up the test jobs of an iOS pipeline: what runs on every pull request and what on merge or nightly?
Turning on parallel testing took the suite from 20 minutes to 6 and made four tests fail at random. What did parallelism change?
// 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…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?
final class CheckoutUITests: XCTestCase { private var app: XCUIApplication! override func setUp() { continueAfterFailure = false app = XCUIApplication()…A 900-line view controller calls
AnalyticsManager.shared,URLSession.shared,UserDefaults.standardandDate(). How do you get it under test this week?// After: seams only, the singletons stay as they areprotocol Analytics { func track(_ event: Event) }extension AnalyticsManager: Analytics {} final class ReceiptPresenter { private let session: URLSession…