ios-testing
iOS Testing Standards
These rules apply when working with Swift test files.
TDD Requirement
All changes MUST follow Test-Driven Development:
- Write a failing test first
- Write minimal code to make it pass
- Refactor while keeping tests green
- No code changes without corresponding tests
Framework: Swift Testing (NOT XCTest)
MUST use Swift Testing for all new tests:
- Use
@Suiteand@Testattributes (not XCTest classes) - Use
#expect()instead ofXCTAssert* - Use
Issue.record()instead ofXCTFail()
Three Testing Layers
| Layer | What | How |
|---|---|---|
| Pure functions | Correctness | Direct unit tests |
| ViewModels | Outcomes | Fakes wired up, verify state and captured data |
| Framework wrappers | Integration | Separate concern, integration tests or manual |
Fakes Over Mocks (Critical)
Use fakes that you control, not mocks that verify interactions.
Example fake from codebase:
class LocalFunctions: RekoDayFunctions {
var result: [String: Any]? = nil
var calledParams: [String: Any] = [:]
var calledFunc: String = ""
var error: Error? = nil
func callLambdaNamed(_ namedFunction: String,
withParameters params: [String: Any]) async throws -> [String: Any]? {
if let e = error { throw e }
self.calledFunc = namedFunction
self.calledParams = params
return result
}
}
Control what it returns, assert on outcomes.
Test Outcomes, Not Interactions
Fragile - tests implementation details:
func testSaveReko_callsProviderThenSendsPromotion() {
await viewModel.saveReko(user: user, rekoCount: 5)
XCTAssertTrue(mockProvider.createRekoCalled) // WRONG
}
Better - tests observable outcomes:
@Test("saveReko returns true when valid")
func saveRekoReturnsTrue() async {
let result = await viewModel.saveReko(user: user, rekoCount: 5)
#expect(result == true)
#expect(fakeProvider.createdReko?.name == "Test Reko")
}
@Test("saveReko returns false when address lookup fails")
func saveRekoFailsOnAddressError() async {
fakeAddressProvider.shouldFail = true
let result = await viewModel.saveReko(user: user, rekoCount: 5)
#expect(result == false)
}
Test File Structure
import Testing
@testable import Reko_Day_Dev
@Suite("Feature Name Tests")
@MainActor // Add if testing UI code
struct FeatureTests {
let provider: DataProvider
init() {
setupFirebase()
DataProvider.operatingMode = .local
provider = DataProvider(mode: .local)
}
@Test("Descriptive test name in sentence case")
func testSomething() async throws {
// Given: Setup
let viewModel = SomeViewModel(provider: provider)
// When: Action
await viewModel.doSomething()
// Then: Assertion
#expect(viewModel.result == expectedValue)
}
}
Running Tests
cd iOS
# Run all tests
./run-tests.sh
# Run specific test class (TDD workflow)
./run-tests.sh -t ShoppingCartTests
# Debug with full output
./run-tests.sh -f
Test Coverage Targets
| Component | Target | Current |
|---|---|---|
| ViewModels | 85%+ | ~60% |
| Providers | 90%+ | ~40% |
| Models | 95%+ | ~30% |
Requirements for New Code
- All new ViewModels: MUST have tests
- All new Providers: MUST have tests
- All error handling: MUST have tests
- All business logic: MUST have tests
File Naming
- Unit tests:
<ClassName>Tests.swift - Integration tests:
<Feature>IntegrationTests.swift - Location:
iOS/Reko DayTests/directory
UI Testing
Philosophy
UI tests should be the smallest layer. They're slow, flaky, and expensive.
Goal is confidence at critical paths, not coverage.
What to test (5-10 tests total, not 50):
- Authentication flows
- Core purchase flow
- Navigation between major features
Setup with Launch Arguments
enum LaunchArgument: String {
case uiTesting = "--uitesting"
case mockAuth = "--mock-auth"
case useEmulator = "--use-emulator"
}
// In AppDelegate or App init
func configureForTesting() {
let args = ProcessInfo.processInfo.arguments
if args.contains(LaunchArgument.uiTesting.rawValue) {
DataProvider.operatingMode = .emulator
}
if args.contains(LaunchArgument.mockAuth.rawValue) {
AuthProvider.shared = MockAuthProvider(user: .testUser)
}
}
Firebase Emulator Configuration
if args.contains(LaunchArgument.useEmulator.rawValue) {
Auth.auth().useEmulator(withHost: "localhost", port: 9099)
Firestore.firestore().useEmulator(withHost: "localhost", port: 8080)
Functions.functions().useEmulator(withHost: "localhost", port: 5001)
}
Avoiding Flakiness
Wait for elements, not timers:
let button = app.buttons["Pay Now"]
XCTAssertTrue(button.waitForExistence(timeout: 5))
Use accessibility identifiers:
// In View
Button("Pay Now")
.accessibilityIdentifier("cart.payNow")
// In test
app.buttons["cart.payNow"].tap()
Disable animations:
if args.contains(LaunchArgument.uiTesting.rawValue) {
UIView.setAnimationsEnabled(false)
}
Suggested Starting Tests
- Fresh user can sign up
- Existing user can sign in
- User can add item to cart and complete purchase
- User can create a Reko
Four tests. If those pass, the app fundamentally works.