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 nilAdd 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):
- File → New → Test Plan
- Add test targets
- Add configurations: Base, Localization (EN, FR, etc.), Address Sanitizer
- 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/testAdditionSwift 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 withtest setUp()andtearDown()run before/after each test; static variants run once per classasync throwstest methods work natively — no expectations needed forasync/awaitcode- Use
accuracy:for floating-point comparisons measure { }for performance baselines tracked by Xcode- Xcode 16's
@Testmacro framework is the future; XCTest remains fully supported