Skip to content

Swift Testing with XCTest — Unit Tests, Async Tests, and Mocking

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Swift Testing with XCTest. We cover key concepts, practical examples, and best practices to help you master this topic.

Swift testing with XCTest provides a comprehensive framework for writing unit tests, performance tests, and UI tests that verify your code behaves correctly across all Apple platforms.

What You'll Learn

  • XCTest framework and test organization
  • Writing unit tests for models and utilities
  • Testing async/await code
  • Mocking dependencies with protocols
  • Performance testing with measure
  • UI testing with XCUITest
  • Test-driven development workflow

Why It Matters

Automated tests catch regressions, document expected behavior, and give you confidence to refactor. Apple's XCTest integrates directly with Xcode and CI systems. Apps with good test coverage have fewer crashes and faster release cycles.

Real-World Use

A banking app has 500+ unit tests for transaction calculation, 50 async tests for network calls, and 30 UI tests that verify the login flow, transfer flow, and statement viewing. Before every release, the test suite runs on CI. A failing test blocks the release, preventing bugs from reaching users.

Learning Path

flowchart LR
  A[SwiftUI Networking
Lesson 30] --> B[Testing
You are here] B --> C[Swift Package Manager
Lesson 32] B --> D[Project: Todo App
Lesson 33] style B fill:#f90,color:#fff

XCTest Basics

Test classes inherit from XCTestCase. Each method starting with test is a test.

import XCTest

struct Calculator {
    func add(_ a: Int, _ b: Int) -> Int { a + b }
    func subtract(_ a: Int, _ b: Int) -> Int { a - b }
    func multiply(_ a: Int, _ b: Int) -> Int { a * b }
    func divide(_ a: Int, _ b: Int) throws -> Int {
        guard b != 0 else { throw CalculationError.divisionByZero }
        return a / b
    }
}

enum CalculationError: Error, CustomStringConvertible {
    case divisionByZero
    var description: String { "Cannot divide by zero" }
}

class CalculatorTests: XCTestCase {
    var calculator: Calculator!

    override func setUpWithError() throws {
        try super.setUpWithError()
        calculator = Calculator()
    }

    override func tearDownWithError() throws {
        calculator = nil
        try super.tearDownWithError()
    }

    func testAddition() {
        XCTAssertEqual(calculator.add(2, 3), 5)
        XCTAssertEqual(calculator.add(-1, 1), 0)
        XCTAssertEqual(calculator.add(0, 0), 0)
    }

    func testSubtraction() {
        XCTAssertEqual(calculator.subtract(10, 3), 7)
        XCTAssertEqual(calculator.subtract(5, 10), -5)
    }

    func testMultiplication() {
        XCTAssertEqual(calculator.multiply(4, 5), 20)
        XCTAssertEqual(calculator.multiply(0, 100), 0)
    }

    func testDivision() throws {
        let result = try calculator.divide(10, 2)
        XCTAssertEqual(result, 5)
    }

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

setUpWithError runs before each test. tearDownWithError runs after. They ensure clean state between tests.

Testing Async Code

XCTest supports async test methods natively.

import XCTest

class AsyncNetworkTests: XCTestCase {

    func testFetchData() async throws {
        let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1")!
        let (data, response) = try await URLSession.shared.data(from: url)

        let httpResponse = try XCTUnwrap(response as? HTTPURLResponse)
        XCTAssertEqual(httpResponse.statusCode, 200)
        XCTAssertFalse(data.isEmpty)
    }

    func testDecoding() async throws {
        let json = """
        {"id": 1, "title": "Test", "body": "Content"}
        """.data(using: .utf8)!

        let decoder = JSONDecoder()
        let post = try decoder.decode(Post.self, from: json)
        XCTAssertEqual(post.id, 1)
        XCTAssertEqual(post.title, "Test")
    }
}

struct Post: Decodable {
    let id: Int
    let title: String
    let body: String
}

Async tests run on a default actor. Use await freely and XCTest handles the concurrency.

Mocking with Protocols

Protocols make it easy to replace real implementations with test doubles.

import XCTest

protocol WeatherServiceProtocol {
    func fetchTemperature(for city: String) async throws -> Double
}

class RealWeatherService: WeatherServiceProtocol {
    func fetchTemperature(for city: String) async throws -> Double {
        let url = URL(string: "https://api.weather.com/\(city)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(Double.self, from: data)
    }
}

class MockWeatherService: WeatherServiceProtocol {
    var mockTemperature: Double = 22.0
    var shouldThrowError = false

    func fetchTemperature(for city: String) async throws -> Double {
        if shouldThrowError {
            throw URLError(.badServerResponse)
        }
        return mockTemperature
    }
}

class WeatherViewModel {
    let service: WeatherServiceProtocol

    init(service: WeatherServiceProtocol) {
        self.service = service
    }

    func displayTemperature(for city: String) async -> String {
        do {
            let temp = try await service.fetchTemperature(for: city)
            return "\(temp)°C"
        } catch {
            return "Error loading"
        }
    }
}

class WeatherViewModelTests: XCTestCase {
    func testSuccess() async {
        let mock = MockWeatherService()
        mock.mockTemperature = 25.0
        let vm = WeatherViewModel(service: mock)

        let result = await vm.displayTemperature(for: "London")
        XCTAssertEqual(result, "25.0°C")
    }

    func testError() async {
        let mock = MockWeatherService()
        mock.shouldThrowError = true
        let vm = WeatherViewModel(service: mock)

        let result = await vm.displayTemperature(for: "London")
        XCTAssertEqual(result, "Error loading")
    }
}

Protocol-based design lets you inject mocks and test each component in isolation.

Testing Expectations

For callback-based code, use XCTestExpectation.

import XCTest

class CallbackTests: XCTestCase {

    func testAsyncCallback() {
        let expectation = XCTestExpectation(description: "Callback called")

        performAsyncOperation { result in
            XCTAssertEqual(result, "Success")
            expectation.fulfill()
        }

        wait(for: [expectation], timeout: 2.0)
    }

    func performAsyncOperation(completion: @escaping (String) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
            completion("Success")
        }
    }
}

If the expectation is not fulfilled within the timeout, the test fails.

Performance Testing

Use measure to track execution time.

import XCTest

class PerformanceTests: XCTestCase {

    func testSortPerformance() {
        let largeArray = (1...10000).map { _ in Int.random(in: 0...100000) }

        measure {
            _ = largeArray.sorted()
        }
    }

    func testStringBuilding() {
        measure {
            var result = ""
            for i in 0..<1000 {
                result += "\(i),"
            }
        }
    }
}

XCActivity records the baseline. Xcode shows if a code change makes it slower.

UI Testing with XCUITest

UI tests simulate user interactions.

import XCTest

class LoginUITests: XCTestCase {
    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testLoginSuccess() {
        let usernameField = app.textFields["username"]
        XCTAssertTrue(usernameField.exists)
        usernameField.tap()
        usernameField.typeText("testuser")

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

        app.buttons["Login"].tap()

        let welcomeLabel = app.staticTexts["welcomeMessage"]
        XCTAssertTrue(welcomeLabel.waitForExistence(timeout: 5))
        XCTAssertEqual(welcomeLabel.label, "Welcome, testuser!")
    }

    func testLoginFailureShowsError() {
        app.buttons["Login"].tap()

        let errorLabel = app.staticTexts["errorMessage"]
        XCTAssertTrue(errorLabel.waitForExistence(timeout: 2))
    }
}

Accessibility identifiers (app.textFields["username"]) make UI tests robust and readable.

Test Organization

Follow the AAA pattern: Arrange, Act, Assert.

func testUserCreation() {
    // Arrange
    let name = "Alice"
    let email = "alice@example.com"
    let service = MockUserService()

    // Act
    let user = service.createUser(name: name, email: email)

    // Assert
    XCTAssertNotNil(user)
    XCTAssertEqual(user?.name, name)
    XCTAssertEqual(user?.email, email)
}

Keep tests focused on one behavior. One assertion per test is ideal but not strict.

Common Mistakes

  1. Testing implementation details: Test behavior (what the code does) not implementation (how it does it). Refactoring should not break tests.

  2. Not cleaning up shared state: Tests that modify global state (UserDefaults, file system) must clean up in tearDown.

  3. Flaky tests due to timing: Async tests with hardcoded delays are unreliable. Use expectations, not sleep.

  4. Testing too much in one test: A test that checks five things fails silently — you only see the first failure. Write focused tests.

  5. Not using mocks for external dependencies: Real network calls in unit tests make tests slow, flaky, and dependent on external services.

Practice Questions

  1. What is the purpose of setUpWithError and tearDownWithError?
  2. How do you test async functions with XCTest?
  3. What is the AAA pattern in testing?
  4. Why should you use mock objects for external dependencies?
  5. Challenge: Write a test suite for a ShoppingCart class with addItem, removeItem, totalPrice, and applyDiscount methods. Include tests for edge cases (empty cart, negative quantities, invalid discount codes).

Mini Project

Create a test suite for a TaskManager app:

  • Unit tests for Task model (validation, date Parsing, priority sorting)
  • Unit tests for TaskViewModel (add task, complete task, delete task, filter)
  • Mock TaskService protocol that the view model uses for persistence
  • Async test for simulated network sync
  • UI test for the main task list screen (add task, mark complete, swipe delete)
  • Performance test for rendering 1000 tasks in a list

FAQ

What is the difference between unit tests and UI tests?

Unit tests verify individual functions and classes in isolation. UI tests simulate user interactions with the full app. Both are important for comprehensive coverage.

How do I run tests from the command line?

Use xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'. This is how CI systems run tests.

What is code coverage?

Code coverage measures which lines of code execute during tests. Aim for 70-80% coverage. Focus on covering critical business logic, not getters and setters.

Can I test SwiftUI views?

Yes. Use XCTest with the app running. For view-specific logic, extract it into a view model and test that. Apple also provides the ViewInspector library for testing view structures.

What is TDD?

Test-Driven Development: write a failing test first, then write the minimum code to make it pass, then refactor. This ensures testability and produces well-designed APIs.

What's Next

After testing, learn dependency management with Swift Package Manager, or build a complete Project: Todo App.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro