iOS Testing Best Practices
Target >85% code coverage with >90% coverage for critical paths. Unit tests should run in <1 second total. Integration tests in <5 seconds. UI tests in <30 seconds. Zero tolerance for flaky tests - tests must be deterministic and pass 100% of the time when code is correct.
Overview
This guide covers iOS testing best practices using XCTest for unit tests, ViewInspector for SwiftUI testing, mock networking strategies, Core Data testing patterns, and UI testing with XCUITest. It complements iOS Overview, iOS UI Development, and iOS Data & Networking with comprehensive testing strategies.
Core Principles
- Testing Pyramid: Unit tests (60-70%), Integration tests (20-30%), UI tests (<10%)
- Fast Feedback: Unit tests run in <1s, integration tests <5s, UI tests <30s
- Isolated Tests: Each test is independent and can run in any order
- Readable Tests: Given-When-Then structure for clarity
- Mock External Dependencies: Network, database, system services
- Test Behavior, Not Implementation: Focus on outputs, not internal state
- Continuous Integration: All tests run on every commit
Testing Strategy Overview
The testing pyramid recommends 60-70% unit tests, 20-30% integration tests, and <10% UI tests. Unit tests run in <1 second total and provide immediate feedback during development. Integration tests verify that modules work together (e.g., repository + DAO + Core Data) and run in <5 seconds. UI tests launch the app, interact with the UI, and verify end-to-end flows - they're slow (30+ seconds) and brittle (break when UI changes). The pyramid shape reflects both speed and quantity: you want many fast unit tests that run on every file save, fewer slower integration tests that run before each commit, and minimal UI tests that run in CI before merging.
XCTest Setup
Test Target Configuration
// BankingAppTests/BankingAppTests.swift
import XCTest
@testable import BankingApp
final class BankingAppTests: XCTestCase {
override func setUpWithError() throws {
// Called before each test
try super.setUpWithError()
continueAfterFailure = false
}
override func tearDownWithError() throws {
// Called after each test
try super.tearDownWithError()
}
func testExample() throws {
// Given
let value = 1
// When
let result = value + 1
// Then
XCTAssertEqual(result, 2)
}
func testPerformanceExample() throws {
measure {
// Code to measure performance
_ = (1...1000).reduce(0, +)
}
}
}
ViewModel Testing
Testing Async ViewModel
// BankingAppTests/Presentation/PaymentListViewModelTests.swift
import XCTest
@testable import BankingApp
@MainActor
final class PaymentListViewModelTests: XCTestCase {
// MARK: - Properties
var viewModel: PaymentListViewModel!
var mockUseCase: MockGetPaymentsUseCase!
// MARK: - Setup & Teardown
override func setUp() {
super.setUp()
mockUseCase = MockGetPaymentsUseCase()
viewModel = PaymentListViewModel(getPaymentsUseCase: mockUseCase)
}
override func tearDown() {
viewModel = nil
mockUseCase = nil
super.tearDown()
}
// MARK: - Tests
func testLoadPayments_Success() async throws {
// Given
let expectedPayments = [
Payment.mock(id: "1", recipientName: "John Doe", amount: 100),
Payment.mock(id: "2", recipientName: "Jane Smith", amount: 200)
]
mockUseCase.result = .success(expectedPayments)
// When
await viewModel.loadPayments()
// Then
guard case .loaded(let payments) = viewModel.state else {
XCTFail("Expected loaded state, got \(viewModel.state)")
return
}
XCTAssertEqual(payments.count, 2)
XCTAssertEqual(payments[0].recipientName, "John Doe")
XCTAssertEqual(payments[1].recipientName, "Jane Smith")
XCTAssertEqual(mockUseCase.executeCallCount, 1)
}
func testLoadPayments_Failure() async throws {
// Given
let expectedError = NetworkError.serverError
mockUseCase.result = .failure(expectedError)
// When
await viewModel.loadPayments()
// Then
guard case .error(let message) = viewModel.state else {
XCTFail("Expected error state, got \(viewModel.state)")
return
}
XCTAssertEqual(message, expectedError.localizedDescription)
XCTAssertEqual(mockUseCase.executeCallCount, 1)
}
func testLoadPayments_EmptyList() async throws {
// Given
mockUseCase.result = .success([])
// When
await viewModel.loadPayments()
// Then
guard case .loaded(let payments) = viewModel.state else {
XCTFail("Expected loaded state, got \(viewModel.state)")
return
}
XCTAssertTrue(payments.isEmpty)
}
func testRefresh_UpdatesExistingPayments() async throws {
// Given - Initial load
let initialPayments = [Payment.mock(id: "1", recipientName: "John", amount: 100)]
mockUseCase.result = .success(initialPayments)
await viewModel.loadPayments()
// When - Refresh with new data
let refreshedPayments = [
Payment.mock(id: "1", recipientName: "John", amount: 100),
Payment.mock(id: "2", recipientName: "Jane", amount: 200)
]
mockUseCase.result = .success(refreshedPayments)
await viewModel.refresh()
// Then
guard case .loaded(let payments) = viewModel.state else {
XCTFail("Expected loaded state after refresh")
return
}
XCTAssertEqual(payments.count, 2)
XCTAssertEqual(mockUseCase.executeCallCount, 2)
}
func testInitialState_IsLoading() {
// Given & When
let freshViewModel = PaymentListViewModel(getPaymentsUseCase: mockUseCase)
// Then
if case .loading = freshViewModel.state {
// Success
} else {
XCTFail("Expected initial state to be loading")
}
}
}
// MARK: - Mock Use Case
final class MockGetPaymentsUseCase: GetPaymentsUseCase {
var result: Result<[Payment], Error> = .success([])
var executeCallCount = 0
func execute() async throws -> [Payment] {
executeCallCount += 1
switch result {
case .success(let payments):
return payments
case .failure(let error):
throw error
}
}
}
// MARK: - Test Helpers
extension Payment {
static func mock(
id: String = UUID().uuidString,
accountId: String = "ACC-123",
recipientName: String = "Test Recipient",
recipientAccountNumber: String = "1234567890",
amount: Decimal = 100.00,
currency: String = "USD",
status: PaymentStatus = .completed,
description: String? = nil,
reference: String = "REF-123",
createdAt: Date = Date(),
completedAt: Date? = nil
) -> Payment {
Payment(
id: id,
accountId: accountId,
recipientName: recipientName,
recipientAccountNumber: recipientAccountNumber,
amount: amount,
currency: currency,
status: status,
description: description,
reference: reference,
createdAt: createdAt,
completedAt: completedAt
)
}
}
SwiftUI View Testing with ViewInspector
Setup ViewInspector
// Package.swift or SPM dependencies
dependencies: [
.package(url: "https://github.com/nalexn/ViewInspector.git", from: "0.9.0")
]
// Or CocoaPods
pod 'ViewInspector'
Testing SwiftUI Views
// BankingAppTests/Presentation/PaymentCardTests.swift
import XCTest
import SwiftUI
import ViewInspector
@testable import BankingApp
final class PaymentCardTests: XCTestCase {
func testPaymentCard_DisplaysRecipientName() throws {
// Given
let payment = Payment.mock(recipientName: "John Doe")
// When
let view = PaymentCard(payment: payment)
// Then
let text = try view.inspect().find(text: "John Doe")
XCTAssertNotNil(text)
}
func testPaymentCard_DisplaysFormattedAmount() throws {
// Given
let payment = Payment.mock(amount: 1234.56, currency: "USD")
// When
let view = PaymentCard(payment: payment)
// Then
let text = try view.inspect().find(text: "$1,234.56")
XCTAssertNotNil(text)
}
func testPaymentCard_DisplaysStatusBadge() throws {
// Given
let payment = Payment.mock(status: .completed)
// When
let view = PaymentCard(payment: payment)
// Then
let badge = try view.inspect().find(text: "Completed")
XCTAssertNotNil(badge)
}
func testPaymentCard_StatusColors() throws {
// Test completed status (green)
let completedPayment = Payment.mock(status: .completed)
let completedView = PaymentCard(payment: completedPayment)
let completedBadge = try completedView.inspect().find(text: "Completed")
// ViewInspector can check foreground color
XCTAssertNotNil(completedBadge)
// Test pending status (orange)
let pendingPayment = Payment.mock(status: .pending)
let pendingView = PaymentCard(payment: pendingPayment)
let pendingBadge = try pendingView.inspect().find(text: "Pending")
XCTAssertNotNil(pendingBadge)
// Test failed status (red)
let failedPayment = Payment.mock(status: .failed)
let failedView = PaymentCard(payment: failedPayment)
let failedBadge = try failedView.inspect().find(text: "Failed")
XCTAssertNotNil(failedBadge)
}
func testPaymentCard_OptionalDescription() throws {
// Test with description
let paymentWithDesc = Payment.mock(description: "Monthly payment")
let viewWithDesc = PaymentCard(payment: paymentWithDesc)
let descText = try viewWithDesc.inspect().find(text: "Monthly payment")
XCTAssertNotNil(descText)
// Test without description
let paymentNoDesc = Payment.mock(description: nil)
let viewNoDesc = PaymentCard(payment: paymentNoDesc)
XCTAssertThrowsError(try viewNoDesc.inspect().find(text: "Monthly payment"))
}
func testPaymentCard_DateFormatting() throws {
// Given
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
let date = dateFormatter.date(from: "2024-10-29 14:30:00")!
let payment = Payment.mock(createdAt: date)
// When
let view = PaymentCard(payment: payment)
// Then - Check that date is displayed (format may vary)
let vstack = try view.inspect().vStack()
XCTAssertGreaterThan(try vstack.text(2).string().count, 0)
}
}
Testing View State Changes
// BankingAppTests/Presentation/PaymentListViewTests.swift
import XCTest
import SwiftUI
import ViewInspector
@testable import BankingApp
final class PaymentListViewTests: XCTestCase {
func testPaymentListView_LoadingState() throws {
// Given
let mockViewModel = MockPaymentListViewModel()
mockViewModel.state = .loading
// When
let view = PaymentListView(viewModel: mockViewModel)
// Then
let progressView = try view.inspect().find(ViewType.ProgressView.self)
XCTAssertNotNil(progressView)
}
func testPaymentListView_LoadedState() throws {
// Given
let mockViewModel = MockPaymentListViewModel()
let payments = [
Payment.mock(id: "1", recipientName: "John"),
Payment.mock(id: "2", recipientName: "Jane")
]
mockViewModel.state = .loaded(payments)
// When
let view = PaymentListView(viewModel: mockViewModel)
// Then
let list = try view.inspect().find(ViewType.List.self)
XCTAssertNotNil(list)
}
func testPaymentListView_ErrorState() throws {
// Given
let mockViewModel = MockPaymentListViewModel()
mockViewModel.state = .error("Network error")
// When
let view = PaymentListView(viewModel: mockViewModel)
// Then
let errorText = try view.inspect().find(text: "Error")
XCTAssertNotNil(errorText)
let messageText = try view.inspect().find(text: "Network error")
XCTAssertNotNil(messageText)
}
func testPaymentListView_EmptyState() throws {
// Given
let mockViewModel = MockPaymentListViewModel()
mockViewModel.state = .loaded([])
// When
let view = PaymentListView(viewModel: mockViewModel)
// Then
let emptyMessage = try view.inspect().find(text: "No payments yet")
XCTAssertNotNil(emptyMessage)
}
}
// MARK: - Mock ViewModel for View Testing
@MainActor
final class MockPaymentListViewModel: ObservableObject {
@Published var state: PaymentListState = .loading
func loadPayments() async {
// Mock implementation
}
func refresh() async {
// Mock implementation
}
}
Mock Networking
Mock Network Manager
// BankingAppTests/Core/Network/MockNetworkManager.swift
import Foundation
@testable import BankingApp
final class MockNetworkManager: NetworkManagerProtocol {
var requestCallCount = 0
var lastEndpoint: APIEndpoint?
// Configure responses
var responses: [String: Any] = [:]
var errors: [String: Error] = [:]
func request<T: Decodable>(_ endpoint: APIEndpoint) async throws -> T {
requestCallCount += 1
lastEndpoint = endpoint
let key = endpointKey(for: endpoint)
// Check for configured error
if let error = errors[key] {
throw error
}
// Return configured response
guard let response = responses[key] as? T else {
throw NetworkError.decodingError(
NSError(domain: "Mock", code: 0, userInfo: [NSLocalizedDescriptionKey: "No mock response configured"])
)
}
return response
}
func request(_ endpoint: APIEndpoint) async throws {
requestCallCount += 1
lastEndpoint = endpoint
let key = endpointKey(for: endpoint)
if let error = errors[key] {
throw error
}
}
// MARK: - Helpers
private func endpointKey(for endpoint: APIEndpoint) -> String {
"\(endpoint.method.rawValue)_\(endpoint.path)"
}
func setResponse<T: Encodable>(_ response: T, for endpoint: APIEndpoint) {
let key = endpointKey(for: endpoint)
responses[key] = response
}
func setError(_ error: Error, for endpoint: APIEndpoint) {
let key = endpointKey(for: endpoint)
errors[key] = error
}
func reset() {
requestCallCount = 0
lastEndpoint = nil
responses.removeAll()
errors.removeAll()
}
}
Testing with Mock Network
// BankingAppTests/Data/Repositories/PaymentRepositoryTests.swift
import XCTest
@testable import BankingApp
final class PaymentRepositoryTests: XCTestCase {
var repository: PaymentRepositoryImpl!
var mockNetworkManager: MockNetworkManager!
var mockDAO: MockPaymentDAO!
override func setUp() {
super.setUp()
mockNetworkManager = MockNetworkManager()
mockDAO = MockPaymentDAO()
repository = PaymentRepositoryImpl(
networkManager: mockNetworkManager,
paymentDAO: mockDAO
)
}
override func tearDown() {
repository = nil
mockNetworkManager = nil
mockDAO = nil
super.tearDown()
}
func testGetPayments_Success() async throws {
// Given
let paymentDTOs = [
PaymentDTO.mock(id: "1", recipientName: "John Doe", amount: "100.00"),
PaymentDTO.mock(id: "2", recipientName: "Jane Smith", amount: "200.00")
]
mockNetworkManager.setResponse(paymentDTOs, for: PaymentEndpoint.getPayments)
// When
let payments = try await repository.getPayments()
// Then
XCTAssertEqual(payments.count, 2)
XCTAssertEqual(payments[0].recipientName, "John Doe")
XCTAssertEqual(payments[1].recipientName, "Jane Smith")
XCTAssertEqual(mockNetworkManager.requestCallCount, 1)
}
func testGetPayments_NetworkError() async throws {
// Given
mockNetworkManager.setError(
NetworkError.serverError,
for: PaymentEndpoint.getPayments
)
// When & Then
do {
_ = try await repository.getPayments()
XCTFail("Expected error to be thrown")
} catch let error as NetworkError {
if case .serverError = error {
// Success
} else {
XCTFail("Expected serverError, got \(error)")
}
}
}
func testCreatePayment_Success() async throws {
// Given
let request = CreatePaymentRequest(
accountId: "ACC-123",
recipientName: "John Doe",
recipientAccountNumber: "1234567890",
amount: "150.00",
currency: "USD",
description: "Test payment"
)
let responseDTO = PaymentDTO.mock(
id: "PAY-NEW",
recipientName: "John Doe",
amount: "150.00"
)
mockNetworkManager.setResponse(responseDTO, for: PaymentEndpoint.createPayment(request))
// When
let payment = try await repository.createPayment(request)
// Then
XCTAssertEqual(payment.id, "PAY-NEW")
XCTAssertEqual(payment.recipientName, "John Doe")
XCTAssertEqual(payment.amount, 150.00)
XCTAssertEqual(mockDAO.saveCallCount, 1)
}
func testCachePayments_AfterNetworkFetch() async throws {
// Given
let paymentDTOs = [PaymentDTO.mock(id: "1", recipientName: "John", amount: "100")]
mockNetworkManager.setResponse(paymentDTOs, for: PaymentEndpoint.getPayments)
// When
_ = try await repository.getPayments()
// Then
XCTAssertEqual(mockDAO.saveAllCallCount, 1)
XCTAssertEqual(mockDAO.savedPayments.count, 1)
}
}
// MARK: - Mock DAO
final class MockPaymentDAO: PaymentDAOProtocol {
var savedPayments: [Payment] = []
var paymentsToReturn: [Payment] = []
var getAllCallCount = 0
var getByIdCallCount = 0
var saveCallCount = 0
var saveAllCallCount = 0
var deleteCallCount = 0
var deleteAllCallCount = 0
func getAll() async throws -> [Payment] {
getAllCallCount += 1
return paymentsToReturn
}
func getById(_ id: String) async throws -> Payment? {
getByIdCallCount += 1
return paymentsToReturn.first { $0.id == id }
}
func save(_ payment: Payment) async throws {
saveCallCount += 1
savedPayments.append(payment)
}
func saveAll(_ payments: [Payment]) async throws {
saveAllCallCount += 1
savedPayments.append(contentsOf: payments)
}
func delete(_ id: String) async throws {
deleteCallCount += 1
savedPayments.removeAll { $0.id == id }
}
func deleteAll() async throws {
deleteAllCallCount += 1
savedPayments.removeAll()
}
}
// MARK: - PaymentDTO Mock
extension PaymentDTO {
static func mock(
id: String = UUID().uuidString,
accountId: String = "ACC-123",
recipientName: String = "Test Recipient",
recipientAccountNumber: String = "1234567890",
amount: String = "100.00",
currency: String = "USD",
status: String = "completed",
description: String? = nil,
reference: String = "REF-123",
createdAt: String = ISO8601DateFormatter().string(from: Date()),
completedAt: String? = nil
) -> PaymentDTO {
PaymentDTO(
id: id,
accountId: accountId,
recipientName: recipientName,
recipientAccountNumber: recipientAccountNumber,
amount: amount,
currency: currency,
status: status,
description: description,
reference: reference,
createdAt: createdAt,
completedAt: completedAt
)
}
}
Core Data Testing
In-Memory Core Data Stack for Testing
// BankingAppTests/Core/Storage/TestCoreDataStack.swift
import CoreData
@testable import BankingApp
final class TestCoreDataStack {
static func createInMemoryStack() -> NSPersistentContainer {
let container = NSPersistentContainer(name: "BankingApp")
let description = NSPersistentStoreDescription()
description.type = NSInMemoryStoreType
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { description, error in
if let error = error {
fatalError("Failed to load in-memory store: \(error)")
}
}
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
return container
}
}
Testing Core Data Operations
// BankingAppTests/Data/Local/PaymentDAOTests.swift
import XCTest
import CoreData
@testable import BankingApp
final class PaymentDAOTests: XCTestCase {
var container: NSPersistentContainer!
var dao: PaymentDAO!
override func setUp() {
super.setUp()
container = TestCoreDataStack.createInMemoryStack()
dao = PaymentDAO(context: container.viewContext)
}
override func tearDown() {
container = nil
dao = nil
super.tearDown()
}
func testSave_SinglePayment() async throws {
// Given
let payment = Payment.mock(id: "PAY-1", recipientName: "John Doe")
// When
try await dao.save(payment)
// Then
let savedPayments = try await dao.getAll()
XCTAssertEqual(savedPayments.count, 1)
XCTAssertEqual(savedPayments[0].id, "PAY-1")
XCTAssertEqual(savedPayments[0].recipientName, "John Doe")
}
func testSaveAll_MultiplePayments() async throws {
// Given
let payments = [
Payment.mock(id: "1", recipientName: "John"),
Payment.mock(id: "2", recipientName: "Jane"),
Payment.mock(id: "3", recipientName: "Bob")
]
// When
try await dao.saveAll(payments)
// Then
let savedPayments = try await dao.getAll()
XCTAssertEqual(savedPayments.count, 3)
}
func testGetById_ExistingPayment() async throws {
// Given
let payment = Payment.mock(id: "PAY-123", recipientName: "John")
try await dao.save(payment)
// When
let retrieved = try await dao.getById("PAY-123")
// Then
XCTAssertNotNil(retrieved)
XCTAssertEqual(retrieved?.id, "PAY-123")
XCTAssertEqual(retrieved?.recipientName, "John")
}
func testGetById_NonExistingPayment() async throws {
// When
let retrieved = try await dao.getById("NON-EXISTENT")
// Then
XCTAssertNil(retrieved)
}
func testDelete_RemovesPayment() async throws {
// Given
let payment = Payment.mock(id: "PAY-DEL")
try await dao.save(payment)
// Verify it was saved
var payments = try await dao.getAll()
XCTAssertEqual(payments.count, 1)
// When
try await dao.delete("PAY-DEL")
// Then
payments = try await dao.getAll()
XCTAssertEqual(payments.count, 0)
}
func testDeleteAll_RemovesAllPayments() async throws {
// Given
let payments = [
Payment.mock(id: "1"),
Payment.mock(id: "2"),
Payment.mock(id: "3")
]
try await dao.saveAll(payments)
// Verify saved
var savedPayments = try await dao.getAll()
XCTAssertEqual(savedPayments.count, 3)
// When
try await dao.deleteAll()
// Then
savedPayments = try await dao.getAll()
XCTAssertEqual(savedPayments.count, 0)
}
func testUpdate_ExistingPayment() async throws {
// Given - Save initial payment
let initialPayment = Payment.mock(id: "PAY-1", recipientName: "John", amount: 100)
try await dao.save(initialPayment)
// When - Update payment with same ID
let updatedPayment = Payment.mock(id: "PAY-1", recipientName: "John Updated", amount: 200)
try await dao.save(updatedPayment)
// Then
let savedPayments = try await dao.getAll()
XCTAssertEqual(savedPayments.count, 1) // Only one payment (updated, not duplicated)
XCTAssertEqual(savedPayments[0].recipientName, "John Updated")
XCTAssertEqual(savedPayments[0].amount, 200)
}
func testGetAll_SortedByCreatedDate() async throws {
// Given
let now = Date()
let payments = [
Payment.mock(id: "1", createdAt: now.addingTimeInterval(-3600)), // 1 hour ago
Payment.mock(id: "2", createdAt: now.addingTimeInterval(-7200)), // 2 hours ago
Payment.mock(id: "3", createdAt: now) // Now
]
try await dao.saveAll(payments)
// When
let retrievedPayments = try await dao.getAll()
// Then - Should be sorted newest first
XCTAssertEqual(retrievedPayments[0].id, "3")
XCTAssertEqual(retrievedPayments[1].id, "1")
XCTAssertEqual(retrievedPayments[2].id, "2")
}
}
UI Testing with XCUITest
Basic UI Test
// BankingAppUITests/PaymentFlowUITests.swift
import XCTest
final class PaymentFlowUITests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["UI-Testing"]
app.launch()
}
override func tearDownWithError() throws {
app = nil
}
func testPaymentList_DisplaysPayments() throws {
// Given - App is launched and showing payment list
// Then
XCTAssertTrue(app.navigationBars["Payments"].exists)
// Check if payment cards are visible
let paymentCard = app.staticTexts["John Doe"]
XCTAssertTrue(paymentCard.waitForExistence(timeout: 5))
}
func testPaymentList_TapPayment_ShowsDetail() throws {
// Given
let paymentCard = app.staticTexts["John Doe"]
XCTAssertTrue(paymentCard.waitForExistence(timeout: 5))
// When
paymentCard.tap()
// Then - Detail view appears
XCTAssertTrue(app.navigationBars["Payment Details"].waitForExistence(timeout: 2))
XCTAssertTrue(app.staticTexts["John Doe"].exists)
XCTAssertTrue(app.staticTexts["$100.00"].exists)
}
func testCreatePayment_ValidData_Success() throws {
// Given
let createButton = app.buttons["plus.circle.fill"]
XCTAssertTrue(createButton.waitForExistence(timeout: 5))
// When
createButton.tap()
// Fill form
let recipientField = app.textFields["Recipient Name"]
XCTAssertTrue(recipientField.waitForExistence(timeout: 2))
recipientField.tap()
recipientField.typeText("Jane Smith")
let accountField = app.textFields["Account Number"]
accountField.tap()
accountField.typeText("1234567890")
let amountField = app.textFields["Amount"]
amountField.tap()
amountField.typeText("250.00")
let submitButton = app.buttons["Create Payment"]
submitButton.tap()
// Then
let successMessage = app.staticTexts["Payment created successfully"]
XCTAssertTrue(successMessage.waitForExistence(timeout: 5))
}
func testCreatePayment_InvalidAmount_ShowsError() throws {
// Given
app.buttons["plus.circle.fill"].tap()
// When - Enter invalid amount
let amountField = app.textFields["Amount"]
XCTAssertTrue(amountField.waitForExistence(timeout: 2))
amountField.tap()
amountField.typeText("-100")
app.buttons["Create Payment"].tap()
// Then
let errorMessage = app.staticTexts["Amount must be greater than zero"]
XCTAssertTrue(errorMessage.waitForExistence(timeout: 2))
}
func testPaymentList_PullToRefresh() throws {
// Given
let list = app.tables.firstMatch
XCTAssertTrue(list.waitForExistence(timeout: 5))
// When
list.swipeDown()
// Then - Loading indicator appears briefly
let loadingIndicator = app.activityIndicators.firstMatch
// Loading may be too fast to catch, so just verify no crash
Thread.sleep(forTimeInterval: 1)
XCTAssertTrue(list.exists)
}
func testBiometricAuthentication() throws {
// Given
let authenticateButton = app.buttons["Authenticate"]
XCTAssertTrue(authenticateButton.waitForExistence(timeout: 5))
// When
authenticateButton.tap()
// Simulate biometric auth success (requires setup in scheme)
// In real testing, you'd configure the simulator
// Then
let authenticatedLabel = app.staticTexts["Authenticated!"]
XCTAssertTrue(authenticatedLabel.waitForExistence(timeout: 5))
}
}
Accessibility Testing
// BankingAppUITests/AccessibilityTests.swift
import XCTest
final class AccessibilityTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launch()
}
func testPaymentCard_HasAccessibilityLabels() throws {
// Given
let paymentCard = app.staticTexts["John Doe"]
XCTAssertTrue(paymentCard.waitForExistence(timeout: 5))
// Then - Check accessibility
XCTAssertTrue(paymentCard.isAccessibilityElement)
XCTAssertNotNil(paymentCard.label)
}
func testVoiceOver_Navigation() throws {
// Enable VoiceOver for testing
XCUIDevice.shared.press(.home)
// Navigate using accessibility
// Verify key elements are accessible
let navBar = app.navigationBars["Payments"]
XCTAssertTrue(navBar.exists)
XCTAssertTrue(navBar.isAccessibilityElement)
}
func testDynamicType_LargeText() throws {
// Test with larger text sizes
// Verify layout doesn't break with accessibility text sizes
let paymentCard = app.staticTexts["John Doe"]
XCTAssertTrue(paymentCard.waitForExistence(timeout: 5))
XCTAssertTrue(paymentCard.exists)
}
}
Test Organization
Test Directory Structure
BankingAppTests/
├── Presentation/
│ ├── ViewModels/
│ │ ├── PaymentListViewModelTests.swift
│ │ ├── PaymentDetailViewModelTests.swift
│ │ └── CreatePaymentViewModelTests.swift
│ ├── Views/
│ │ ├── PaymentCardTests.swift
│ │ └── PaymentListViewTests.swift
│ └── Mocks/
│ └── MockViewModels.swift
│
├── Domain/
│ ├── UseCases/
│ │ ├── GetPaymentsUseCaseTests.swift
│ │ └── CreatePaymentUseCaseTests.swift
│ └── Models/
│ └── PaymentTests.swift
│
├── Data/
│ ├── Repositories/
│ │ └── PaymentRepositoryTests.swift
│ ├── Local/
│ │ └── PaymentDAOTests.swift
│ ├── Remote/
│ │ └── PaymentServiceTests.swift
│ └── Mappers/
│ └── PaymentMapperTests.swift
│
├── Core/
│ ├── Network/
│ │ ├── NetworkManagerTests.swift
│ │ └── MockNetworkManager.swift
│ ├── Storage/
│ │ ├── KeychainManagerTests.swift
│ │ └── TestCoreDataStack.swift
│ └── Security/
│ └── BiometricAuthTests.swift
│
└── Helpers/
├── TestHelpers.swift
└── MockData.swift
Common Pitfalls
Don't: Test Implementation Details
// BAD: Testing internal state
func testViewModel_InternalState() {
XCTAssertEqual(viewModel.internalCounter, 0) // DON'T TEST INTERNALS
}
// GOOD: Test public behavior
func testViewModel_LoadPayments() async {
await viewModel.loadPayments()
XCTAssertEqual(viewModel.state, .loaded([]))
}
Don't: Forget to Use MainActor
// BAD: Testing @MainActor without annotation
func testViewModel() async {
let viewModel = PaymentListViewModel() // CRASH! Not on main actor
}
// GOOD: Mark test as @MainActor
@MainActor
func testViewModel() async {
let viewModel = PaymentListViewModel()
}
Don't: Share State Between Tests
// BAD: Shared mutable state
final class PaymentTests: XCTestCase {
let sharedPayments: [Payment] = [] // SHARED!
func testA() {
sharedPayments.append(Payment.mock()) // MUTATES SHARED STATE
}
}
// GOOD: Create new instances in setUp
final class PaymentTests: XCTestCase {
var payments: [Payment]!
override func setUp() {
payments = []
}
}
Further Reading
iOS Framework Guidelines
- iOS Overview - iOS project setup and DI for testability
- iOS UI Development - Testing SwiftUI views
- iOS Data & Networking - Testing repositories and network layer
- iOS Security - Security testing approaches
- iOS Architecture - Testing layered architecture
- iOS Performance - Performance testing
Testing Strategy
- Testing Strategy - Overall testing approach and honeycomb model
- Unit Testing - Unit testing principles across platforms
- Integration Testing - Integration test patterns
- Mutation Testing - Mutation testing strategies
- CI Testing - CI pipeline testing
Language Testing
- Swift Testing - Swift-specific testing patterns
External Resources
- XCTest Documentation
- ViewInspector - SwiftUI testing
- UI Testing Guide
- Testing Tips
Summary
Key Takeaways
- Testing Pyramid - Unit tests (60-70%), Integration (20-30%), UI (<10%)
- XCTest framework - Native testing with fast feedback
- ViewInspector - Test SwiftUI views without running simulator
- Mock networking - Isolate network layer with configurable mocks
- Core Data testing - Use in-memory store for fast, isolated tests
- UI testing - XCUITest for critical user flows
- Async testing - Use async/await with proper @MainActor annotations
- Test organization - Mirror production code structure
- Fast feedback - Unit tests <1s, integration <5s, UI <30s
- Mock dependencies - Test in isolation with controlled inputs
Next Steps: Review iOS Architecture for testable architectural patterns, iOS UI Development for SwiftUI implementation details, and iOS Data & Networking for repository patterns.