Testing
XCTest unit testing, UI testing, and test architecture patterns for reliable iOS applications
You are an expert in testing iOS applications with XCTest and Swift. ## Key Points - **Follow Arrange-Act-Assert (Given-When-Then).** Each test should have clear setup, action, and verification phases. - **Test behavior, not implementation.** Verify what the view model exposes (published state), not how it internally processes data. - **Use dependency injection everywhere.** If a class is hard to test, it probably has hidden dependencies. Make them explicit through init parameters. - **Keep tests fast.** Unit tests should complete in milliseconds. Mock network calls, file I/O, and databases. Reserve real I/O for integration tests. - **One assertion per concept.** Multiple `XCTAssert` calls are fine if they verify the same logical outcome. Avoid testing unrelated behaviors in one method. - **Name tests descriptively.** `testLoadTasks_networkError_showsRetryButton` is better than `testError`. The name should document the scenario. - **Use `@testable import`** to access internal symbols, but never test private methods directly. If private logic needs testing, it may belong in its own type. - **Set up launch arguments for UI tests** to configure mock data, skip onboarding, or disable animations (`UIView.setAnimationsEnabled(false)`). - **Flaky async tests.** Using hardcoded `sleep` or tight timeouts. Prefer expectations, `fulfillment(of:timeout:)`, or `await` for deterministic async tests. - **Testing the framework, not your code.** Do not test that SwiftUI renders a `Text` view. Test that your view model produces the correct string for that `Text`. - **Shared mutable state between tests.** Tests run in undefined order. Always reset state in `setUp` and `tearDown`. Use fresh mock instances per test. - **Ignoring UI test reliability.** UI tests break when accessibility identifiers change or animations interfere. Use stable identifiers and disable animations in test builds.
skilldb get ios-swift-skills/TestingFull skill: 440 linesTesting — iOS/Swift
You are an expert in testing iOS applications with XCTest and Swift.
Core Philosophy
Tests exist to give you confidence that your code works and will keep working as it changes. The most valuable tests verify observable behavior -- given these inputs, the system produces these outputs and side effects. They survive refactors because they test the contract, not the implementation. A test that breaks when you rename a private method or reorder internal logic was testing the wrong thing. Focus tests on what the code does, not how it does it.
The testing pyramid applies to iOS apps: many fast unit tests at the base, a moderate number of integration tests in the middle, and a small number of UI tests at the top. Unit tests should run in milliseconds with no network, no database, and no simulator. They mock or fake at architectural boundaries (repositories, API clients) and verify logic in isolation. UI tests are slow, flaky, and expensive to maintain -- use them sparingly for critical user flows, not for validating business logic.
Dependency injection is the enabler of testable code. If a class creates its own dependencies internally (e.g., let apiClient = APIClient()), you cannot test it without hitting the real network. When dependencies are injected through initializers, tests can provide fakes or mocks that return controlled data, simulate errors, and verify interactions. If a class is hard to test, the design is telling you something: its dependencies are hidden, and making them explicit will improve both testability and architecture.
Anti-Patterns
-
Testing the framework instead of your code: Writing a test that verifies SwiftUI renders a
Textview or that@Publishedtriggers an update is testing Apple's framework, not your logic. Test that your ViewModel produces the correct string, not that SwiftUI displays it. -
Shared mutable state between test methods: Tests that rely on state left behind by a previous test are fragile and order-dependent. XCTest does not guarantee execution order. Always reset state in
setUpandtearDown, using fresh mock instances for each test. -
Using hardcoded sleeps for async tests:
Thread.sleep(forTimeInterval: 2)in a test is a code smell. It makes tests slow when the delay is too long and flaky when it is too short. UseXCTestExpectation,fulfillment(of:timeout:), orawaitfor deterministic async testing. -
Mocking everything: If every dependency is mocked, the test verifies that your code calls methods on mocks in the right order -- not that the system actually works. Use mocks at architectural boundaries (network, database, external services) and real implementations for pure logic like formatters, validators, and model transformations.
-
Not testing error paths: Tests that only cover the happy path catch a fraction of real-world bugs. Network failures, empty responses, malformed data, concurrent access, and user cancellation are normal operating conditions that need test coverage.
Overview
Testing iOS apps involves three layers: unit tests (fast, isolated logic verification), integration tests (multiple components working together), and UI tests (end-to-end through the interface). XCTest is Apple's built-in framework supporting all three. Swift's protocol-oriented design and async/await make tests clean and expressive. The Swift Testing framework (Xcode 16+) offers a modern alternative with @Test macros, but XCTest remains the standard for UI testing and most production codebases.
Core Concepts
XCTest Basics
import XCTest
@testable import MyApp
final class TaskTests: XCTestCase {
// Setup runs before each test method
var sut: TaskListViewModel!
var mockRepository: MockTaskRepository!
override func setUp() {
super.setUp()
mockRepository = MockTaskRepository()
sut = TaskListViewModel(repository: mockRepository)
}
override func tearDown() {
sut = nil
mockRepository = nil
super.tearDown()
}
func testLoadTasks_success_populatesTasks() async {
// Given
let expectedTasks = [
Task(id: UUID(), title: "Buy milk", isCompleted: false),
Task(id: UUID(), title: "Walk dog", isCompleted: true)
]
mockRepository.fetchAllResult = .success(expectedTasks)
// When
await sut.loadTasks()
// Then
XCTAssertEqual(sut.tasks.count, 2)
XCTAssertEqual(sut.tasks.first?.title, "Buy milk")
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.errorMessage)
}
func testLoadTasks_failure_setsErrorMessage() async {
// Given
mockRepository.fetchAllResult = .failure(NetworkError.noConnection)
// When
await sut.loadTasks()
// Then
XCTAssertTrue(sut.tasks.isEmpty)
XCTAssertNotNil(sut.errorMessage)
}
func testToggleCompletion_updatesTask() async {
// Given
let task = Task(id: UUID(), title: "Test", isCompleted: false)
mockRepository.fetchAllResult = .success([task])
await sut.loadTasks()
mockRepository.updateResult = .success(())
// When
await sut.toggleCompletion(task)
// Then
XCTAssertTrue(sut.tasks.first?.isCompleted == true)
XCTAssertEqual(mockRepository.updateCallCount, 1)
}
}
Mock Objects
Create mocks by conforming to repository protocols:
class MockTaskRepository: TaskRepository {
var fetchAllResult: Result<[Task], Error> = .success([])
var updateResult: Result<Void, Error> = .success(())
var deleteResult: Result<Void, Error> = .success(())
var fetchAllCallCount = 0
var updateCallCount = 0
var deleteCallCount = 0
var lastUpdatedTask: Task?
var lastDeletedId: UUID?
func fetchAll() async throws -> [Task] {
fetchAllCallCount += 1
return try fetchAllResult.get()
}
func update(_ task: Task) async throws {
updateCallCount += 1
lastUpdatedTask = task
try updateResult.get()
}
func delete(_ id: UUID) async throws {
deleteCallCount += 1
lastDeletedId = id
try deleteResult.get()
}
}
Testing Async Code
func testSearchDebounces() async {
// Given
let viewModel = SearchViewModel(useCase: mockSearchUseCase)
// When
viewModel.query = "swift"
// Wait for debounce (300ms) + processing
try? await Task.sleep(for: .milliseconds(500))
// Then
XCTAssertEqual(mockSearchUseCase.executeCallCount, 1)
XCTAssertEqual(mockSearchUseCase.lastQuery, "swift")
}
func testConcurrentFetch() async throws {
// Test that concurrent access is safe (actor-based)
let cache = ImageCache()
await withTaskGroup(of: Void.self) { group in
for i in 0..<100 {
group.addTask {
let url = URL(string: "https://example.com/image/\(i)")!
_ = try? await cache.image(for: url)
}
}
}
// No crash = pass (testing thread safety)
}
Testing Combine Publishers
import Combine
func testFormValidation_allFieldsValid_enablesSubmit() {
// Given
let viewModel = RegistrationViewModel()
var cancellables = Set<AnyCancellable>()
let expectation = XCTestExpectation(description: "Form becomes valid")
viewModel.$isFormValid
.dropFirst() // Skip initial false
.filter { $0 == true }
.sink { _ in expectation.fulfill() }
.store(in: &cancellables)
// When
viewModel.username = "validuser"
viewModel.email = "user@example.com"
viewModel.password = "securepass123"
viewModel.confirmPassword = "securepass123"
// Then
wait(for: [expectation], timeout: 2.0)
XCTAssertTrue(viewModel.isFormValid)
}
Implementation Patterns
Testing Network Layer
class MockURLProtocol: URLProtocol {
static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.requestHandler else {
fatalError("No request handler set")
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
final class APIClientTests: XCTestCase {
var session: URLSession!
var client: APIClient!
override func setUp() {
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
session = URLSession(configuration: config)
client = APIClient(baseURL: URL(string: "https://api.test.com")!, session: session)
}
func testFetchUsers_decodesCorrectly() async throws {
// Given
let json = """
[{"id": "1", "name": "Alice"}, {"id": "2", "name": "Bob"}]
""".data(using: .utf8)!
MockURLProtocol.requestHandler = { request in
XCTAssertEqual(request.url?.path, "/users")
let response = HTTPURLResponse(
url: request.url!, statusCode: 200,
httpVersion: nil, headerFields: nil
)!
return (response, json)
}
// When
let users: [User] = try await client.send(Endpoint(path: "/users"))
// Then
XCTAssertEqual(users.count, 2)
XCTAssertEqual(users.first?.name, "Alice")
}
func testFetchUsers_serverError_throws() async {
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(
url: request.url!, statusCode: 500,
httpVersion: nil, headerFields: nil
)!
return (response, Data())
}
do {
let _: [User] = try await client.send(Endpoint(path: "/users"))
XCTFail("Expected error to be thrown")
} catch let error as NetworkError {
if case .httpError(let code, _) = error {
XCTAssertEqual(code, 500)
} else {
XCTFail("Wrong error type: \(error)")
}
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}
UI Testing
final class TaskListUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() {
continueAfterFailure = false
app.launchArguments = ["--uitesting"]
app.launchEnvironment = ["MOCK_DATA": "true"]
app.launch()
}
func testAddTask_appearsInList() {
// Tap add button
app.navigationBars["Tasks"].buttons["Add"].tap()
// Fill in the form
let titleField = app.textFields["Task title"]
titleField.tap()
titleField.typeText("New test task")
// Save
app.buttons["Save"].tap()
// Verify it appears in the list
let cell = app.cells.staticTexts["New test task"]
XCTAssertTrue(cell.waitForExistence(timeout: 3))
}
func testSwipeToDelete() {
let firstCell = app.cells.firstMatch
XCTAssertTrue(firstCell.waitForExistence(timeout: 3))
let cellText = firstCell.staticTexts.firstMatch.label
// Swipe to delete
firstCell.swipeLeft()
app.buttons["Delete"].tap()
// Verify it's gone
let deleted = app.cells.staticTexts[cellText]
XCTAssertFalse(deleted.exists)
}
func testPullToRefresh() {
let firstCell = app.cells.firstMatch
XCTAssertTrue(firstCell.waitForExistence(timeout: 3))
// Pull to refresh
let list = app.tables.firstMatch
let start = list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.3))
let end = list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
start.press(forDuration: 0, thenDragTo: end)
// Verify loading indicator appeared
// (app-specific assertion)
}
}
Snapshot / Preview Testing
Use accessibility identifiers for reliable element queries:
// In production code
struct TaskRow: View {
let task: Task
var body: some View {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.accessibilityIdentifier("task-status-\(task.id)")
Text(task.title)
.accessibilityIdentifier("task-title-\(task.id)")
}
}
}
Test Helpers and Factories
// Factory for test data
enum TaskFactory {
static func make(
id: UUID = UUID(),
title: String = "Test Task",
isCompleted: Bool = false,
dueDate: Date? = nil
) -> Task {
Task(id: id, title: title, isCompleted: isCompleted, dueDate: dueDate)
}
static func makeList(count: Int = 5) -> [Task] {
(0..<count).map { i in
make(title: "Task \(i + 1)", isCompleted: i.isMultiple(of: 2))
}
}
}
// XCTest helper for async expectations
extension XCTestCase {
func awaitPublisher<T: Publisher>(
_ publisher: T,
timeout: TimeInterval = 2,
file: StaticString = #file,
line: UInt = #line
) throws -> T.Output where T.Failure == Never {
var result: T.Output?
let expectation = expectation(description: "Awaiting publisher")
let cancellable = publisher
.first()
.sink { value in
result = value
expectation.fulfill()
}
waitForExpectations(timeout: timeout)
cancellable.cancel()
return try XCTUnwrap(result, "Publisher did not emit a value", file: file, line: line)
}
}
Best Practices
- Follow Arrange-Act-Assert (Given-When-Then). Each test should have clear setup, action, and verification phases.
- Test behavior, not implementation. Verify what the view model exposes (published state), not how it internally processes data.
- Use dependency injection everywhere. If a class is hard to test, it probably has hidden dependencies. Make them explicit through init parameters.
- Keep tests fast. Unit tests should complete in milliseconds. Mock network calls, file I/O, and databases. Reserve real I/O for integration tests.
- One assertion per concept. Multiple
XCTAssertcalls are fine if they verify the same logical outcome. Avoid testing unrelated behaviors in one method. - Name tests descriptively.
testLoadTasks_networkError_showsRetryButtonis better thantestError. The name should document the scenario. - Use
@testable importto access internal symbols, but never test private methods directly. If private logic needs testing, it may belong in its own type. - Set up launch arguments for UI tests to configure mock data, skip onboarding, or disable animations (
UIView.setAnimationsEnabled(false)).
Common Pitfalls
- Flaky async tests. Using hardcoded
sleepor tight timeouts. Prefer expectations,fulfillment(of:timeout:), orawaitfor deterministic async tests. - Testing the framework, not your code. Do not test that SwiftUI renders a
Textview. Test that your view model produces the correct string for thatText. - Shared mutable state between tests. Tests run in undefined order. Always reset state in
setUpandtearDown. Use fresh mock instances per test. - Ignoring UI test reliability. UI tests break when accessibility identifiers change or animations interfere. Use stable identifiers and disable animations in test builds.
- Not testing error paths. Happy-path tests catch only a fraction of bugs. Test network failures, empty states, invalid input, and concurrent access.
- Mocking too much. If every dependency is mocked, the test verifies nothing real. Use mocks at architectural boundaries (network, database) and real implementations for pure logic.
Install this skill directly: skilldb add ios-swift-skills
Related Skills
App Architecture
MVVM and Clean Architecture patterns for structuring scalable, testable iOS applications
Combine
Combine framework reactive programming patterns for handling asynchronous events in iOS
Core Data
Core Data persistence framework for modeling, storing, and querying structured data in iOS apps
Navigation
SwiftUI navigation patterns using NavigationStack, programmatic routing, and deep linking
Networking
URLSession networking patterns for building robust API clients and handling data transfer in iOS
Swift Concurrency
Swift structured concurrency with async/await, actors, and task groups for safe concurrent iOS code