XCTest and XCUITest: iOS Testing with Swift and Xcode

XCTest and XCUITest: iOS Testing with Swift and Xcode

Apple's built-in testing framework is XCTest. It handles both unit tests and UI tests — and since Xcode 15, the tooling around it has never been better. If you're developing native iOS apps in Swift, XCTest is the place to start.

This guide covers unit testing with XCTest, UI testing with XCUITest, and how to structure a maintainable iOS test suite.

XCTest vs XCUITest

XCTest — the base framework for unit and integration tests. Tests run in the same process as your app code. Fast, direct access to your types and functions.

XCUITest — extends XCTest for UI automation. Tests run in a separate process and interact with your app through the Accessibility API. Slower but tests real UI behavior.

Use XCTest for:

  • Business logic
  • ViewModel/Presenter logic
  • Networking layer
  • Data parsing and transformation

Use XCUITest for:

  • Critical user flows (login, checkout, onboarding)
  • Navigation between screens
  • Form validation
  • End-to-end scenarios

Setting Up

No package installation needed — XCTest comes with Xcode.

In Xcode: File → New → Target → Unit Testing Bundle (for unit tests) or UI Testing Bundle (for UI tests).

Xcode creates a test target with example tests. Your test files go there.

MyApp/
  MyApp/              ← App source
  MyAppTests/         ← Unit tests (XCTest)
  MyAppUITests/       ← UI tests (XCUITest)

Your First Unit Test

// MyAppTests/CartTests.swift
import XCTest
@testable import MyApp

final class CartTests: XCTestCase {

    var sut: Cart!  // sut = System Under Test

    override func setUp() {
        super.setUp()
        sut = Cart()
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    func test_addItem_increasesTotal() {
        let item = CartItem(id: "1", name: "Widget", price: 9.99, quantity: 2)
        sut.add(item)
        XCTAssertEqual(sut.total, 19.98, accuracy: 0.01)
    }

    func test_removeItem_decreasesTotal() {
        let item = CartItem(id: "1", name: "Widget", price: 9.99, quantity: 1)
        sut.add(item)
        sut.remove(itemId: "1")
        XCTAssertEqual(sut.total, 0.0, accuracy: 0.01)
    }

    func test_emptyCart_totalIsZero() {
        XCTAssertEqual(sut.total, 0.0, accuracy: 0.01)
        XCTAssertTrue(sut.items.isEmpty)
    }

    func test_applyDiscount_reducesTotal() {
        let item = CartItem(id: "1", name: "Widget", price: 100.0, quantity: 1)
        sut.add(item)
        sut.applyDiscount(.percent(20))
        XCTAssertEqual(sut.total, 80.0, accuracy: 0.01)
    }
}

Run tests: ⌘U in Xcode, or from the command line:

xcodebuild test \
  -scheme MyApp \
  -destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 15'

XCTest Assertions

// Equality
XCTAssertEqual(actual, expected)
XCTAssertNotEqual(a, b)
XCTAssertEqual(3.14, result, accuracy: 0.01)  // Floating point

// Boolean
XCTAssertTrue(condition)
XCTAssertFalse(condition)

// Nil checks
XCTAssertNil(value)
XCTAssertNotNil(value)

// Throws
XCTAssertThrowsError(try riskyOperation()) { error in
    XCTAssertEqual(error as? AppError, .invalidInput)
}
XCTAssertNoThrow(try safeOperation())

// Failure with message
XCTAssertEqual(result, expected, "Expected \(expected) but got \(result)")

// Unconditional failure
XCTFail("This code path should not be reached")

Testing ViewModels

With the modern async/await pattern:

// MyAppTests/HomeViewModelTests.swift
import XCTest
@testable import MyApp

@MainActor
final class HomeViewModelTests: XCTestCase {

    var sut: HomeViewModel!
    var mockRepository: MockUserRepository!

    override func setUp() {
        super.setUp()
        mockRepository = MockUserRepository()
        sut = HomeViewModel(repository: mockRepository)
    }

    func test_loadUsers_updatesStateToLoaded() async throws {
        // Given
        let expectedUsers = [User(id: "1", name: "Alice"), User(id: "2", name: "Bob")]
        mockRepository.stubbedUsers = expectedUsers

        // When
        await sut.loadUsers()

        // Then
        guard case .loaded(let users) = sut.state else {
            XCTFail("Expected loaded state, got \(sut.state)")
            return
        }
        XCTAssertEqual(users.count, 2)
        XCTAssertEqual(users[0].name, "Alice")
    }

    func test_loadUsers_onError_updatesStateToFailed() async {
        // Given
        mockRepository.shouldThrow = true

        // When
        await sut.loadUsers()

        // Then
        guard case .failed = sut.state else {
            XCTFail("Expected failed state")
            return
        }
    }
}

// Mock
final class MockUserRepository: UserRepositoryProtocol {
    var stubbedUsers: [User] = []
    var shouldThrow = false

    func fetchUsers() async throws -> [User] {
        if shouldThrow { throw AppError.networkError }
        return stubbedUsers
    }
}

Testing Async Code

Use async/await directly in test methods:

func test_fetchData_returnsExpectedResult() async throws {
    let result = try await sut.fetchData()
    XCTAssertFalse(result.isEmpty)
}

For Combine publishers, use XCTestExpectation:

func test_publisherEmitsCorrectValues() {
    let expectation = expectation(description: "Publisher emits value")
    var receivedValues: [Int] = []
    var cancellables = Set<AnyCancellable>()

    sut.valuePublisher
        .sink { value in
            receivedValues.append(value)
            if receivedValues.count == 3 {
                expectation.fulfill()
            }
        }
        .store(in: &cancellables)

    sut.emitValues()

    waitForExpectations(timeout: 2.0)
    XCTAssertEqual(receivedValues, [1, 2, 3])
}

UI Testing with XCUITest

XCUITest controls your app through the Accessibility API. The test runs in a separate process.

// MyAppUITests/LoginUITests.swift
import XCTest

final class LoginUITests: XCTestCase {

    var app: XCUIApplication!

    override func setUp() {
        super.setUp()
        continueAfterFailure = false
        app = XCUIApplication()
        app.launchArguments = ["--uitesting"]  // Tell app it's being tested
        app.launch()
    }

    func test_successfulLogin_showsHomeScreen() {
        // Enter email
        let emailField = app.textFields["emailTextField"]
        emailField.tap()
        emailField.typeText("user@example.com")

        // Enter password
        let passwordField = app.secureTextFields["passwordTextField"]
        passwordField.tap()
        passwordField.typeText("password123")

        // Tap login
        app.buttons["loginButton"].tap()

        // Verify home screen appears
        let homeScreen = app.staticTexts["Welcome back!"]
        XCTAssertTrue(homeScreen.waitForExistence(timeout: 5))
    }

    func test_emptyEmail_showsValidationError() {
        app.buttons["loginButton"].tap()

        let error = app.staticTexts["Email is required"]
        XCTAssertTrue(error.waitForExistence(timeout: 2))
    }
}

Accessibility Identifiers

Set accessibilityIdentifier in your app code to make elements reliably findable:

// In your SwiftUI view
TextField("Email", text: $email)
    .accessibilityIdentifier("emailTextField")

Button("Login") { login() }
    .accessibilityIdentifier("loginButton")

// In UIKit
emailTextField.accessibilityIdentifier = "emailTextField"
loginButton.accessibilityIdentifier = "loginButton"

In tests:

// SwiftUI (use .accessibilityIdentifier)
let field = app.textFields["emailTextField"]

// UIKit (same)
let field = app.textFields["emailTextField"]

XCUIElement Queries

let app = XCUIApplication()

// By accessibility identifier
app.buttons["submitButton"]
app.textFields["emailField"]
app.staticTexts["pageTitle"]
app.images["profilePicture"]
app.cells["tableCell"]

// By label (visible text)
app.buttons["Submit"]
app.staticTexts["Welcome"]

// Query with predicate
let button = app.buttons.matching(
    NSPredicate(format: "label CONTAINS 'Login'")
).firstMatch

// All elements of a type
let allButtons = app.buttons.allElementsBoundByIndex

// First/last
app.cells.firstMatch
app.cells.element(boundBy: 0)  // by index

Waiting for Elements

Always use waitForExistence — UI changes take time:

// Wait up to 5 seconds for an element to appear
let element = app.buttons["submitButton"]
XCTAssertTrue(element.waitForExistence(timeout: 5))

// Wait for element to disappear
let spinner = app.activityIndicators["loadingSpinner"]
let disappeared = spinner.waitForNonExistence(timeout: 10)
XCTAssertTrue(disappeared, "Loading spinner should have disappeared")

Gestures in XCUITest

// Tap
app.buttons["submitButton"].tap()
app.buttons["submitButton"].doubleTap()

// Long press
app.cells.firstMatch.press(forDuration: 1.5)

// Swipe
app.scrollViews.firstMatch.swipeUp()
app.scrollViews.firstMatch.swipeLeft()

// Pinch/Zoom
app.images["mapView"].pinch(withScale: 2.0, velocity: 1.5)

// Drag
let source = app.cells.element(boundBy: 0)
let target = app.cells.element(boundBy: 3)
source.press(forDuration: 0.5, thenDragTo: target)

// Type text
let field = app.textFields["searchField"]
field.tap()
field.typeText("hello world")

// Clear and type
field.clearAndEnterText("new text")  // Custom extension needed

Extension for clearing text:

extension XCUIElement {
    func clearAndEnterText(_ text: String) {
        guard let stringValue = self.value as? String else {
            tap()
            typeText(text)
            return
        }
        tap()
        let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count)
        typeText(deleteString)
        typeText(text)
    }
}

Test Launch Arguments

Pass flags to your app to configure it for testing:

// In the test
app.launchArguments = ["--uitesting", "--reset-data"]
app.launchEnvironment = ["API_BASE_URL": "http://localhost:8080"]
app.launch()
// In your app's AppDelegate or @main
if CommandLine.arguments.contains("--uitesting") {
    // Use in-memory storage, mock API, etc.
    UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!)
}

Using the Test Recorder

Xcode's UI Test Recorder generates XCUITest code from your interactions:

  1. Open a UI test file
  2. Click the record button (red circle) at the bottom
  3. Interact with your app in the simulator
  4. Xcode generates code for each action

The generated code is verbose and often uses coordinate-based taps. Refactor to use accessibilityIdentifier selectors before committing.

XCTest Performance Tests

Measure code performance:

func test_sortingPerformance() {
    let items = (0..<1000).map { CartItem(id: "\($0)", name: "Item \($0)", price: Double($0)) }
    
    measure {
        _ = items.sorted { $0.price < $1.price }
    }
}

The measure block runs 10 times and reports average, standard deviation, and baseline comparisons.

Running Tests from Command Line

# All tests
xcodebuild <span class="hljs-built_in">test \
  -scheme MyApp \
  -destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 15,OS=17.0'

<span class="hljs-comment"># Specific test class
xcodebuild <span class="hljs-built_in">test \
  -scheme MyApp \
  -destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 15' \
  -only-testing:MyAppTests/CartTests

<span class="hljs-comment"># Specific test method
xcodebuild <span class="hljs-built_in">test \
  -scheme MyApp \
  -destination <span class="hljs-string">'platform=iOS Simulator,name=iPhone 15' \
  -only-testing:MyAppTests/CartTests/test_addItem_increasesTotal

CI with GitHub Actions

name: iOS Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: macos-14

    steps:
      - uses: actions/checkout@v4
      
      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_15.2.app
      
      - name: Run unit tests
        run: |
          xcodebuild test \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.2' \
            -resultBundlePath TestResults.xcresult \
            | xcpretty
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: TestResults.xcresult

Monitoring After Ship

XCUITest verifies flows in your simulator. After you ship, users run your app on real devices with real network conditions and real backend states. Flows that pass locally can break in production.

HelpMeTest runs scheduled checks against your live app — catching regressions between releases without maintaining device labs or simulators in CI.

Summary

  • Unit tests: XCTestCase subclasses in *Tests target. Use @testable import to access internal types.
  • Async tests: Use async throws on test methods directly.
  • UI tests: XCUIApplication + XCUIElement. Always use accessibilityIdentifier, always waitForExistence.
  • Gestures: .tap(), .swipeUp(), .press(forDuration:), .typeText()
  • CI: macos-14 runner on GitHub Actions, xcodebuild test with -destination
  • Performance: Wrap code in measure { } blocks

XCTest is fully integrated into Xcode — no dependencies, no configuration. Start with unit tests for your business logic, then add UI tests for your most critical user journeys.

Read more