| name | ui-tests |
| description | UI tests with Robot pattern. Use when creating UI tests, implementing Robot classes, or adding accessibility identifiers. |
Skill: UI Tests
Guide for creating UI tests using XCTest with the Robot pattern.
References
File Structure
App/Tests/UI/
├── HomeUITests.swift # Home screen flow (launch from home)
├── CharacterListUITests.swift # Character list (deep link)
├── CharacterDetailUITests.swift # Character detail (deep link)
├── CharacterEpisodesUITests.swift # Character episodes (deep link)
└── NotFoundUITests.swift # Invalid deep link → not found screen
App/Tests/Shared/
├── Robots/
│ ├── Robot.swift # UITestCase base class
│ ├── HomeRobot.swift
│ ├── AboutRobot.swift
│ ├── NotFoundRobot.swift
│ ├── CharacterListRobot.swift
│ ├── CharacterDetailRobot.swift
│ ├── CharacterFilterRobot.swift
│ └── CharacterEpisodesRobot.swift
└── Scenarios/
└── UITestCase+Scenarios.swift
Robot Pattern Rules
| Rule | Description |
|---|
Extend UITestCase | Inherits mock server setup, teardown, and robot DSL |
async throws on test methods | Required for await serverMock.registerCatchAll |
@MainActor on test methods | Required for UI interactions (XCUIApplication) |
| Actions section | Methods that perform UI interactions (tap, swipe, type) |
| Verifications section | Methods that assert UI state |
@discardableResult | All robot methods return Self for chaining |
#filePath and line | Pass through for accurate test failure locations |
| Private AccessibilityIdentifier | Each Robot has its own copy of identifiers |
.firstMatch | Use when multiple elements may match an identifier |
Test Patterns
Flow Tests — navigate through the app from home
Use launch() and navigate through the app via robots. Best for multi-screen flows starting from home. One comprehensive test per flow.
final class HomeUITests: UITestCase {
@MainActor
func testHomeFlowAboutSheetCharacterListAndBack() async throws {
try await givenCharacterListSucceeds()
launch()
home { robot in
robot.verifyIsVisible()
robot.tapInfoButton()
}
about { robot in
robot.verifyIsVisible()
robot.swipeUp()
robot.verifyCreditsExist()
robot.tapClose()
}
home { robot in
robot.verifyIsVisible()
robot.tapCharacterButton()
}
characterList { robot in
robot.verifyIsVisible()
robot.tapBack()
}
home { robot in
robot.verifyIsVisible()
}
}
}
Screen Tests — deep link directly to a screen
Use launch(deepLink: url) to navigate directly to a specific screen. Best for comprehensive single-screen tests covering error/retry, main interactions, and navigation. One test class per screen with a single comprehensive test method. Use // swiftlint:disable:next function_body_length for long test methods.
final class CharacterDetailUITests: UITestCase {
@MainActor
func testCharacterDetailErrorRetryRefreshEpisodesAndBack() async throws {
await givenAllRequestsFail()
let url = try XCTUnwrap(URL(string: "challenge://character/detail/1"))
launch(deepLink: url)
characterDetail { robot in
robot.verifyErrorIsVisible()
}
try await givenCharacterDetailSucceeds()
characterDetail { robot in
robot.tapRetry()
robot.verifyIsVisible()
robot.pullToRefresh()
robot.verifyIsVisible()
}
try await givenCharacterEpisodesRecovers()
characterDetail { robot in
robot.tapEpisodes()
}
characterEpisodes { robot in
robot.verifyIsVisible()
robot.tapBack()
}
characterDetail { robot in
robot.verifyIsVisible()
}
}
}
Runtime Deep Link Tests — launch() + app.open(url)
Use launch() then app.open(url) when testing deep links that the app handles at runtime (not at launch). This is required for invalid/unknown routes because DEEP_LINK_URL env var only resolves known routes at launch time.
final class NotFoundUITests: UITestCase {
@MainActor
func testNotFoundScreenAndGoBack() async throws {
await givenAllRequestsReturnNotFound()
launch()
let url = try XCTUnwrap(URL(string: "challenge://invalid/route"))
home { robot in
robot.verifyIsVisible()
}
app.open(url)
notFound { robot in
robot.verifyIsVisible()
robot.tapGoBack()
}
home { robot in
robot.verifyIsVisible()
}
}
}
Deep Link URLs
| Screen | URL |
|---|
| Character List | challenge://character/list |
| Character Detail | challenge://character/detail/{id} |
| Character Episodes | challenge://episode/character/{id} |
Screen Test Flow Pattern
Each screen test follows the same structure:
- Error —
givenAllRequestsFail() + launch(deepLink: url) → verify error
- Recovery — register success scenario mid-test (replaces catch-all)
- Retry — tap retry → verify content loads
- Interactions — pull-to-refresh, pagination, filters, etc.
- Navigation — navigate to related screen → verify → tap back → verify return
Accessibility Identifiers in Views
Rules
- Private to each View — defined as a private enum at the bottom of the View file
- Naming convention —
{screenName}.{elementType} (e.g., home.characterButton)
- Dynamic identifiers — use static functions for elements with IDs (e.g.,
row(id:))
- DS propagation — pass
accessibilityIdentifier: to DS components for child propagation
Propagated Identifiers
When using accessibilityIdentifier: "characterList.row.1" on DSCardInfoRow:
- Container:
characterList.row.1
- Image (via
DSAsyncImage + SwiftUI modifier): characterList.row.1.image
- Title text:
characterList.row.1.title
DSStatusIndicator: characterList.row.1.status
Build & Verify
Run a specific test class:
mise x -- tuist test "ChallengeUITests" -- -only-testing:ChallengeUITests/CharacterDetailUITests
Run all UI tests:
mise x -- tuist test "ChallengeUITests" 2>&1 | tee /tmp/ui-tests.txt | tail -30
Checklist
Robot Implementation
UI Test
View Accessibility