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 indexWaiting 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 neededExtension 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:
- Open a UI test file
- Click the record button (red circle) at the bottom
- Interact with your app in the simulator
- 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_increasesTotalCI 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.xcresultMonitoring 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:
XCTestCasesubclasses in*Teststarget. Use@testable importto access internal types. - Async tests: Use
async throwson test methods directly. - UI tests:
XCUIApplication+XCUIElement. Always useaccessibilityIdentifier, alwayswaitForExistence. - Gestures:
.tap(),.swipeUp(),.press(forDuration:),.typeText() - CI:
macos-14runner on GitHub Actions,xcodebuild testwith-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.