Home > Developer Guide > iOS > Testing


iOS Test Infrastructure

Purpose: Document iOS testing setup for KMP projects, including framework configuration, mock patterns, and known issues

Test Count: 123 iOS tests (100% pass rate)

Overview

Setting up iOS tests for a Kotlin Multiplatform (KMP) project requires special configuration to:

  1. Access KMP-generated Swift frameworks from test targets
  2. Avoid duplicate symbol errors from framework linking
  3. Enable proper mocking and dependency injection
  4. Support both XCTest unit tests and XCUITest integration tests

Quick Start: Running Tests

Command Line

# Navigate to iOS project directory
cd iosApp/ArcheryApprentice
 
# Run all tests on simulator
xcodebuild -workspace ArcheryApprentice.xcworkspace \
  -scheme ArcheryApprentice \
  -destination 'platform=iOS Simulator,name=iPhone 16e' \
  test
 
# Run with quiet output (less verbose)
xcodebuild -workspace ArcheryApprentice.xcworkspace \
  -scheme ArcheryApprentice \
  -destination 'platform=iOS Simulator,name=iPhone 16e' \
  test -quiet

Running Specific Test Classes

# Single test class
xcodebuild test \
  -workspace ArcheryApprentice.xcworkspace \
  -scheme ArcheryApprentice \
  -destination 'platform=iOS Simulator,name=iPhone 16e' \
  -only-testing:ArcheryApprenticeTests/ThemeManagerTests
 
# Multiple test classes
xcodebuild test \
  -workspace ArcheryApprentice.xcworkspace \
  -scheme ArcheryApprentice \
  -destination 'platform=iOS Simulator,name=iPhone 16e' \
  -only-testing:ArcheryApprenticeTests/ActiveScoringViewModelTests \
  -only-testing:ArcheryApprenticeTests/RoundCreationViewModelTests

Project Structure

iosApp/ArcheryApprentice/
├── ArcheryApprentice/                  # Main app target
│   ├── ViewModels/
│   ├── Screens/
│   ├── DI/
│   │   ├── RoundRepositoryBridge.swift
│   │   ├── EquipmentRepositoryBridge.swift
│   │   └── DependencyContainer.swift
│   └── ...
├── ArcheryApprenticeTests/            # Unit tests (XCTest)
│   ├── RoundListViewModelTests.swift
│   ├── RoundDetailViewModelTests.swift
│   ├── EquipmentListViewModelTests.swift
│   ├── ActiveScoringViewModelTests.swift
│   ├── RoundRepositoryBridgeTests.swift
│   └── DependencyContainerTests.swift
├── ArcheryApprenticeUITests/          # UI tests (XCUITest)
│   ├── ArcheryApprenticeUITests.swift
│   └── ArcheryApprenticeUITestsLaunchTests.swift
└── Shared.framework                   # KMP shared framework

XCTest Configuration

Framework Search Paths

Critical: Test targets need to find the KMP Shared.framework.

In your test target’s Build Settings:

FRAMEWORK_SEARCH_PATHS[arch=*] = (
    "$(inherited)",  // MUST BE FIRST - inherit from app target
    "$(SRCROOT)/../../shared/presentation/build/bin/iosSimulatorArm64/debugFramework",
);

Key Points:

  • $(inherited) MUST be first - inherits paths from app target
  • Add KMP framework build output directory
  • Use architecture-specific paths (iosSimulatorArm64 for M1/M2 Macs)

Framework Linking

IMPORTANT: Do NOT link Shared.framework directly in test target!

CorrectIncorrect
XCTest.framework onlyShared.framework added
Access KMP via host appDirect framework linking

Why: Test bundles access KMP code through the host app target. Direct linking causes duplicate symbol errors:

objc[12345]: Class _TtC6Shared12RoundStatus is implemented in both:
  - /path/to/App.app/Frameworks/Shared.framework/Shared
  - /path/to/Tests.xctest/Frameworks/Shared.framework/Shared

Test File Structure

Basic Test Setup

import XCTest
import FirebaseAuth
import FirebaseCore
import FirebaseFirestore
@testable import ArcheryApprentice
 
#if canImport(Shared)
import Shared
#endif
 
@MainActor
class RoundListViewModelTests: XCTestCase {
 
    var sut: RoundListViewModel!  // System Under Test
    var mockRepository: MockRoundRepository!
 
    override func setUp() {
        super.setUp()
        mockRepository = MockRoundRepository()
        sut = RoundListViewModel(repository: mockRepository)
    }
 
    override func tearDown() {
        sut = nil
        mockRepository = nil
        super.tearDown()
    }
 
    // Test methods...
}

Key Elements:

ElementPurpose
@testable importAccess internal types from app
#if canImport(Shared)Conditional import for KMP framework
@MainActorTests run on main actor for @MainActor types
setUp/tearDownProper test isolation

Mock Patterns

Protocol-Based Mocking

The Bridge Pattern enables easy mocking by using protocols instead of concrete types.

Define Protocol

protocol RoundRepositoryProtocol {
    func getAllRounds() async throws -> [Round]
    func getRoundWithDetails(roundId: Int32) async throws -> RoundWithDetails?
    func updateRoundStatus(roundId: Int32, status: RoundStatus) async throws
    func deleteRound(roundId: Int32) async throws
}

Implement Mock

class MockRoundRepository: RoundRepositoryProtocol {
    // Control test behavior
    var roundsToReturn: [Round] = []
    var shouldFail = false
    var errorToThrow: Error?
    var simulateDelay = false
 
    // Track method calls
    var getAllRoundsCalled = false
    var updateStatusCalled = false
    var lastUpdatedStatus: RoundStatus?
 
    func getAllRounds() async throws -> [Round] {
        getAllRoundsCalled = true
 
        if simulateDelay {
            try? await Task.sleep(nanoseconds: 500_000_000)
        }
 
        if shouldFail {
            throw errorToThrow ?? NSError(domain: "MockError", code: 500)
        }
 
        return roundsToReturn
    }
 
    // ... other methods
}

Benefits:

  • Controllable: Set exactly what data to return
  • Verifiable: Track method calls and arguments
  • Flexible: Simulate success, failure, delays
  • Type-Safe: Compiler ensures protocol conformance

Testing Async Code

Basic Async Test

func testLoadRounds() async {
    mockRepository.roundsToReturn = [testRound1, testRound2]
    await sut.loadRounds()
    XCTAssertEqual(sut.rounds.count, 2)
}

Testing Loading States

func testLoadRounds_setsLoadingState() async {
    mockRepository.simulateDelay = true
 
    let loadTask = Task {
        await sut.loadRounds()
    }
 
    try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s
    XCTAssertTrue(sut.isLoading)
 
    await loadTask.value
    XCTAssertFalse(sut.isLoading)
}

Testing Combine Publishers

func testSelectTab_filtersToInProgress() async {
    let testRounds = [
        createTestRound(id: 1, status: .planned),
        createTestRound(id: 2, status: .inProgress),
        createTestRound(id: 3, status: .completed)
    ]
    mockRepository.roundsToReturn = testRounds
    await sut.loadRounds()
 
    sut.selectTab(1)
 
    // Wait for Combine publishers to update
    try? await Task.sleep(nanoseconds: 100_000_000)
 
    XCTAssertEqual(sut.filteredRounds.count, 1)
}

Test Organization

MARK Comments Structure

class RoundListViewModelTests: XCTestCase {
 
    // MARK: - Properties
    var sut: RoundListViewModel!
    var mockRepository: MockRoundRepository!
 
    // MARK: - Setup & Teardown
    override func setUp() { ... }
    override func tearDown() { ... }
 
    // MARK: - Initialization Tests
    func testInit_startsWithEmptyRounds() { ... }
 
    // MARK: - Load Rounds Tests
    func testLoadRounds_fetchesFromRepository() async { ... }
 
    // MARK: - Filter Tests
    func testSelectTab_filtersToInProgress() async { ... }
 
    // MARK: - Error Handling Tests
    func testError_setsErrorMessage() async { ... }
 
    // MARK: - Helper Methods
    private func createTestRound(...) -> Round { ... }
}

Test Naming Convention

Format: test<MethodUnderTest>_<scenario>_<expectedBehavior>

Examples:

  • testInit_startsWithEmptyRounds()
  • testLoadRounds_fetchesFromRepository()
  • testLoadRounds_setsLoadingState()
  • testSelectTab_filtersToInProgress()

KMP Enum Comparisons

Remember to use .name comparison for KMP enums:

// Wrong - identity comparison fails
XCTAssertEqual(round.status, .inProgress)
 
// Correct - compare by name
XCTAssertEqual(round.status.name, RoundStatus.inProgress.name)

See KMP iOS Patterns for more details.


CI/CD Integration

Running Tests in CI

# Run unit tests
xcodebuild test \
  -workspace ArcheryApprentice.xcworkspace \
  -scheme ArcheryApprentice \
  -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
  -only-testing:ArcheryApprenticeTests
 
# Run UI tests
xcodebuild test \
  -workspace ArcheryApprentice.xcworkspace \
  -scheme ArcheryApprentice \
  -destination 'platform=iOS Simulator,name=iPhone 15,OS=17.0' \
  -only-testing:ArcheryApprenticeUITests

Exit Code Handling

The xcodebuild command returns exit code 0 when all tests pass, even if it prints “TEST FAILED” due to the post-test KMP GC crash (see below). For CI/CD, parse the test output:

xcodebuild test ... 2>&1 | grep -E "Executed \d+ tests, with \d+ failures"

Troubleshooting

Cannot find ‘Shared’ in scope

Solution: Verify framework search paths include KMP framework output:

FRAMEWORK_SEARCH_PATHS[arch=*] = (
    "$(inherited)",
    "$(SRCROOT)/../../shared/presentation/build/bin/iosSimulatorArm64/debugFramework",
);

Duplicate Symbol Errors

objc[12345]: Class _TtC6Shared12RoundStatus is implemented in both...

Solution: Remove Shared.framework from test target’s “Link Binary With Libraries”

Tests Can’t Access App Code

Solution: Use @testable import:

@testable import ArcheryApprentice

Async Test Timing Issues

Solution: Add small delays for Combine publishers:

try? await Task.sleep(nanoseconds: 100_000_000) // 0.1s


Last Updated: 2025-12-06