| name | ui-testing |
| description | Write, review, or orchestrate XCUITest UI tests — accessibility identifiers, page objects, test helpers, and test execution. |
| user-invocable | false |
UI Testing Guide
Architecture Overview
UI tests live in the VultisigAppUITests target and use Apple's XCUITest framework. The testing architecture follows three layers:
┌─────────────────────────────────────────┐
│ Test Cases (XCTestCase subclasses) │ — what to verify
├─────────────────────────────────────────┤
│ Page Objects (Screen abstractions) │ — how to interact
├─────────────────────────────────────────┤
│ Accessibility IDs (in app source) │ — where to find elements
└─────────────────────────────────────────┘
Directory Structure
VultisigApp/VultisigAppUITests/
├── Helpers/
│ ├── XCUIApplication+Launch.swift # Launch helpers & environment config
│ └── XCUIElement+Helpers.swift # waitForExistence, tap-if-exists, etc.
├── Pages/
│ ├── HomePage.swift # Home screen interactions
│ ├── VaultSelectorPage.swift # Vault selector overlay
│ ├── SendPage.swift # Send transaction flow
│ ├── SettingsPage.swift # Settings screen
│ └── ... # One page per screen
├── Tests/
│ ├── HomeScreenTests.swift # Home screen test cases
│ ├── SendFlowTests.swift # Send transaction tests
│ ├── VaultCreationTests.swift # Vault creation tests
│ ├── SettingsTests.swift # Settings tests
│ └── LaunchTests.swift # App launch & screenshot tests
└── VultisigAppUITests.swift # Base test class (shared setup)
Accessibility Identifiers
The AccessibilityID Enum
All identifiers are defined in a single enum in the main app target (not the test target), organized by screen:
File: VultisigApp/VultisigApp/Utils/AccessibilityID.swift
import Foundation
enum AccessibilityID {
enum Home {
static let walletTab = "home.walletTab"
static let defiTab = "home.defiTab"
static let agentTab = "home.agentTab"
static let settingsButton = "home.settingsButton"
static let historyButton = "home.historyButton"
static let vaultSelector = "home.vaultSelector"
static let cameraButton = "home.cameraButton"
}
enum VaultSelector {
static let container = "vaultSelector.container"
static let addVaultButton = "vaultSelector.addVaultButton"
static func vaultCell(name: String) -> String {
"vaultSelector.vault.\(name)"
}
}
enum Send {
static let amountField = "send.amountField"
static let addressField = "send.addressField"
static let memoField = "send.memoField"
static let continueButton = "send.continueButton"
static let coinSelector = "send.coinSelector"
static let maxButton = "send.maxButton"
}
enum Verify {
static let confirmButton = "verify.confirmButton"
static let amountLabel = "verify.amountLabel"
static let addressLabel = "verify.addressLabel"
static let feeLabel = "verify.feeLabel"
}
enum Settings {
static let container = "settings.container"
static let languageCell = "settings.languageCell"
static let currencyCell = "settings.currencyCell"
static let vaultSettingsCell = "settings.vaultSettingsCell"
static let faqCell = "settings.faqCell"
}
enum Onboarding {
static let createVaultButton = "onboarding.createVaultButton"
static let importVaultButton = "onboarding.importVaultButton"
static let vaultNameField = "onboarding.vaultNameField"
}
}
Applying Identifiers in Views
Add .accessibilityIdentifier() to interactive and assertable elements:
PrimaryButton(title: "continue".localized, type: .primary, size: .medium) {
onContinue()
}
.accessibilityIdentifier(AccessibilityID.Send.continueButton)
CommonTextField(text: $amount, label: "amount".localized, placeholder: "0.0")
.accessibilityIdentifier(AccessibilityID.Send.amountField)
Button { selectedTab = .wallet } label: { ... }
.accessibilityIdentifier(AccessibilityID.Home.walletTab)
Button(action: onSettings) { Image(systemName: "gear") }
.accessibilityIdentifier(AccessibilityID.Home.settingsButton)
VaultCell(vault: vault)
.accessibilityIdentifier(AccessibilityID.VaultSelector.vaultCell(name: vault.name))
Rules for Identifiers
- Namespace by screen —
"screen.element" format (e.g., "home.settingsButton")
- Only tag interactive or assertable elements — buttons, fields, labels you'll assert on, cells you'll tap
- Use static strings for fixed elements, functions for dynamic ones — cells with IDs, lists with indices
- Define ALL identifiers in
AccessibilityID — never use inline string literals in .accessibilityIdentifier()
- Keep the enum in the main target — it's app code, not test code
Page Objects
Each screen gets a page object that encapsulates element queries and interactions. Page objects live in the test target.
Pattern
import XCTest
struct HomePage {
let app: XCUIApplication
var walletTab: XCUIElement {
app.buttons[AccessibilityID.Home.walletTab]
}
var defiTab: XCUIElement {
app.buttons[AccessibilityID.Home.defiTab]
}
var settingsButton: XCUIElement {
app.buttons[AccessibilityID.Home.settingsButton]
}
var vaultSelector: XCUIElement {
app.buttons[AccessibilityID.Home.vaultSelector]
}
@discardableResult
func tapWalletTab() -> Self {
walletTab.tap()
return self
}
@discardableResult
func tapSettings() -> SettingsPage {
settingsButton.tap()
return SettingsPage(app: app)
}
@discardableResult
func tapVaultSelector() -> VaultSelectorPage {
vaultSelector.tap()
return VaultSelectorPage(app: app)
}
func assertVisible(timeout: TimeInterval = 5) {
XCTAssertTrue(walletTab.waitForExistence(timeout: timeout), "Home screen not visible")
}
}
Key Principles
- Return
self or the next page — enables fluent chaining: homePage.tapSettings().assertVisible()
- Use
@discardableResult — callers can ignore the return when they don't chain
- Element queries use
AccessibilityID constants — the page never contains string literals
- Assertions live in the page —
assertVisible(), assertAmount(equals:), etc.
- One file per screen — matches the
*Screen.swift naming in the app
Page Objects Need AccessibilityID Visibility
Since AccessibilityID is defined in the main app target, the test target needs access. Two options:
Option A (recommended): Add AccessibilityID.swift to both targets in the project file (main + UI test target membership).
Option B: Import the app module in the test target:
@testable import VultisigApp
Test Helpers
App Launch Configuration
import XCTest
extension XCUIApplication {
func launchForTesting() {
launchArguments += ["-UITesting"]
launchArguments += ["-disableAnimations"]
launchEnvironment["UI_TESTING"] = "1"
launch()
}
func launchWithVault(named name: String) {
launchEnvironment["TEST_VAULT_NAME"] = name
launchForTesting()
}
func launchSkippingAuth() {
launchArguments += ["-skipAuthentication"]
launchForTesting()
}
}
App-Side Launch Argument Handling
The app must check for these arguments to configure test mode:
#if DEBUG
if CommandLine.arguments.contains("-UITesting") {
}
if CommandLine.arguments.contains("-skipAuthentication") {
}
if CommandLine.arguments.contains("-disableAnimations") {
UIView.setAnimationsEnabled(false)
}
if let testVault = ProcessInfo.processInfo.environment["TEST_VAULT_NAME"] {
}
#endif
Element Wait Helpers
import XCTest
extension XCUIElement {
func waitAndTap(timeout: TimeInterval = 5, file: StaticString = #file, line: UInt = #line) {
let exists = waitForExistence(timeout: timeout)
XCTAssertTrue(exists, "Element \(identifier) not found after \(timeout)s", file: file, line: line)
tap()
}
func tapIfExists(timeout: TimeInterval = 2) {
if waitForExistence(timeout: timeout) {
tap()
}
}
func clearAndType(_ text: String) {
guard exists else { return }
tap()
if let value = value as? String, !value.isEmpty {
let selectAll = XCUIApplication().menuItems["Select All"]
if selectAll.exists {
selectAll.tap()
}
typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: value.count))
}
typeText(text)
}
func assertLabelContains(_ text: String, file: StaticString = #file, line: UInt = #line) {
let labelValue = label
XCTAssertTrue(labelValue.contains(text),
"Expected label to contain '\(text)', got '\(labelValue)'",
file: file, line: line)
}
}
Writing Test Cases
Base Test Class
import XCTest
class VultisigUITestCase: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
}
override func tearDownWithError() throws {
app = nil
}
var homePage: HomePage { HomePage(app: app) }
var sendPage: SendPage { SendPage(app: app) }
var settingsPage: SettingsPage { SettingsPage(app: app) }
var vaultSelectorPage: VaultSelectorPage { VaultSelectorPage(app: app) }
func launchToHome() {
app.launchSkippingAuth()
homePage.assertVisible()
}
func takeScreenshot(name: String) {
let screenshot = app.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}
Test Case Example
import XCTest
final class HomeScreenTests: VultisigUITestCase {
func testHomeScreenShowsTabs() throws {
launchToHome()
homePage.assertVisible()
XCTAssertTrue(homePage.walletTab.exists)
XCTAssertTrue(homePage.defiTab.exists)
}
func testNavigateToSettings() throws {
launchToHome()
let settings = homePage.tapSettings()
settings.assertVisible()
}
func testSwitchTabs() throws {
launchToHome()
homePage.tapWalletTab()
homePage.tapDefiTab()
}
func testVaultSelectorOpens() throws {
launchToHome()
let selector = homePage.tapVaultSelector()
selector.assertVisible()
}
}
Multi-Step Flow Example
import XCTest
final class SendFlowTests: VultisigUITestCase {
func testSendFlowNavigatesToVerify() throws {
launchToHome()
sendPage.assertVisible()
sendPage
.enterAmount("0.001")
.enterAddress("0x1234567890abcdef1234567890abcdef12345678")
.tapContinue()
let verifyPage = VerifyPage(app: app)
verifyPage.assertVisible()
}
}
Running UI Tests
UI tests have their own dedicated scheme (VultisigAppUITests) and test plan (VultisigAppUITests.xctestplan), separate from unit tests. This keeps CI fast — unit tests run on the main VultisigApp scheme, UI tests run on VultisigAppUITests.
Command Line
Prefer the Makefile target (see /make skill) for full runs:
make ui_test
make ui_test DESTINATION='platform=iOS Simulator,name=iPhone 16 Pro,OS=18.5'
Drop to raw xcodebuild only when you need -only-testing: filtering. Prepend make generate so newly added test files are in the project:
make generate
cd VultisigApp && xcodebuild test \
-project VultisigApp.xcodeproj \
-scheme VultisigAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:VultisigAppUITests/HomeScreenTests \
-skipPackagePluginValidation
make generate
cd VultisigApp && xcodebuild test \
-project VultisigApp.xcodeproj \
-scheme VultisigAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:VultisigAppUITests/HomeScreenTests/testHomeScreenShowsTabs \
-skipPackagePluginValidation
Scheme & Test Plan Structure
| Scheme | Test Plan | Targets | Purpose |
|---|
VultisigApp | VultisigApp.xctestplan | VultisigAppTests | Unit + integration tests (CI) |
VultisigAppUITests | VultisigAppUITests.xctestplan | VultisigAppUITests | UI tests (separate CI step) |
Workflow: Adding UI Tests for a New Screen
Follow this checklist when adding UI tests for a screen:
Step 1 — Define Accessibility IDs
- Open
VultisigApp/VultisigApp/Utils/AccessibilityID.swift
- Add a new nested enum for the screen (e.g.,
enum Swap { ... })
- Add
static let constants for each interactive/assertable element
- Use functions for dynamic elements (cells, list items)
Step 2 — Apply IDs to the View
- Open the screen's SwiftUI view file
- Add
.accessibilityIdentifier(AccessibilityID.Screen.element) to each tagged element
- Focus on: buttons, text fields, labels with dynamic content, navigation triggers, cells
Step 3 — Create the Page Object
- Create
VultisigAppUITests/Pages/{Screen}Page.swift
- Add element properties using
app.buttons[...], app.textFields[...], etc.
- Add action methods that return
self or the destination page
- Add
assertVisible() and any screen-specific assertions
Step 4 — Write Test Cases
- Create
VultisigAppUITests/Tests/{Screen}Tests.swift
- Subclass
VultisigUITestCase
- Write focused tests — one behavior per test method
- Use page objects for all interactions (never query elements directly in tests)
Step 5 — Run and Verify
Always regenerate the project first — new Swift files aren't in VultisigApp.xcodeproj until xcodegen picks them up.
Filtered runs need raw xcodebuild; full runs go through make ui_test:
make generate
cd VultisigApp && xcodebuild test \
-project VultisigApp.xcodeproj \
-scheme VultisigAppUITests \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' \
-only-testing:VultisigAppUITests/{TestClass} \
-skipPackagePluginValidation
App-Side Test Mode Setup
For UI tests to work reliably, the app needs test-mode hooks. Add these checks wrapped in #if DEBUG:
Required Changes
VultisigApp.swift — Check for -UITesting launch argument:
#if DEBUG
private var isUITesting: Bool {
CommandLine.arguments.contains("-UITesting")
}
#endif
AppViewModel.swift — Skip authentication in test mode:
#if DEBUG
if CommandLine.arguments.contains("-skipAuthentication") {
isAuthenticated = true
showSplashView = false
return
}
#endif
Animations — Disable for test stability:
#if DEBUG
if CommandLine.arguments.contains("-disableAnimations") {
UIView.setAnimationsEnabled(false)
}
#endif
Naming Conventions
| Type | Pattern | Example |
|---|
| Test classes | {Feature}Tests.swift | HomeScreenTests.swift |
| Page objects | {Screen}Page.swift | HomePage.swift |
| Test methods | test{Behavior} | testNavigateToSettings |
| Accessibility IDs | {screen}.{element} | "home.settingsButton" |
| Helpers | Descriptive name | XCUIElement+Helpers.swift |
Common XCUIElement Queries
| SwiftUI Element | XCUITest Query |
|---|
Button | app.buttons[id] |
TextField | app.textFields[id] |
SecureField | app.secureTextFields[id] |
Text | app.staticTexts[id] |
Image | app.images[id] |
Toggle | app.switches[id] |
NavigationLink | app.buttons[id] |
List / ForEach cell | app.cells[id] or app.buttons[id] |
TabView item | app.buttons[id] |
ScrollView | app.scrollViews[id] |
Alert button | app.alerts.buttons["OK"] |
Sheet content | app.sheets.firstMatch |
Tips
- Always use
waitForExistence(timeout:) before interacting — SwiftUI renders asynchronously
- Disable animations via launch arguments for faster, more reliable tests
- One assertion focus per test — test methods should be small and specific
- Use screenshots (
takeScreenshot(name:)) at key checkpoints for debugging failures
- Avoid sleep — use
waitForExistence or XCTNSPredicateExpectation instead
- Test in landscape and portrait if the app supports both:
XCUIDevice.shared.orientation = .landscapeLeft