Master iOS testing with XCTest, mocking strategies, UI testing, snapshot testing, and TDD patterns. Use when writing unit tests, creating mocks, testing async code, UI testing, or implementing test-driven development. Triggers on XCTest, unit test, mock, stub, spy, UI test, XCUITest, test, TDD, testing, snapshot test, test double.
You are an expert in iOS testing. When this skill activates, help write comprehensive, maintainable tests.
import XCTest
@testable import MyApp
final class UserServiceTests: XCTestCase {
// MARK: - Properties
private var sut: UserService! // System Under Test
private var mockAPI: MockAPIClient!
private var mockStorage: MockUserStorage!
// MARK: - Lifecycle
override func setUp() {
super.setUp()
mockAPI = MockAPIClient()
mockStorage = MockUserStorage()
sut = UserService(api: mockAPI, storage: mockStorage)
}
override func tearDown() {
sut = nil
mockAPI = nil
mockStorage = nil
super.tearDown()
}
// MARK: - Tests
func test_fetchUser_withValidID_returnsUser() async throws {
// Given
let expectedUser = User(id: "123", name: "John")
mockAPI.stubbedUser = expectedUser
// When
let user = try await sut.fetchUser(id: "123")
// Then
XCTAssertEqual(user, expectedUser)
XCTAssertEqual(mockAPI.fetchUserCallCount, 1)
XCTAssertEqual(mockAPI.lastFetchedUserID, "123")
}
}
// Pattern: test_[method]_[condition]_[expectedResult]
func test_login_withValidCredentials_returnsSuccess() { }
func test_login_withInvalidPassword_throwsAuthError() { }
func test_fetchItems_whenOffline_returnsCachedItems() { }
func test_calculateTotal_withEmptyCart_returnsZero() { }
// Records interactions AND provides stubbed responses
class MockAPIClient: APIClientProtocol {
// Stubs (what to return)
var stubbedUser: User?
var stubbedError: Error?
// Spies (what was called)
private(set) var fetchUserCallCount = 0
private(set) var lastFetchedUserID: String?
func fetchUser(id: String) async throws -> User {
fetchUserCallCount += 1
lastFetchedUserID = id
if let error = stubbedError {
throw error
}
return stubbedUser ?? User(id: id, name: "Default")
}
// Reset for reuse
func reset() {
stubbedUser = nil
stubbedError = nil
fetchUserCallCount = 0
lastFetchedUserID = nil
}
}
// Just provides canned responses
struct StubUserRepository: UserRepositoryProtocol {
var users: [User] = []
func getAll() async -> [User] {
users
}
func get(id: String) async -> User? {
users.first { $0.id == id }
}
}
// Records all interactions for verification
class SpyAnalytics: AnalyticsProtocol {
private(set) var trackedEvents: [(name: String, properties: [String: Any])] = []
func track(_ event: String, properties: [String: Any]) {
trackedEvents.append((event, properties))
}
// Verification helpers
func didTrack(_ eventName: String) -> Bool {
trackedEvents.contains { $0.name == eventName }
}
func eventCount(for name: String) -> Int {
trackedEvents.filter { $0.name == name }.count
}
}
// Working implementation with shortcuts
class FakeUserStorage: UserStorageProtocol {
private var users: [String: User] = [:]
func save(_ user: User) async {
users[user.id] = user
}
func load(id: String) async -> User? {
users[id]
}
func delete(id: String) async {
users.removeValue(forKey: id)
}
}
func test_fetchUser_success() async throws {
// Given
mockAPI.stubbedUser = User(id: "1", name: "Test")
// When
let user = try await sut.fetchUser(id: "1")
// Then
XCTAssertEqual(user.name, "Test")
}
func test_fetchUser_throwsError() async {
// Given
mockAPI.stubbedError = NetworkError.offline
// When/Then
do {
_ = try await sut.fetchUser(id: "1")
XCTFail("Expected error to be thrown")
} catch {
XCTAssertTrue(error is NetworkError)
}
}
func test_fetchUser_withCallback() {
// Given
let expectation = expectation(description: "User fetched")
var receivedUser: User?
// When
sut.fetchUser(id: "1") { result in
if case .success(let user) = result {
receivedUser = user
}
expectation.fulfill()
}
// Then
wait(for: [expectation], timeout: 1.0)
XCTAssertNotNil(receivedUser)
}
import Combine
func test_userPublisher_emitsUser() {
// Given
var cancellables = Set<AnyCancellable>()
let expectation = expectation(description: "User received")
var receivedUser: User?
// When
sut.userPublisher
.sink { user in
receivedUser = user
expectation.fulfill()
}
.store(in: &cancellables)
sut.loadUser()
// Then
wait(for: [expectation], timeout: 1.0)
XCTAssertNotNil(receivedUser)
}
@MainActor
final class HomeViewModelTests: XCTestCase {
private var sut: HomeViewModel!
private var mockUseCase: MockGetItemsUseCase!
override func setUp() {
super.setUp()
mockUseCase = MockGetItemsUseCase()
sut = HomeViewModel(getItemsUseCase: mockUseCase)
}
func test_loadItems_setsLoadingState() async {
// Given
mockUseCase.delay = 0.1
mockUseCase.stubbedItems = [.mock]
// When
let loadTask = Task { await sut.loadItems() }
// Then - Loading state
try? await Task.sleep(for: .milliseconds(50))
XCTAssertTrue(sut.isLoading)
await loadTask.value
// Then - Loaded state
XCTAssertFalse(sut.isLoading)
XCTAssertEqual(sut.items.count, 1)
}
func test_loadItems_failure_setsError() async {
// Given
mockUseCase.stubbedError = TestError.network
// When
await sut.loadItems()
// Then
XCTAssertNotNil(sut.error)
XCTAssertTrue(sut.items.isEmpty)
}
}
import XCUITest
final class LoginUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launch()
}
func test_login_withValidCredentials_showsHomeScreen() {
// Given
let emailField = app.textFields["email-field"]
let passwordField = app.secureTextFields["password-field"]
let loginButton = app.buttons["login-button"]
// When
emailField.tap()
emailField.typeText("[email protected]")
passwordField.tap()
passwordField.typeText("password123")
loginButton.tap()
// Then
let homeTitle = app.staticTexts["Welcome"]
XCTAssertTrue(homeTitle.waitForExistence(timeout: 5))
}
func test_login_withInvalidPassword_showsError() {
// Given
let emailField = app.textFields["email-field"]
let passwordField = app.secureTextFields["password-field"]
let loginButton = app.buttons["login-button"]
// When
emailField.tap()
emailField.typeText("[email protected]")
passwordField.tap()
passwordField.typeText("wrong")
loginButton.tap()
// Then
let errorAlert = app.alerts["Error"]
XCTAssertTrue(errorAlert.waitForExistence(timeout: 5))
}
}
// Page objects encapsulate UI structure
struct LoginPage {
let app: XCUIApplication
var emailField: XCUIElement {
app.textFields["email-field"]
}
var passwordField: XCUIElement {
app.secureTextFields["password-field"]
}
var loginButton: XCUIElement {
app.buttons["login-button"]
}
var errorAlert: XCUIElement {
app.alerts["Error"]
}
func login(email: String, password: String) {
emailField.tap()
emailField.typeText(email)
passwordField.tap()
passwordField.typeText(password)
loginButton.tap()
}
}
struct HomePage {
let app: XCUIApplication
var welcomeText: XCUIElement {
app.staticTexts["Welcome"]
}
var isDisplayed: Bool {
welcomeText.waitForExistence(timeout: 5)
}
}
// Usage in tests
func test_login_success() {
let loginPage = LoginPage(app: app)
let homePage = HomePage(app: app)
loginPage.login(email: "[email protected]", password: "password123")
XCTAssertTrue(homePage.isDisplayed)
}
// In your SwiftUI views
TextField("Email", text: $email)
.accessibilityIdentifier("email-field")
Button("Login") { }
.accessibilityIdentifier("login-button")
// In UI tests
app.textFields["email-field"]
app.buttons["login-button"]
import SnapshotTesting
import XCTest
@testable import MyApp
final class ProfileViewSnapshotTests: XCTestCase {
func test_profileView_default() {
let view = ProfileView(user: .mock)
assertSnapshot(
of: view,
as: .image(layout: .device(config: .iPhone13))
)
}
func test_profileView_darkMode() {
let view = ProfileView(user: .mock)
.preferredColorScheme(.dark)
assertSnapshot(
of: view,
as: .image(layout: .device(config: .iPhone13))
)
}
func test_profileView_largeText() {
let view = ProfileView(user: .mock)
.environment(\.sizeCategory, .accessibilityExtraLarge)
assertSnapshot(
of: view,
as: .image(layout: .device(config: .iPhone13))
)
}
}
extension User {
static var mock: User {
User(id: "test-id", name: "Test User", email: "[email protected]")
}
static func mock(
id: String = "test-id",
name: String = "Test User",
email: String = "[email protected]"
) -> User {
User(id: id, name: name, email: email)
}
}
extension Array where Element == User {
static var mockList: [User] {
[
.mock(id: "1", name: "Alice"),
.mock(id: "2", name: "Bob"),
.mock(id: "3", name: "Charlie")
]
}
}
class UserBuilder {
private var id = "default-id"
private var name = "Default Name"
private var email = "[email protected]"
private var isPremium = false
func with(id: String) -> UserBuilder {
self.id = id
return self
}
func with(name: String) -> UserBuilder {
self.name = name
return self
}
func asPremium() -> UserBuilder {
self.isPremium = true
return self
}
func build() -> User {
User(id: id, name: name, email: email, isPremium: isPremium)
}
}
// Usage
let premiumUser = UserBuilder()
.with(name: "Premium User")
.asPremium()
.build()
// Test one thing per test
func test_addItem_increasesCount() { }
func test_addItem_triggersNotification() { }
// Use descriptive names
func test_checkout_withEmptyCart_throwsEmptyCartError() { }
// Arrange-Act-Assert (Given-When-Then)
func test_example() {
// Given (Arrange)
let input = "test"
// When (Act)
let result = sut.process(input)
// Then (Assert)
XCTAssertEqual(result, "expected")
}
// Don't test implementation details
func test_internal_cache_dictionary_has_key() { } // ❌
// Don't use sleep for async
sleep(2) // ❌ Use expectations or async/await
// Don't test multiple behaviors
func test_login_validatesAndSavesAndNavigates() { } // ❌
# Generate coverage report
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 15' \
-enableCodeCoverage YES
# View in Xcode: Product → Test → Coverage