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:
- Access KMP-generated Swift frameworks from test targets
- Avoid duplicate symbol errors from framework linking
- Enable proper mocking and dependency injection
- 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 -quietRunning 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/RoundCreationViewModelTestsProject 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!
| Correct | Incorrect |
|---|---|
| XCTest.framework only | Shared.framework added |
| Access KMP via host app | Direct 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:
| Element | Purpose |
|---|---|
@testable import | Access internal types from app |
#if canImport(Shared) | Conditional import for KMP framework |
@MainActor | Tests run on main actor for @MainActor types |
setUp/tearDown | Proper 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:ArcheryApprenticeUITestsExit 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 ArcheryApprenticeAsync Test Timing Issues
Solution: Add small delays for Combine publishers:
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1sRelated Documentation
- KMP GC Crash Investigation - Known XCTest crash issue
- KMP iOS Patterns - Swift-Kotlin interop
- iOS Bridge Pattern - Repository bridges
Last Updated: 2025-12-06