iOS Testing Skill
Core Rules
- Use Swift Testing (
@Test, #expect) for ALL new unit tests -- it is the modern framework (Xcode 16+).
- Keep XCTest only for UI tests and performance tests -- Swift Testing does not support these yet.
- Both frameworks can coexist in the same target -- migrate incrementally, never rewrite working tests.
- Use protocol-based dependency injection for testability.
- Use
URLProtocol for network mocking (NOT mocking URLSession directly).
- Use
isStoredInMemoryOnly: true for SwiftData test containers.
- Name tests descriptively:
@Test("Login succeeds with valid credentials").
- One assertion per test is ideal, but pragmatic grouping is fine.
- Test behavior, not implementation details.
- Never use
sleep() in tests -- use expectations, confirmations, or Clock injection.
Framework Choice
| Test Type |
Framework |
Why |
| Unit tests (new) |
Swift Testing |
Modern, less boilerplate, parameterized tests |
| Unit tests (existing) |
XCTest |
Don't rewrite working tests without reason |
| UI tests |
XCTest |
Swift Testing doesn't support XCUIApplication |
| Performance tests |
XCTest |
measure {} not available in Swift Testing |
| Snapshot tests |
XCTest + swift-snapshot-testing |
Point-Free library, XCTest integration |
Test Organization
MyAppTests/ # Unit test target
Models/
UserTests.swift
OrderTests.swift
ViewModels/
LoginViewModelTests.swift
ProfileViewModelTests.swift
Services/
APIClientTests.swift
AuthServiceTests.swift
Helpers/
Mocks/
MockAPIClient.swift
MockAuthService.swift
TestData/
UserFixtures.swift
JSONFixtures.swift
MyAppUITests/ # UI test target
Screens/ # Page Objects
LoginScreen.swift
HomeScreen.swift
Flows/
OnboardingFlowTests.swift
PurchaseFlowTests.swift
Helpers/
XCUIApplication+Launch.swift
Quick Start: Swift Testing
import Testing
@testable import MyApp
@Suite("AuthService")
struct AuthServiceTests {
let sut: AuthService
let mockAPI: MockAPIClient
init() {
mockAPI = MockAPIClient()
sut = AuthService(api: mockAPI)
}
@Test("Login succeeds with valid credentials")
func loginSuccess() async throws {
mockAPI.loginResult = .success(User.fixture)
let user = try await sut.login(email: "test@example.com", password: "pass123")
#expect(user.email == "test@example.com")
#expect(mockAPI.loginCallCount == 1)
}
@Test("Login fails with invalid credentials")
func loginFailure() async {
mockAPI.loginResult = .failure(AuthError.invalidCredentials)
await #expect(throws: AuthError.invalidCredentials) {
try await sut.login(email: "bad@example.com", password: "wrong")
}
}
@Test("Password validation", arguments: [
("short", false),
("validPass1!", true),
("nouppercase1!", false),
("NOLOWERCASE1!", false),
])
func passwordValidation(password: String, isValid: Bool) {
#expect(sut.isValidPassword(password) == isValid)
}
}
Quick Start: XCTest (UI Tests)
import XCTest
final class LoginUITests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting", "--reset-state"]
app.launch()
}
func test_login_withValidCredentials_showsHome() {
let loginScreen = LoginScreen(app: app)
loginScreen
.typeEmail("user@example.com")
.typePassword("password123")
.tapLogin()
let homeScreen = HomeScreen(app: app)
XCTAssertTrue(homeScreen.welcomeLabel.waitForExistence(timeout: 5))
}
}
Protocol-Based Mocking Pattern
// 1. Define protocol
protocol APIClientProtocol: Sendable {
func login(email: String, password: String) async throws -> User
func fetchProfile(id: String) async throws -> Profile
}
// 2. Production implementation
final class APIClient: APIClientProtocol {
func login(email: String, password: String) async throws -> User { /* real impl */ }
func fetchProfile(id: String) async throws -> Profile { /* real impl */ }
}
// 3. Mock for tests
final class MockAPIClient: APIClientProtocol, @unchecked Sendable {
var loginResult: Result<User, Error> = .failure(TestError.notConfigured)
var loginCallCount = 0
var loginReceivedArgs: [(email: String, password: String)] = []
func login(email: String, password: String) async throws -> User {
loginCallCount += 1
loginReceivedArgs.append((email, password))
return try loginResult.get()
}
var fetchProfileResult: Result<Profile, Error> = .failure(TestError.notConfigured)
var fetchProfileCallCount = 0
func fetchProfile(id: String) async throws -> Profile {
fetchProfileCallCount += 1
return try fetchProfileResult.get()
}
}
URLProtocol Network Mocking
final 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 {
client?.urlProtocolDidFinishLoading(self)
return
}
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() {}
}
// Usage in test:
let config = URLSessionConfiguration.ephemeral
config.protocolClasses = [MockURLProtocol.self]
let session = URLSession(configuration: config)
let apiClient = APIClient(session: session)
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
let data = try JSONEncoder().encode(User.fixture)
return (response, data)
}
SwiftData Testing
@Test("Saving a user persists it")
func saveUser() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: User.self, configurations: config)
let context = ModelContext(container)
let user = User(name: "Test", email: "test@example.com")
context.insert(user)
try context.save()
let descriptor = FetchDescriptor<User>()
let users = try context.fetch(descriptor)
#expect(users.count == 1)
#expect(users.first?.name == "Test")
}
Combine Testing
@Test("Publisher emits values correctly")
func publisherEmitsValues() async {
let viewModel = CounterViewModel()
var received: [Int] = []
let cancellable = viewModel.$count.sink { received.append($0) }
viewModel.increment()
viewModel.increment()
#expect(received == [0, 1, 2])
cancellable.cancel()
}
async/await Confirmation (Replaces XCTestExpectation)
@Test("Notification triggers callback")
func notificationCallback() async {
await confirmation("callback received") { confirm in
let observer = NotificationObserver {
confirm()
}
NotificationCenter.default.post(name: .testNotification, object: nil)
}
}
@Test("Delegate called exactly 3 times")
func delegateCalledThreeTimes() async {
await confirmation("delegate called", expectedCount: 3) { confirm in
let delegate = MockDelegate(onCall: { confirm() })
let sut = DataLoader(delegate: delegate)
await sut.loadBatch(count: 3)
}
}
Test Fixtures Pattern
extension User {
static var fixture: User {
User(id: "test-id", name: "Test User", email: "test@example.com")
}
static func fixture(
id: String = "test-id",
name: String = "Test User",
email: String = "test@example.com"
) -> User {
User(id: id, name: name, email: email)
}
}
Common Mistakes to Avoid
- Don't test private methods -- test public behavior instead.
- Don't mock what you don't own -- wrap third-party APIs in your own protocol.
- Don't use
sleep() or Task.sleep() for timing -- use Clock injection or expectations.
- Don't share mutable state between tests -- use struct-based
@Suite with init().
- Don't forget
@MainActor isolation -- if your SUT is @MainActor, your test must be too.
- Don't use
XCTAssert in Swift Testing -- use #expect and #require.
- Don't force-unwrap in tests -- use
#require or XCTUnwrap.
- Don't test Apple frameworks -- trust that
UserDefaults.set works.
- Don't write tests after the fact just for coverage -- write tests that catch real bugs.
- Don't ignore flaky tests -- use
withKnownIssue to mark them, then fix root cause.
Migration: XCTest to Swift Testing
| XCTest |
Swift Testing |
class MyTests: XCTestCase |
@Suite struct MyTests |
func testSomething() |
@Test func something() |
override func setUp() |
init() |
override func tearDown() |
deinit |
XCTAssertEqual(a, b) |
#expect(a == b) |
XCTAssertNil(x) |
#expect(x == nil) |
XCTAssertThrowsError(expr) |
#expect(throws: ErrorType.self) { expr } |
XCTUnwrap(optional) |
try #require(optional) |
XCTestExpectation + wait |
confirmation { } |
XCTSkipIf(condition) |
.enabled(if: !condition) trait |
measure { } |
No equivalent -- keep in XCTest |
| UI tests with XCUIApplication |
No equivalent -- keep in XCTest |
References
- references/swift-testing.md -- Swift Testing framework deep dive
- references/xctest.md -- XCTest assertions, async, performance
- references/ui-testing.md -- XCUIApplication, Page Object, accessibility
- references/mocking.md -- Protocol mocks, URLProtocol, snapshot testing
Related Skills
ios-simulator — simulator testing
ios-performance — performance testing
xcode-cloud — CI testing
GitNexus Index
This skill is indexed by GitNexus for knowledge graph traversal.
Index path: /Users/localuser/.claude/skills/ios-testing/.gitnexus
Last indexed: 2026-05-23
1---2name: ios-testing3description: iOS testing expert skill covering Swift Testing framework (@Test, #expect, #require, @Suite, parameterized tests, traits), XCTest (assertions, async testing, performance testing, XCTestExpectation), UI Testing (XCUIApplication, XCUIElement, Page Object pattern, accessibility identifiers), snapshot testing (swift-snapshot-testing), mocking strategies (protocol-based mocks, URLProtocol for network, test doubles), and testing patterns for SwiftUI, SwiftData, Combine, and async/await code. Use this skill whenever the user writes tests, creates test classes, needs mocking strategies, or asks about testing iOS code. Triggers on: test, @Test, #expect, XCTest, XCTestCase, unit test, UI test, integration test, mock, stub, spy, fake, snapshot test, test coverage, TDD, testing, assert, XCTAssert, Swift Testing, @Suite, parameterized test, test plan, test double, URLProtocol mock, ViewInspector, or any iOS testing question.4---56# iOS Testing Skill78## Core Rules9101. **Use Swift Testing (`@Test`, `#expect`) for ALL new unit tests** -- it is the modern framework (Xcode 16+).112. **Keep XCTest only for UI tests and performance tests** -- Swift Testing does not support these yet.123. Both frameworks can coexist in the same target -- migrate incrementally, never rewrite working tests.134. Use **protocol-based dependency injection** for testability.145. Use **`URLProtocol`** for network mocking (NOT mocking URLSession directly).156. Use **`isStoredInMemoryOnly: true`** for SwiftData test containers.167. Name tests descriptively: `@Test("Login succeeds with valid credentials")`.178. One assertion per test is ideal, but pragmatic grouping is fine.189. **Test behavior, not implementation details.**1910. Never use `sleep()` in tests -- use expectations, confirmations, or `Clock` injection.2021## Framework Choice2223| Test Type | Framework | Why |24|-----------|-----------|-----|25| Unit tests (new) | Swift Testing | Modern, less boilerplate, parameterized tests |26| Unit tests (existing) | XCTest | Don't rewrite working tests without reason |27| UI tests | XCTest | Swift Testing doesn't support XCUIApplication |28| Performance tests | XCTest | `measure {}` not available in Swift Testing |29| Snapshot tests | XCTest + swift-snapshot-testing | Point-Free library, XCTest integration |3031## Test Organization3233```34MyAppTests/ # Unit test target35 Models/36 UserTests.swift37 OrderTests.swift38 ViewModels/39 LoginViewModelTests.swift40 ProfileViewModelTests.swift41 Services/42 APIClientTests.swift43 AuthServiceTests.swift44 Helpers/45 Mocks/46 MockAPIClient.swift47 MockAuthService.swift48 TestData/49 UserFixtures.swift50 JSONFixtures.swift51MyAppUITests/ # UI test target52 Screens/ # Page Objects53 LoginScreen.swift54 HomeScreen.swift55 Flows/56 OnboardingFlowTests.swift57 PurchaseFlowTests.swift58 Helpers/59 XCUIApplication+Launch.swift60```6162## Quick Start: Swift Testing6364```swift65import Testing66@testable import MyApp6768@Suite("AuthService")69struct AuthServiceTests {70 let sut: AuthService71 let mockAPI: MockAPIClient7273 init() {74 mockAPI = MockAPIClient()75 sut = AuthService(api: mockAPI)76 }7778 @Test("Login succeeds with valid credentials")79 func loginSuccess() async throws {80 mockAPI.loginResult = .success(User.fixture)8182 let user = try await sut.login(email: "test@example.com", password: "pass123")8384 #expect(user.email == "test@example.com")85 #expect(mockAPI.loginCallCount == 1)86 }8788 @Test("Login fails with invalid credentials")89 func loginFailure() async {90 mockAPI.loginResult = .failure(AuthError.invalidCredentials)9192 await #expect(throws: AuthError.invalidCredentials) {93 try await sut.login(email: "bad@example.com", password: "wrong")94 }95 }9697 @Test("Password validation", arguments: [98 ("short", false),99 ("validPass1!", true),100 ("nouppercase1!", false),101 ("NOLOWERCASE1!", false),102 ])103 func passwordValidation(password: String, isValid: Bool) {104 #expect(sut.isValidPassword(password) == isValid)105 }106}107```108109## Quick Start: XCTest (UI Tests)110111```swift112import XCTest113114final class LoginUITests: XCTestCase {115 var app: XCUIApplication!116117 override func setUp() {118 super.setUp()119 continueAfterFailure = false120 app = XCUIApplication()121 app.launchArguments = ["--uitesting", "--reset-state"]122 app.launch()123 }124125 func test_login_withValidCredentials_showsHome() {126 let loginScreen = LoginScreen(app: app)127128 loginScreen129 .typeEmail("user@example.com")130 .typePassword("password123")131 .tapLogin()132133 let homeScreen = HomeScreen(app: app)134 XCTAssertTrue(homeScreen.welcomeLabel.waitForExistence(timeout: 5))135 }136}137```138139## Protocol-Based Mocking Pattern140141```swift142// 1. Define protocol143protocol APIClientProtocol: Sendable {144 func login(email: String, password: String) async throws -> User145 func fetchProfile(id: String) async throws -> Profile146}147148// 2. Production implementation149final class APIClient: APIClientProtocol {150 func login(email: String, password: String) async throws -> User { /* real impl */ }151 func fetchProfile(id: String) async throws -> Profile { /* real impl */ }152}153154// 3. Mock for tests155final class MockAPIClient: APIClientProtocol, @unchecked Sendable {156 var loginResult: Result<User, Error> = .failure(TestError.notConfigured)157 var loginCallCount = 0158 var loginReceivedArgs: [(email: String, password: String)] = []159160 func login(email: String, password: String) async throws -> User {161 loginCallCount += 1162 loginReceivedArgs.append((email, password))163 return try loginResult.get()164 }165166 var fetchProfileResult: Result<Profile, Error> = .failure(TestError.notConfigured)167 var fetchProfileCallCount = 0168169 func fetchProfile(id: String) async throws -> Profile {170 fetchProfileCallCount += 1171 return try fetchProfileResult.get()172 }173}174```175176## URLProtocol Network Mocking177178```swift179final class MockURLProtocol: URLProtocol {180 static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?181182 override class func canInit(with request: URLRequest) -> Bool { true }183 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }184185 override func startLoading() {186 guard let handler = Self.requestHandler else {187 client?.urlProtocolDidFinishLoading(self)188 return189 }190 do {191 let (response, data) = try handler(request)192 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)193 client?.urlProtocol(self, didLoad: data)194 client?.urlProtocolDidFinishLoading(self)195 } catch {196 client?.urlProtocol(self, didFailWithError: error)197 }198 }199200 override func stopLoading() {}201}202203// Usage in test:204let config = URLSessionConfiguration.ephemeral205config.protocolClasses = [MockURLProtocol.self]206let session = URLSession(configuration: config)207let apiClient = APIClient(session: session)208209MockURLProtocol.requestHandler = { request in210 let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!211 let data = try JSONEncoder().encode(User.fixture)212 return (response, data)213}214```215216## SwiftData Testing217218```swift219@Test("Saving a user persists it")220func saveUser() throws {221 let config = ModelConfiguration(isStoredInMemoryOnly: true)222 let container = try ModelContainer(for: User.self, configurations: config)223 let context = ModelContext(container)224225 let user = User(name: "Test", email: "test@example.com")226 context.insert(user)227 try context.save()228229 let descriptor = FetchDescriptor<User>()230 let users = try context.fetch(descriptor)231 #expect(users.count == 1)232 #expect(users.first?.name == "Test")233}234```235236## Combine Testing237238```swift239@Test("Publisher emits values correctly")240func publisherEmitsValues() async {241 let viewModel = CounterViewModel()242 var received: [Int] = []243 let cancellable = viewModel.$count.sink { received.append($0) }244245 viewModel.increment()246 viewModel.increment()247248 #expect(received == [0, 1, 2])249 cancellable.cancel()250}251```252253## async/await Confirmation (Replaces XCTestExpectation)254255```swift256@Test("Notification triggers callback")257func notificationCallback() async {258 await confirmation("callback received") { confirm in259 let observer = NotificationObserver {260 confirm()261 }262 NotificationCenter.default.post(name: .testNotification, object: nil)263 }264}265266@Test("Delegate called exactly 3 times")267func delegateCalledThreeTimes() async {268 await confirmation("delegate called", expectedCount: 3) { confirm in269 let delegate = MockDelegate(onCall: { confirm() })270 let sut = DataLoader(delegate: delegate)271 await sut.loadBatch(count: 3)272 }273}274```275276## Test Fixtures Pattern277278```swift279extension User {280 static var fixture: User {281 User(id: "test-id", name: "Test User", email: "test@example.com")282 }283284 static func fixture(285 id: String = "test-id",286 name: String = "Test User",287 email: String = "test@example.com"288 ) -> User {289 User(id: id, name: name, email: email)290 }291}292```293294## Common Mistakes to Avoid2952961. **Don't test private methods** -- test public behavior instead.2972. **Don't mock what you don't own** -- wrap third-party APIs in your own protocol.2983. **Don't use `sleep()` or `Task.sleep()` for timing** -- use `Clock` injection or expectations.2994. **Don't share mutable state between tests** -- use struct-based `@Suite` with `init()`.3005. **Don't forget `@MainActor` isolation** -- if your SUT is `@MainActor`, your test must be too.3016. **Don't use `XCTAssert` in Swift Testing** -- use `#expect` and `#require`.3027. **Don't force-unwrap in tests** -- use `#require` or `XCTUnwrap`.3038. **Don't test Apple frameworks** -- trust that `UserDefaults.set` works.3049. **Don't write tests after the fact just for coverage** -- write tests that catch real bugs.30510. **Don't ignore flaky tests** -- use `withKnownIssue` to mark them, then fix root cause.306307## Migration: XCTest to Swift Testing308309| XCTest | Swift Testing |310|--------|--------------|311| `class MyTests: XCTestCase` | `@Suite struct MyTests` |312| `func testSomething()` | `@Test func something()` |313| `override func setUp()` | `init()` |314| `override func tearDown()` | `deinit` |315| `XCTAssertEqual(a, b)` | `#expect(a == b)` |316| `XCTAssertNil(x)` | `#expect(x == nil)` |317| `XCTAssertThrowsError(expr)` | `#expect(throws: ErrorType.self) { expr }` |318| `XCTUnwrap(optional)` | `try #require(optional)` |319| `XCTestExpectation` + `wait` | `confirmation { }` |320| `XCTSkipIf(condition)` | `.enabled(if: !condition)` trait |321| `measure { }` | No equivalent -- keep in XCTest |322| UI tests with XCUIApplication | No equivalent -- keep in XCTest |323324## References325326- [references/swift-testing.md](references/swift-testing.md) -- Swift Testing framework deep dive327- [references/xctest.md](references/xctest.md) -- XCTest assertions, async, performance328- [references/ui-testing.md](references/ui-testing.md) -- XCUIApplication, Page Object, accessibility329- [references/mocking.md](references/mocking.md) -- Protocol mocks, URLProtocol, snapshot testing330331## Related Skills332- `ios-simulator` — simulator testing333- `ios-performance` — performance testing334- `xcode-cloud` — CI testing335336## GitNexus Index337This skill is indexed by GitNexus for knowledge graph traversal.338Index path: /Users/localuser/.claude/skills/ios-testing/.gitnexus339Last indexed: 2026-05-23