| name | writing-ios-unit-tests |
| description | Use this skill when writing iOS unit tests, debugging test failures, creating Swift Testing tests, testing SwiftData models, or when the user asks to add tests, fix tests, improve coverage, or test Swift code. Covers Swift Testing framework, ModelContainer lifecycle, and coverage requirements. |
iOS Unit Testing (Swift Testing)
Three core principles ensure reliable unit tests:
- Use Swift Testing framework - Modern
@Test and #expect syntax for new tests
- Keep container alive - Always capture ModelContainer when using SwiftData contexts
- Safe unwrapping patterns - Never force unwrap; use guard with explicit failures
Scope
If input arguments provided:
Scope / file pattern: $ARGUMENTS
else:
Scope:
- Determine what unit tests need to be created or updated based on either:
A - New or modified code in the current phase/PR (analyze changed files and map to testable components); or
B - Coverage gaps identified by
./scripts/check-coverage.sh
- Investigate existing tests to avoid duplicative test classes or methods
- Check coverage tier requirements in
coverage-config.json
⛔️ MANDATORY: When Tests Fail, Debug First
STOP. Before changing ANY code when a test fails, you MUST analyze the error:
Step 1: Read the Full Error Message
cat logs/latest/raw_output.txt
Step 2: Check Test Isolation
- Is the test failing due to shared state?
- Is ModelContainer being deallocated prematurely?
Step 3: Add Debug Output
print("DEBUG: value = \(value)")
#expect(value != nil, "Value was nil - check initialization path")
Step 4: Analyze BEFORE Changing Code
- Error message shows: What assertion failed and why
- Stack trace shows: Where in the code the failure occurred
- Debug output shows: Actual vs expected values
❌ DO NOT:
- Guess at why tests fail without reading errors
- Change production code to make tests pass
- Remove or weaken assertions
✅ ALWAYS:
- Read the full error output first
- Verify test data setup is correct
- Check ModelContainer lifecycle
- Only then make targeted fixes based on evidence
Framework Overview
- Framework: Swift Testing (modern, preferred) + XCTest (legacy)
- Config: project.yml (XcodeGen) or .xcodeproj
- Output Formatter: xcbeautify
- Unit Test Directory: {ProjectName}Tests/
- Naming Pattern: *Tests.swift
Swift Testing vs XCTest
✅ Swift Testing - Modern, cleaner syntax (USE THIS):
import Testing
@testable import AppName
@Test("User creation with valid data")
func testUserCreation() {
let user = User(email: "test@example.com", name: "Test User")
#expect(user.email == "test@example.com")
#expect(user.name == "Test User")
}
❌ XCTest - Legacy syntax (avoid in new tests):
func testUserCreation() throws {
let user = User(email: "test@example.com", name: "Test User")
XCTAssertEqual(user.email, "test@example.com")
XCTAssertEqual(user.name, "Test User")
}
Basic Test Structure
import Testing
@testable import AppName
@Suite("FeatureName Tests")
struct FeatureNameTests {
@Test("Calculate next scheduled event time")
@MainActor
func testGetNextScheduledTime() async {
let (context, container) = createTestContext()
_ = container
let viewModel = ScheduleViewModel()
let nextTime = viewModel.getNextScheduledTime()
#expect(nextTime != nil, "Expected non-nil value")
}
}
SwiftData Testing Patterns (CRITICAL)
See ./swiftdata-testing-patterns.md for complete patterns including:
- Creating test context with proper container lifecycle
- The critical relationship anti-pattern that causes crashes
- Why array assignment to relationships fails in tests
Safe Unwrapping Patterns
Avoid Force Unwrapping
let result = viewModel.calculateDose()!
guard let result = viewModel.calculateDose() else {
#expect(Bool(false), "calculateDose() returned nil unexpectedly")
return
}
let result = viewModel.calculateDose()
#expect(result != nil, "Expected calculateDose() to return a value")
Tolerance-Based Assertions
Time/Date Comparisons
#expect(nextDose == expectedDate)
let timeDifference = abs(nextDose.timeIntervalSince(expectedDate))
#expect(timeDifference < 60, "Time should be within 1 minute tolerance")
let timeDifference = abs(nextDoseTime.timeIntervalSinceNow)
#expect(timeDifference < 24 * 60 * 60, "Next dose should be within 24 hours")
Async Testing Best Practices
@MainActor for UI Components
@Test("Verify medication profile selection updates state")
@MainActor
func testMedicationProfileSelection() async {
let viewModel = OnboardingViewModel()
let medication = Medication.semaglutide
await viewModel.selectMedication(medication)
#expect(viewModel.selectedMedication == medication)
#expect(viewModel.canProceedToNextStep == true)
}
Common Test Patterns
Testing Error Handling
@Test("Invalid dose amount throws appropriate error")
func testInvalidDoseThrowsError() async {
let validator = DoseValidator()
await #expect(throws: MedicationError.invalidDose) {
try validator.validate(dose: -1.0, medication: .semaglutide)
}
}
Testing State Changes
@Test("ViewModel updates state correctly on user action")
@MainActor
func testViewModelStateChange() async {
let viewModel = OnboardingViewModel()
#expect(viewModel.currentStep == .welcome)
#expect(viewModel.canProceedToNextStep == true)
await viewModel.proceedToNextStep()
#expect(viewModel.currentStep == .medicationSelection)
}
Testing Computed Properties
@Test("MedicationProfile computes next dose time correctly")
func testNextDoseTimeComputation() {
let profile = MedicationProfile.testProfile()
profile.frequency = .weekly
profile.lastDoseDate = Date().addingTimeInterval(-7 * 24 * 60 * 60)
let nextDose = profile.nextDoseTime
let timeDifference = abs(nextDose.timeIntervalSinceNow)
#expect(timeDifference < 3600)
}
Mocking Patterns
Protocol-Based Mocking
protocol NotificationCenterProtocol {
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
func add(_ request: UNNotificationRequest) async throws
}
extension UNUserNotificationCenter: NotificationCenterProtocol {}
class MockNotificationCenter: NotificationCenterProtocol {
var authorizationResult = true
var addedRequests: [UNNotificationRequest] = []
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
return authorizationResult
}
func add(_ request: UNNotificationRequest) async throws {
addedRequests.append(request)
}
}
What to Mock:
- External services (NotificationCenter, URLSession)
- System frameworks (HealthKit, StoreKit)
- Time-dependent operations
What NOT to Mock:
- Internal services (test with real implementations)
- SwiftData (use in-memory container)
- Pure functions
Test Factories for Consistency
Extension-Based Factories
extension User {
static func testUser(
email: String = "test@example.com",
name: String? = "Test User",
weight: Double = 70.0
) -> User {
User(email: email, name: name, weight: weight)
}
}
extension MedicationProfile {
static func testProfile(
genericName: String = "semaglutide",
brandName: String = "Ozempic",
currentDose: Double = 0.5
) -> MedicationProfile {
MedicationProfile(
genericName: genericName,
brandName: brandName,
currentDose: currentDose
)
}
}
Test Data Seeding
Using Preset Configurations
@Test("Test with seeded data")
@MainActor
func myTest() throws {
let container = try TestDataSeeding.createTestContainer()
let context = container.mainContext
let result = try TestDataSeeding.seedData(
into: context,
config: .thirtyDays
)
#expect(result.doses.count > 0)
#expect(result.adherenceRate >= 0.90)
}
Available Presets
| Preset | Days | Adherence | Use Case |
|---|
.sevenDays | 7 | 100% | Quick tests |
.thirtyDays | 30 | 95% | Standard tests |
.ninetyDays | 90 | 93% | Performance tests |
.oneYear | 365 | 92% | Stress tests |
.twoYears | 730 | 90% | Edge case tests |
Coverage Policy (5-Tier System)
See ./coverage-policy-tiers.md for complete coverage requirements including:
- Tier thresholds by category (90% for business logic, 85% for ViewModels, etc.)
- Coverage workflow commands
- Common coverage issues and solutions
Running Unit Tests
Using Project Scripts (Recommended)
./scripts/test.sh unit 1
./scripts/test.sh unit 1 MyServiceTests
./scripts/test.sh unit 1 --coverage
./scripts/test.sh unit 1 --coverage --log-only
cat logs/latest/raw_output.txt
Using xcodebuild Directly
xcodebuild test \
-scheme AppName \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.2' \
-only-testing:AppNameTests \
| xcbeautify
xcodebuild test \
-scheme AppName \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.2' \
-enableCodeCoverage YES \
-resultBundlePath .coverage/coverage.xcresult \
-only-testing:AppNameTests \
| xcbeautify
Swift Testing Framework Limitations
Unit Test Targeting Support:
- ✅ Target Level:
-only-testing:AppNameTests (all unit tests)
- ✅ Suite Level:
-only-testing:AppNameTests/MyServiceTests (specific test suite)
- ❌ Method Level: Swift Testing doesn't support individual method isolation
Integration Testing Patterns
Testing Component Interactions
@Test("AnalyticsService coordinates User, Dose, and MedicationProfile correctly")
@MainActor
func testAnalyticsServiceIntegration() async throws {
let (context, container) = createTestContext()
_ = container
let result = try TestDataSeeding.seedData(
into: context,
config: .thirtyDays
)
let analyticsService = AnalyticsService(context: context)
let summary = await analyticsService.generateUserSummary(for: result.user)
#expect(summary.overallAdherence >= 0.90)
#expect(summary.medicationEffectiveness.count > 0)
}
Complete Example
import Testing
@testable import AppName
@Suite("PharmacokineticsEngine Tests")
final class PharmacokineticsEngineTests {
func createTestContext() -> (ModelContext, ModelContainer) {
let schema = Schema([User.self, MedicationProfile.self, Dose.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
let container = try! ModelContainer(for: schema, configurations: [config])
return (ModelContext(container), container)
}
@Test("Calculate concentration for single dose")
@MainActor
func testSingleDoseConcentration() throws {
let (context, container) = createTestContext()
_ = container
let engine = PharmacokineticsEngine()
let dose = Dose(
amount: 1.0,
timestamp: Date().addingTimeInterval(-24 * 60 * 60),
medication: .semaglutide
)
let concentration = engine.calculateConcentration(
doses: [dose],
medication: .semaglutide,
at: Date()
)
#expect(concentration > 0.0)
#expect(concentration < 1.0)
}
@Test("Handle empty dose array gracefully")
@MainActor
func testEmptyDoseArray() {
let engine = PharmacokineticsEngine()
let concentration = engine.calculateConcentration(
doses: [],
medication: .semaglutide,
at: Date()
)
#expect(concentration == 0.0)
}
}
Common Mistakes and Best Practices
See ./unit-test-anti-patterns.md for complete reference including:
- Common mistakes table with problems and solutions
- Do's and Don'ts for Swift Testing
- SwiftData-specific anti-patterns
Simulator Conflicts
When running tests, ensure no conflicting simulators are active:
xcrun simctl list
Use 1 of the 3 available iPhone 17 Pro simulators to avoid conflicts:
./scripts/test.sh unit 1
./scripts/test.sh unit 2
./scripts/test.sh unit 3