Master advanced testing techniques to write comprehensive, maintainable tests for Swift apps.
Test Doubles
Dummy Objects
swift
// Dummy - Used to satisfy parameter requirements
class DummyUserRepository: UserRepository {
func fetchUser(id: String) throws -> User {
fatalError("Not implemented")
}
func saveUser(_ user: User) throws {
fatalError("Not implemented")
}
}
// Usage
class UserServiceTests: XCTestCase {
func testUserCreation() throws {
let dummyRepository = DummyUserRepository()
let service = UserService(repository: dummyRepository)
// Test logic that doesn't require repository
}
}Test Stubs
swift
// Stub - Provides canned answers
class StubAPIService: APIService {
var stubbedUser: User?
override func fetchUser(id: String) async throws -> User {
guard let user = stubbedUser else {
throw APIError.notFound
}
return user
}
}
// Usage
func testFetchUserSuccess() async throws {
let stub = StubAPIService()
stub.stubbedUser = User(id: "123", name: "John")
let user = try await stub.fetchUser(id: "123")
XCTAssertEqual(user.name, "John")
}Mock Objects
swift
// Mock - Verifies interactions
class MockUserRepository: UserRepository {
var saveCalled = false
var savedUser: User?
var fetchCalled = false
var fetchId: String?
func fetchUser(id: String) throws -> User {
fetchCalled = true
fetchId = id
return User(id: id, name: "Test User")
}
func saveUser(_ user: User) throws {
saveCalled = true
savedUser = user
}
func verifySaveCalled(expected: Bool, file: StaticString = #file, line: UInt = #line) {
XCTAssertEqual(saveCalled, expected, "saveUser() call status", file: file, line: line)
}
func verifyFetchedUser(id: String, file: StaticString = #file, line: UInt = #line) {
XCTAssertTrue(fetchCalled, "fetchUser() was not called", file: file, line: line)
XCTAssertEqual(fetchId, id, "fetchUser() was called with wrong ID", file: file, line: line)
}
}
// Usage
func testUserServiceSavesUser() throws {
let mockRepository = MockUserRepository()
let service = UserService(repository: mockRepository)
try service.createUser(name: "John", email: "john@example.com")
mockRepository.verifySaveCalled(expected: true)
XCTAssertNotNil(mockRepository.savedUser)
XCTAssertEqual(mockRepository.savedUser?.name, "John")
}Spies
swift
// Spy - Captures information about calls
class SpyAPIService: APIService {
struct Call {
let method: String
let parameters: [String: Any]
let timestamp: Date
}
private(set) var calls: [Call] = []
override func fetchUser(id: String) async throws -> User {
calls.append(Call(
method: "fetchUser",
parameters: ["id": id],
timestamp: Date()
))
return User(id: id, name: "Spy User")
}
func verifyCallCount(method: String, expected: Int) {
let actual = calls.filter { $0.method == method }.count
XCTAssertEqual(actual, expected, "\(method) call count")
}
func verifyCallOrder(expectedMethods: [String]) {
let actualMethods = calls.map { $0.method }
XCTAssertEqual(actualMethods, expectedMethods, "Call order")
}
}
// Usage
func testCallOrder() async throws {
let spy = SpyAPIService()
_ = try await spy.fetchUser(id: "1")
_ = try await spy.fetchUser(id: "2")
spy.verifyCallCount(method: "fetchUser", expected: 2)
}Fake Objects
swift
// Fake - Working implementation but simplified
class InMemoryUserRepository: UserRepository {
private var storage: [String: User] = [:]
func fetchUser(id: String) throws -> User {
guard let user = storage[id] else {
throw RepositoryError.notFound
}
return user
}
func saveUser(_ user: User) throws {
storage[user.id] = user
}
func clear() {
storage.removeAll()
}
func count() -> Int {
storage.count
}
}
// Usage
func testUserCRUD() throws {
let fakeRepository = InMemoryUserRepository()
let user = User(id: "123", name: "John")
try fakeRepository.saveUser(user)
XCTAssertEqual(fakeRepository.count(), 1)
let fetched = try fakeRepository.fetchUser(id: "123")
XCTAssertEqual(fetched.name, "John")
}Mocking Frameworks
Cuckoo
swift
import Cuckoo
// Generate mock with: cuckoo generate --testable MyApp
class MockUserService: UserService, Mock {
var matcher: Matcher!
var stubbing: Stubbing!
var verification: Verification!
init() {
matcher = Matcher()
stubbing = Stubbing()
verification = Verification()
}
override func fetchUser(id: String) async throws -> User {
let matcher: Matcher = self.matcher
return try super.fetchUser(id: id)
}
}
// Usage
func testWithCuckoo() async throws {
let mock = MockUserService()
stub(mock) { mock in
when(mock.fetchUser(id: "123")).thenReturn(User(id: "123", name: "Test"))
}
let result = try await mock.fetchUser(id: "123")
verify(mock).fetchUser(id: "123")
XCTAssertEqual(result.name, "Test")
}Dependency Injection for Testing
Protocol-Based Injection
swift
protocol NetworkService {
func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T
}
class ProductionNetworkService: NetworkService {
func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(T.self, from: data)
}
}
class MockNetworkService: NetworkService {
var result: Result<Any, Error> = .failure(NetworkError.unknown)
func fetch<T: Decodable>(_ type: T.Type, from url: URL) async throws -> T {
switch result {
case .success(let data):
return data as! T
case .failure(let error):
throw error
}
}
}
// Usage in production
class ViewModel {
private let network: NetworkService
init(network: NetworkService = ProductionNetworkService()) {
self.network = network
}
func loadUser() async throws -> User {
try await network.fetch(User.self, from: userURL)
}
}
// Usage in tests
func testLoadUser() async throws {
let mock = MockNetworkService()
mock.result = .success(User(id: "123", name: "Test"))
let viewModel = ViewModel(network: mock)
let user = try await viewModel.loadUser()
XCTAssertEqual(user.name, "Test")
}Testing Async Code
Async/Await Tests
swift
func testAsyncOperation() async throws {
let result = try await slowOperation()
XCTAssertEqual(result, "success")
}
func testAsyncError() async {
do {
_ = try await failingOperation()
XCTFail("Should have thrown error")
} catch {
XCTAssertTrue(error is ExpectedError)
}
}
func testMultipleAsyncCalls() async throws {
async let first = fetchUser(id: "1")
async let second = fetchUser(id: "2")
let (user1, user2) = try await (first, second)
XCTAssertEqual(user1.id, "1")
XCTAssertEqual(user2.id, "2")
}Time-Based Testing
swift
func testWithTimeout() async throws {
try await withTimeout(seconds: 1.0) {
_ = try await slowOperation()
}
}
func withTimeout<T>(
seconds: TimeInterval,
operation: () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TimeoutError()
}
guard let result = try await group.next() else {
throw TimeoutError()
}
group.cancelAll()
return result
}
}Testing UI
Snapshot Testing
swift
import SnapshotTesting
func testProfileViewSnapshot() {
let view = ProfileView(user: User(id: "1", name: "John"))
assertSnapshot(matching: view, as: .image(layout: .device(config: "iPhone 14 Pro")))
}
func testDetailViewDarkMode() {
let view = DetailView(item: Item(id: "1", name: "Test"))
assertSnapshot(matching: view, as: .image(precision: 0.9, layout: .device(config: "iPhone 14 Pro", appearance: .dark)))
}View Modifiers Testing
swift
func testCustomModifier() {
let view = Text("Hello")
.modifier(CustomStyle())
assertSnapshot(matching: view, as: .image)
}Test Organization
Test Suites
swift
class UserTests: XCTestCase {
// CRUD tests
}
class UserValidationTests: XCTestCase {
// Validation tests
}
class UserAuthenticationTests: XCTestCase {
// Authentication tests
}
class UserNetworkTests: XCTestCase {
// Network integration tests
}Shared Test Setup
swift
class TestCase: XCTestCase {
var sut: SystemUnderTest!
var mockDependencies: MockDependencies!
override func setUp() {
super.setUp()
mockDependencies = MockDependencies()
sut = SystemUnderTest(dependencies: mockDependencies)
}
override func tearDown() {
sut = nil
mockDependencies = nil
super.tearDown()
}
}
class UserTests: TestCase {
func testUserCreation() {
// Use sut and mockDependencies
}
}Best Practices
- 1Test Behavior: Test what, not how
- 2One Assertion: Prefer one assertion per test
- 3Descriptive Names: Use descriptive test names
- 4Independence: Keep tests independent
- 5Fast Tests: Keep tests fast
- 6Real Data: Use realistic test data
- 7Edge Cases: Test edge cases
- 8Maintainability: Maintain test quality
Advanced testing makes your code reliable!