Total rebrand across KMM project: - Kotlin package: com.example.casera -> com.tt.honeyDue (dirs + declarations) - Gradle: rootProject.name, namespace, applicationId - Android: manifest, strings.xml (all languages), widget resources - iOS: pbxproj bundle IDs, Info.plist, entitlements, xcconfig - iOS directories: Casera/ -> HoneyDue/, CaseraTests/ -> HoneyDueTests/, etc. - Swift source: all class/struct/enum renames - Deep links: casera:// -> honeydue://, .casera -> .honeydue - App icons replaced with honeyDue honeycomb icon - Domains: casera.treytartt.com -> honeyDue.treytartt.com - Bundle IDs: com.tt.casera -> com.tt.honeyDue - Database table names preserved Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
15 KiB
XCUITest Implementation Guide
Overview
This guide provides step-by-step instructions for implementing comprehensive UI testing for the HoneyDue iOS app using XCUITest.
Table of Contents
Project Setup
Current Status
✅ Already Done:
- UI Test target exists:
HoneyDueTests - Base test infrastructure in place (
TestHelpers.swift,BaseUITest) - Initial test files created
What's New:
- Centralized Accessibility Identifiers:
iosApp/Helpers/AccessibilityIdentifiers.swift - Comprehensive Test Suite: Based on
AUTOMATED_TEST_EXECUTION_PLAN.md - Enhanced Test Utilities: Improved helpers for common operations
Adding Accessibility Identifiers
Step 1: Import the Identifiers File
Ensure all view files import the identifiers:
import SwiftUI
// No import needed - same module
Step 2: Add Identifiers to Views
For each interactive element in your views, add the .accessibilityIdentifier() modifier.
Example: LoginView.swift
// Username Field
TextField("Enter your email", text: $viewModel.username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField)
// Password Field
SecureField("Enter your password", text: $viewModel.password)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField)
// Login Button
Button(action: viewModel.login) {
Text("Sign In")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
// Sign Up Button
Button("Sign Up") {
showingRegister = true
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton)
Example: RegisterView.swift
TextField("Username", text: $viewModel.username)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerUsernameField)
TextField("Email", text: $viewModel.email)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerEmailField)
SecureField("Password", text: $viewModel.password)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerPasswordField)
SecureField("Confirm Password", text: $viewModel.confirmPassword)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerConfirmPasswordField)
Button("Register") {
viewModel.register()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.registerButton)
Example: MainTabView.swift
TabView(selection: $selectedTab) {
NavigationView {
ResidencesListView()
}
.tabItem {
Label("Residences", systemImage: "house.fill")
}
.tag(0)
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
// Repeat for other tabs...
}
Example: ResidenceFormView.swift
TextField("Property Name", text: $name)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.nameField)
Picker("Property Type", selection: $selectedPropertyType) {
// ...
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.propertyTypePicker)
TextField("Street Address", text: $streetAddress)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.streetAddressField)
TextField("City", text: $city)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.cityField)
TextField("State/Province", text: $stateProvince)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.stateProvinceField)
TextField("Postal Code", text: $postalCode)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.postalCodeField)
TextField("Country", text: $country)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.countryField)
TextField("Bedrooms", text: $bedrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bedroomsField)
TextField("Bathrooms", text: $bathrooms)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.bathroomsField)
Toggle("Primary Residence", isOn: $isPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.isPrimaryToggle)
Button("Save") {
saveResidence()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.saveButton)
Example: TaskFormView.swift
TextField("Title", text: $title)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.titleField)
TextField("Description", text: $description)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.descriptionField)
Picker("Category", selection: $selectedCategory) {
// ...
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.categoryPicker)
Picker("Frequency", selection: $selectedFrequency) {
// ...
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.frequencyPicker)
Picker("Priority", selection: $selectedPriority) {
// ...
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.priorityPicker)
DatePicker("Due Date", selection: $dueDate)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.dueDatePicker)
TextField("Estimated Cost", text: $estimatedCost)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.estimatedCostField)
Button("Save") {
saveTask()
}
.accessibilityIdentifier(AccessibilityIdentifiers.Task.saveButton)
Example: ResidenceDetailView.swift
// Add Task Button (FAB or toolbar button)
Button(action: { showingAddTask = true }) {
Image(systemName: "plus")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.addTaskButton)
// Edit Button
Button("Edit") {
showingEditForm = true
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.editButton)
// Delete Button
Button("Delete", role: .destructive) {
showingDeleteConfirmation = true
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.deleteButton)
Step 3: Dynamic Identifiers for List Items
For list items (residence cards, task cards, etc.), use dynamic identifiers:
ForEach(residences, id: \.id) { residence in
ResidenceCard(residence: residence)
.accessibilityIdentifier("\(AccessibilityIdentifiers.Residence.residenceCard).\(residence.id)")
.onTapGesture {
selectedResidence = residence
}
}
Quick Reference: Files to Update
Here's a checklist of all views that need accessibility identifiers:
Authentication (Priority: High)
- ✅
/iosApp/Login/LoginView.swift- Partially done - ⬜
/iosApp/Register/RegisterView.swift - ⬜
/iosApp/VerifyEmail/VerifyEmailView.swift - ⬜
/iosApp/PasswordReset/ForgotPasswordView.swift
Navigation (Priority: High)
- ⬜
/iosApp/MainTabView.swift - ⬜
/iosApp/ContentView.swift
Residence (Priority: High)
- ⬜
/iosApp/ResidenceFormView.swift - ⬜
/iosApp/Residence/ResidencesListView.swift - ⬜
/iosApp/Residence/ResidenceDetailView.swift - ⬜
/iosApp/AddResidenceView.swift - ⬜
/iosApp/EditResidenceView.swift
Task (Priority: High)
- ⬜
/iosApp/Task/TaskFormView.swift - ⬜
/iosApp/Task/AddTaskView.swift - ⬜
/iosApp/Task/EditTaskView.swift - ⬜
/iosApp/Task/AllTasksView.swift - ⬜
/iosApp/Task/CompleteTaskView.swift
Contractor (Priority: Medium)
- ⬜
/iosApp/Contractor/ContractorsListView.swift - ⬜
/iosApp/Contractor/ContractorDetailView.swift
Document (Priority: Medium)
- ⬜
/iosApp/Documents/DocumentsWarrantiesView.swift - ⬜
/iosApp/Documents/AddDocumentView.swift - ⬜
/iosApp/Documents/DocumentDetailView.swift
Profile (Priority: Medium)
- ⬜
/iosApp/Profile/ProfileView.swift - ⬜
/iosApp/Profile/ProfileTabView.swift
Running the Tests
In Xcode
-
Open the project:
cd /Users/treyt/Desktop/code/HoneyDue/HoneyDueKMM/iosApp open iosApp.xcodeproj -
Select the test target:
- Product → Scheme → HoneyDueTests
-
Choose a simulator:
- iPhone 15 Pro (recommended)
- iOS 17.0+
-
Run all tests:
- Press
⌘ + U(Command + U) - Or: Product → Test
- Press
-
Run specific test:
- Open test file (e.g.,
AuthenticationUITests.swift) - Click the diamond icon next to the test method
- Or: Right-click → Run Test
- Open test file (e.g.,
From Command Line
# Navigate to iOS app directory
cd /Users/treyt/Desktop/code/HoneyDue/HoneyDueKMM/iosApp
# Run all tests
xcodebuild test \
-project iosApp.xcodeproj \
-scheme HoneyDueTests \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=17.0'
# Run specific test class
xcodebuild test \
-project iosApp.xcodeproj \
-scheme HoneyDueTests \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=17.0' \
-only-testing:HoneyDueTests/AuthenticationUITests
# Run specific test method
xcodebuild test \
-project iosApp.xcodeproj \
-scheme HoneyDueTests \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=17.0' \
-only-testing:HoneyDueTests/AuthenticationUITests/testLoginWithValidCredentials
Test Results
Test results are saved to:
~/Library/Developer/Xcode/DerivedData/iosApp-*/Logs/Test/
Continuous Integration
GitHub Actions Example
Create .github/workflows/ios-tests.yml:
name: iOS UI Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: macos-14 # macOS Sonoma with Xcode 15+
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.2'
- name: Start Django Backend
run: |
cd honeyDueAPI
docker-compose up -d
sleep 10 # Wait for backend to start
- name: Check Backend Health
run: |
curl --retry 5 --retry-delay 3 http://localhost:8000/api/
- name: Run iOS UI Tests
run: |
cd HoneyDueKMM/iosApp
xcodebuild test \
-project iosApp.xcodeproj \
-scheme HoneyDueTests \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro,OS=17.2' \
-resultBundlePath TestResults.xcresult \
-enableCodeCoverage YES
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: HoneyDueKMM/iosApp/TestResults.xcresult
- name: Upload Screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: ~/Library/Developer/CoreSimulator/Devices/*/data/tmp/
if-no-files-found: ignore
- name: Stop Docker Containers
if: always()
run: |
cd honeyDueAPI
docker-compose down
##Test Configuration
Test Plan (Optional)
Create a .xctestplan file for better organization:
- In Xcode: File → New → Test Plan
- Name it:
HoneyDueTestPlan.xctestplan - Configure:
- Configurations: Debug, Release
- Test Targets: HoneyDueTests
- Code Coverage: Enable
- Screenshots: Automatically on failure
Launch Arguments
Configure launch arguments for testing:
// In test setUp()
app.launchArguments = [
"--uitesting", // Flag for UI testing mode
"--disable-animations", // Speed up tests
"--reset-user-defaults", // Clean state
"--use-test-api" // Point to test backend
]
app.launch()
Handle in your app:
// In AppDelegate or App init
if ProcessInfo.processInfo.arguments.contains("--uitesting") {
// Disable animations
UIView.setAnimationsEnabled(false)
// Clear user defaults
if ProcessInfo.processInfo.arguments.contains("--reset-user-defaults") {
let domain = Bundle.main.bundleIdentifier!
UserDefaults.standard.removePersistentDomain(forName: domain)
}
// Use test API
if ProcessInfo.processInfo.arguments.contains("--use-test-api") {
ApiConfig.CURRENT_ENV = .LOCAL
}
}
Best Practices
1. Write Maintainable Tests
// ✅ Good: Descriptive test names
func testUserCanLoginWithValidCredentials() { }
// ❌ Bad: Vague test names
func testLogin() { }
2. Use Page Object Pattern
// Create a LoginPage helper
struct LoginPage {
let app: XCUIApplication
var usernameField: XCUIElement {
app.textFields[AccessibilityIdentifiers.Authentication.usernameField]
}
var passwordField: XCUIElement {
app.secureTextFields[AccessibilityIdentifiers.Authentication.passwordField]
}
var loginButton: XCUIElement {
app.buttons[AccessibilityIdentifiers.Authentication.loginButton]
}
func login(username: String, password: String) {
usernameField.tap()
usernameField.typeText(username)
passwordField.tap()
passwordField.typeText(password)
loginButton.tap()
}
}
// Use in tests
func testLogin() {
let loginPage = LoginPage(app: app)
loginPage.login(username: "testuser", password: "password")
// Assert...
}
3. Wait for Elements
// ✅ Good: Wait with timeout
let loginButton = app.buttons["Login"]
XCTAssertTrue(loginButton.waitForExistence(timeout: 5))
// ❌ Bad: Assume immediate availability
XCTAssertTrue(app.buttons["Login"].exists)
4. Clean State Between Tests
override func tearDown() {
// Logout if logged in
if app.tabBars.exists {
logout()
}
super.tearDown()
}
5. Use Meaningful Assertions
// ✅ Good: Clear assertion messages
XCTAssertTrue(
app.tabBars.exists,
"Should navigate to main tab view after login"
)
// ❌ Bad: No context
XCTAssertTrue(app.tabBars.exists)
Troubleshooting
Common Issues
Issue: Elements not found
- Solution: Verify accessibility identifier is added to the view
- Debug: Use
app.debugDescriptionto see all elements
Issue: Tests are flaky
- Solution: Add waits (
waitForExistence) before interactions - Solution: Disable animations
Issue: Tests run slow
- Solution: Use
--disable-animationslaunch argument - Solution: Run tests in parallel (Xcode 13+)
Issue: Backend not ready
- Solution: Add health check before tests
- Solution: Increase wait time in pre-flight checks
Debugging Tips
// Print all elements
print(app.debugDescription)
// Take screenshot manually
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways
add(attachment)
// Breakpoint in test
// Use Xcode's test navigator to pause at failures
Next Steps
- ✅ Add accessibility identifiers to all views (see checklist above)
- ✅ Run existing tests to verify setup
- ✅ Review and expand test coverage using the new comprehensive test suite
- ⬜ Set up CI/CD pipeline
- ⬜ Configure test reporting/dashboards