Swift Unit Testing Guide: XCTest, Assertions, and Test Structure

Swift Unit Testing Guide: XCTest, Assertions, and Test Structure

Swift's built-in testing framework is XCTest, shipped with Xcode and available on all Apple platforms. Every Swift project created in Xcode includes an XCTest target by default. This guide covers how to write, organize, and run effective unit tests with XCTest.

Project Setup

When you create a new Xcode project, check "Include Tests" to get a test target automatically. For existing projects, add a Unit Test Bundle target via File → New → Target → Unit Testing Bundle.

Test files import XCTest:

import XCTest
@testable import MyApp   // gives access to internal types

@testable import exposes internal declarations to tests without making them public.

Writing a Test Case

Test classes inherit from XCTestCase. Each test method starts with test:

import XCTest
@testable import MyApp

final class CalculatorTests: XCTestCase {

    var calculator: Calculator!

    override func setUp() {
        super.setUp()
        calculator = Calculator()
    }

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

    func testAddition() {
        let result = calculator.add(2, 3)
        XCTAssertEqual(result, 5)
    }

    func testDivisionByZero() {
        XCTAssertThrowsError(try calculator.divide(10, by: 0)) { error in
            XCTAssertEqual(error as? CalculatorError, .divisionByZero)
        }
    }
}

setUp() runs before each test method; tearDown() runs after each test method. Reset state between tests to prevent test interdependence.

Assertion Methods

XCTest provides a comprehensive set of assertions:

// Equality
XCTAssertEqual(a, b)
XCTAssertNotEqual(a, b)

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

// Boolean
XCTAssertTrue(condition)
XCTAssertFalse(condition)

// Comparisons
XCTAssertGreaterThan(a, b)
XCTAssertLessThanOrEqual(a, b)

// Error handling
XCTAssertThrowsError(try expression) { error in
    // inspect error here
}
XCTAssertNoThrow(try expression)

// Optional unwrapping
let value = try XCTUnwrap(optional)  // fails if nil

Add a failure message to any assertion:

XCTAssertEqual(result, expected, "Parsing failed for input: \(input)")

Floating Point Comparison

Use XCTAssertEqual with accuracy for floating point:

XCTAssertEqual(result, 3.14159, accuracy: 0.0001)

Async Testing

Modern Swift uses async/await for asynchronous operations. XCTest supports async test methods directly:

func testFetchUser() async throws {
    let service = UserService()
    let user = try await service.fetchUser(id: 1)
    XCTAssertEqual(user.name, "Alice")
}

No expectation, no waitForExpectations — just async throws on the test method.

Expectation-Based Async (Legacy)

For completion handler-based APIs:

func testAsyncCallback() {
    let expectation = expectation(description: "Fetch completes")

    networkClient.fetchData { result in
        switch result {
        case .success(let data):
            XCTAssertFalse(data.isEmpty)
            expectation.fulfill()
        case .failure(let error):
            XCTFail("Expected success, got error: \(error)")
        }
    }

    waitForExpectations(timeout: 5)
}

setUp and tearDown Variants

XCTest offers both instance and class-level lifecycle methods:

final class DatabaseTests: XCTestCase {

    static var database: Database!

    // Runs once before all tests in this class
    override class func setUp() {
        super.setUp()
        database = Database.openInMemory()
    }

    // Runs once after all tests
    override class func tearDown() {
        database.close()
        database = nil
        super.tearDown()
    }

    // Runs before each test
    override func setUp() async throws {
        try await database.seed()
    }

    // Runs after each test
    override func tearDown() async throws {
        try await database.clear()
    }
}

setUp and tearDown can be async throws — Xcode 14.3+.

Performance Tests

Measure execution time with measure:

func testSortPerformance() {
    let data = Array(0..<10_000).shuffled()

    measure {
        _ = data.sorted()
    }
}

measure runs the block 10 times and reports mean and standard deviation. Xcode stores a baseline — subsequent runs compare against it and fail if they exceed a configurable threshold.

Async Performance

func testFetchPerformance() {
    measure {
        let expectation = expectation(description: "fetch")
        Task {
            _ = try? await api.fetchAllUsers()
            expectation.fulfill()
        }
        wait(for: [expectation], timeout: 10)
    }
}

Test Plans and Configurations

Xcode Test Plans let you run the same tests with different configurations (localization, sanitizers, memory debugging):

  1. File → New → Test Plan
  2. Add test targets
  3. Add configurations: Base, Localization (EN, FR, etc.), Address Sanitizer
  4. Run all configurations in CI: xcodebuild test -testPlan MyPlan

Running Tests

In Xcode

  • ⌘U — run all tests
  • Click the diamond next to a test method — run that test
  • Test Navigator (⌘6) — browse and filter tests

Command Line

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

<span class="hljs-comment"># Run specific test class
xcodebuild <span class="hljs-built_in">test -scheme MyApp \
  -only-testing:MyAppTests/CalculatorTests

<span class="hljs-comment"># Run specific test method
xcodebuild <span class="hljs-built_in">test -scheme MyApp \
  -only-testing:MyAppTests/CalculatorTests/testAddition

Swift Testing Framework (Xcode 16+)

Xcode 16 introduced a new @Test macro-based framework as an alternative to XCTest:

import Testing

@Suite("Calculator Tests")
struct CalculatorTests {

    @Test("Addition returns correct sum")
    func additionTest() {
        let calc = Calculator()
        #expect(calc.add(2, 3) == 5)
    }

    @Test("Division by zero throws", arguments: [0, -0])
    func divisionByZeroTest(divisor: Int) throws {
        let calc = Calculator()
        #expect(throws: CalculatorError.divisionByZero) {
            try calc.divide(10, by: divisor)
        }
    }
}

The new framework uses #expect and #require macros, struct-based test suites, and parameterized tests via arguments:. XCTest and Swift Testing coexist in the same project.

Test Organization Best Practices

Group by feature, not by type. Put UserLoginTests, UserRegistrationTests, UserProfileTests in one folder, not scattered.

Name tests as sentences. testUserCannotLoginWithInvalidPassword reads as a requirement.

One assertion per concept. Multiple XCTAssert calls are fine, but each test should verify one behavior.

Isolate from globals. Any global state mutated by a test must be reset in tearDown. Failing to do so causes non-deterministic test failures.

Production Monitoring

XCTest validates behavior on simulators and devices during development. For continuous verification that your iOS backend APIs and web services work correctly in production, HelpMeTest runs behavioral tests against live endpoints 24/7.

Summary

  • Test classes inherit from XCTestCase; test methods start with test
  • setUp() and tearDown() run before/after each test; static variants run once per class
  • async throws test methods work natively — no expectations needed for async/await code
  • Use accuracy: for floating-point comparisons
  • measure { } for performance baselines tracked by Xcode
  • Xcode 16's @Test macro framework is the future; XCTest remains fully supported

Read more