Skip to content

Test Automation Efficiency UI Testing Guide for Firefox iOS

mdotb-moz edited this page Jan 28, 2026 · 5 revisions

Test Automation Efficiency UI Testing Guide for Firefox iOS

Table of Contents

  1. Introduction
  2. Understanding Selectors
  3. How to create Selectors
  4. Building Page Object
  5. Navigation Registration Files
  6. Guide to Writing TAE Tests Using Page Object Model (POM)
  7. Best Practices and Tips
  8. Appendix

Introduction

Our automation framework for Firefox iOS has evolved from a monolithic, screen-graph-based structure (MappaMundi) toward a modular Page Object Model (POM) combined with a Navigation Registry. This refactor improves maintainability, readability, reduces flakiness, and enables observability.

This guide is designed to get started quickly with writing automated tests. By the end of this guide, you will understand how to create locators, build page objects, write tests, and follow best practices for test automation.

Why the Refactor?

  1. Maintainability: Decouples screens and transitions for modularity.
 Each screen is independent, making it easier to update when the UI changes.
  2. Readability: Abstraction through POM improves clarity and reduces selector duplication.
 Tests become more readable and easier to understand.
  3. Performance and Stability: Centralized waits and stabilization utilities reduce test flakiness and improve reliability.

  4. Observability Ready: Prepares for metrics, reporting, and AI-based test classifications to better understand test results and failures.

Architecture Overview

The TAE (Test Automation Efficiency) framework is built on a Modular Page Object Model (POM). This architecture separates the intent of the test from the implementation of the UI.

Legacy Approach (Non-TAE)

The Problem: The test is "noisy." It is filled with AccessibilityIdentifiers, manual waits (mozWaitForElement), and direct interactions with app.tables.cells. If the UI hierarchy changes, this test (and every other test like it) will break.

func testClearPrivateData() throws {
        XCTExpectFailure("The app was not launched", strict: false) {
        navigator.nowAt(NewTabScreen)
        mozWaitForElementToExist(app.buttons[AccessibilityIdentifiers.Toolbar.settingsMenuButton])
        // Clear private data from settings and confirm
        navigator.goto(ClearPrivateDataSettings)
        app.tables.cells["ClearPrivateData"].waitAndTap()
        mozWaitForElementToExist(app.tables.cells["ClearPrivateData"])
        app.alerts.buttons["OK"].waitAndTap()

        // Wait for OK pop-up to disappear after confirming
        mozWaitForElementToNotExist(app.alerts.buttons["OK"])

        // Try to tap on the disabled Clear Private Data button
        app.tables.cells["ClearPrivateData"].waitAndTap()
        }
    }

✅ TAE Approach

The Benefit: The test focuses on the User Journey. We use Page Objects (toolbarScreen, settingScreen) to hide the complexity. The test is readable, maintainable, and stable.

func testClearPrivateData_TAE() throws {
        XCTExpectFailure("The app was not launched", strict: false) {
            navigator.nowAt(NewTabScreen)
            toolbarScreen.assertSettingsButtonExists()
            // Clear private data from settings and confirm
            navigator.goto(ClearPrivateDataSettings)
            settingScreen.clearPrivateDataAndConfirm()

            // Wait for OK pop-up to disappear after confirming
            settingScreen.assertConfirmationAlertNotPresent()
            // Try to tap on the disabled Clear Private Data button
            settingScreen.tryTapClearPrivateDataButton()
      }
    }

Key Structural Components

To achieve the clean code seen above, the framework is organized into specific layers. Each layer has a single responsibility:

XCUITest/
   |-- PageScreens
   |	|-- HistoryScreen.swift
   |	|-- LibraryScreen.swift
   |	|-- ……
   |-- Selectors
   |	|-- SelectorShortcuts.swift
   |	|-- SelectorHelper.swift
   |        |-- HistorySelectors.swift
   |	|-- LibrarySelectors.swift
   |-- Utils
   |        |-- FxUserState.swift
   |	|-- NavigationRegistry.swift
   |	|-- registerLibraryPanelNavigation.swift
   |	|-- …….
   |	|-- FxScreenGraph.swift
   |-- PrivateBrowsingTests.swift
   |-- ……..

Key components

  • Page Screens: Represents a specific UI view. It contains the logic for how to interact with that screen (e.g., tapSettings()) and how to verify it (e.g., assertButtonExists()).
  • Selectors: Centralizes element lookups. This separates the "What" from the "How." We use SelectorShortcuts to keep definitions readable.
  • Utils: Contains the "Engine" of the framework, including the Navigation Registry (handling screen transitions) and User State (managing settings/logins).
  • Test Files: The high-level scripts that orchestrate the Page Objects to validate a feature.

How the Layers Interact

  1. Tests call Page Screen to perform actions.
  2. Page Screens use Selectors to find elements.
  3. Selectors use SelectorHelper to translate identifiers into real XCUIElements

Understanding Selectors

What is a selector?

A selector is a data object that describes how to find a UI element in your application. It contains information about the element type, its identifier, and additional metadata like description and grouping. Think of a selector as a recipe for finding an element. Instead of manually searching for an element every time, you define the selector once and reuse it across your tests.

SelectorHelper: The Engine

The SelectorHelper is the core utility that powers all element lookups in your Page Object Model architecture. It defines how a Selector is translated into a real XCUIElement or XCUIElementQuery. SelectorHelper implements the logic behind different strategies, such as:

  • buttonById - Find a button by its accessibility identifier
  • staticTextByLabel - Find static text by its label
  • anyById - Find any element type by its accessibility identifier
  • predicate - Use custom NSPredicate for complex queries This design keeps your element definitions completely declarative. Tests and Screens never need to know if something is a button, cell, or static text. SelectorHelper handles that translation and ensures that all waits and queries behave consistently.

SelectorShortcuts.swift: The developer API

The SelectorShortcuts file builds on top of SelectorHelper and acts as the developer-friendly layer for defining selectors. It provides a rich set of static helper methods that make defining selectors fast, readable, and consistent. Common shortcut methods include:

  • buttonByLabel() - Find a button by its visible label
  • buttonById() - Find a button by its accessibility identifier
  • switchById() - Find a toggle switch by its identifier
  • navigationBarByTitle() - Find a navigation bar by its title
  • anyIdOrLabel() - Find any element by identifier or label
  • textFieldById() - Find a text field by its identifier

💡 Tip: Always use shortcuts instead of writing raw predicates. They are more maintainable and consistent.

Why use Selector Strategy?

  1. Consistency Across Tests: All tests use the same locator strategy for the same elements, reducing discrepancies and making tests more reliable.
  2. Maintainability: When UI identifiers or element types change, you only need to update the shortcut definition in one place rather than hunting through dozens of test files.
  3. Readability: Compare these two approaches:
  4. Type Safety and Grouping: Each selector includes metadata like description and groups, making it easier to organize, search, and document UI elements.
  5. Reduced Boilerplate: Common patterns (finding by ID, label, or predicate) are abstracted into simple method calls, reducing code duplication.

Creating new Shortcuts

Screenshot 2026-01-16 at 11 06 36

For the available Shortcut Methods see the appendix of this document.

How to create Selectors

Step by step guide

Creating selectors involves three main steps: finding the accessibility identifier, defining the selector, and using it in a page object.

Step 1: Find the Accessibility Identifier Before you can create a selector, you need to know the accessibility identifier of the element you want to interact with. There are several ways to find this:

  1. Using Xcode Accessibility Inspector: Open Xcode and run the app on a simulator or device. Go to Xcode menu → Open Developer Tool → Accessibility Inspector. Use the inspector
    to hover over elements and see their accessibility identifiers.
  2. Check the Source Code: Look in the app's source code for AccessibilityIdentifiers. Most teams maintain a centralized file (e.g., AccessibilityIdentifiers.swift)
    that lists all identifiers.
  3. Ask the Developer: If an element doesn't have an accessibility identifier, work with developers to add one. Good accessibility identifiers are essential for robust test automation.

⚠️ Important: Accessibility identifiers should be unique, descriptive, and stable across app versions. Avoid using generated IDs or labels that change frequently.

Step 2: Define the Selector Once you have the accessibility identifier, create a selector file for your screen. Follow this structure:

Example: ToolbarSelectors.swift

import XCTest

// Define the Selectors names
protocol ToolbarSelectorsSet { 
    	var TABS_BUTTON: Selector { get }
	var URL_BAR: Selector { get }
	var MENU_BUTTON: Selector { get }
}

struct ToolbarSelectors: ToolbarSelectorsSet {
    private enum IDs { // Define the variables containing the Accessibility Identifiers and labels names
        static let tabsButton = AccessibilityIdentifiers.Toolbar.tabsButton
	static let urlBar = AccessibilityIdentifiers.Toolbar.urlBar
	static let menuButton = AccessibilityIdentifiers.Toolbar.menuButton
    }

// Define the Selectors using the proper shortcut
    let TABS_BUTTON = Selector.buttonId(
        IDs.tabsButton,
        description: "Toolbar tabs button",
        groups: ["toolbar"]
    )

    let URL_BAR = Selector.textFieldById(
        IDs.urlBar,
        description: "URL bar text field",
        groups: ["toolbar"]
    )

    let MENU_BUTTON = Selector.buttonId(
        IDs.menuButton,
        description: "MAin menu button",
        groups: ["toolbar"]
    )
}

Use best practices

  • Use Descriptive Names
  • Always Include Meaningful Descriptions
  • Use Groups for Organization

Understanding the Selector Structure

  • Protocol (ToolbarSelectorsSet): Defines the contract for what selectors are available. This allows for easy mocking in tests.
  • Private enum IDs: Centralizes the accessibility identifier strings. This makes it easy to update if identifiers change.
  • Selector properties (TABS_BUTTON, etc.): The actual selector definitions using shortcuts from SelectorShortcuts.
  • Description: A human-readable description for debugging and error messages.
  • Groups: Optional tags for organizing and filtering selectors (useful for reporting).

Choosing the Right Selector Type

Different UI elements require different selector types. Here's a quick reference:

  • Buttons: buttonById() or buttonByLabel()
  • Text Fields: textFieldById()
  • Switches/Toggles: switchById()
  • Static Text: staticTextByLabel()
  • Navigation Bar: navigationBarByTitle()
  • Table Cells: cellById()
  • Any Element: anyById() or anyIdOrLabel() (use when element type might change)

💡 Tip: Prefer using specific types (button, textField, etc.) over generic ones (any) when possible. This makes your tests more robust to UI changes.

Building Page Object

What is a Page Object

A Page Object is a class that encapsulates all the interactions with a specific screen in your application. It provides methods for actions (tap, type, swipe) and assertions (verify element exists, check text content). Think of a Page Object as a remote control for your screen. Instead of manually finding and clicking buttons, you call methods like tapTabsButton() or assertMenuButtonExists().

Creating a Page Object

Here's how to create a page object for the Toolbar screen:

import XCTest

// Define the Screen class
final class ToolbarScreen {        
    private let app: XCUIApplication
    private let sel: ToolbarSelectorsSet

//Init the class
    init(app: XCUIApplication, selectors: ToolbarSelectorsSet = ToolbarSelectors()) {
        self.app = app
        self.sel = selectors
    }   

// MARK: Actions
// Define the functions that interacts with the UI using the selectors and the BaseTestCase methods

    func assertTabsButtonExists(timeout: TimeInterval = TIMEOUT) {
        let btn = sel.TABS_BUTTON.element(in: app)
        BaseTestCase().mozWaitForElementToExist(btn, timeout: timeout)
    }

   func typeInURLBar(_ text: String, timeout: TimeInterval = TIMEOUT) {
        let field = sel.URL_BAR.element(in: app)
        BaseTestCase().mozWaitForElementToExist(field, timeout: timeout)
        field.tap()
        field.typeText(text)
    }

   func tapMenuButton(timeout: TimeInterval = TIMEOUT) {
        let btn = sel.MENU_BUTTON.element(in: app)
        BaseTestCase().mozWaitForElementToExist(btn, timeout: timeout)
        btn.tap()
    }

   // MARK: Assertions

   func assertTabsButtonExists(timeout: TimeInterval = TIMEOUT) {
        let btn = sel.TABS_BUTTON.element(in: app)
        BaseTestCase().mozWaitForElementToExist(btn, timeout: timeout)
    }
    
    func assertURLBarContains(_ text: String, timeout: TimeInterval = TIMEOUT) {
        let field = sel.URL_BAR.element(in: app)
        BaseTestCase().mozWaitForElementToExist(field, timeout: timeout)
        XCTAssertTrue(field.value as? String ?? "").contains(text)
    }
}

Page Object Best Practice

  1. Separate Actions from Assertions: Use MARK comments to group related methods.
  2. Always Wait for Elements: Use mozWaitForElementToExist before interacting with elements to reduce flakiness.
  3. Use Descriptive Method Names: Method names should clearly describe what they do (e.g., tapTabsButton instead of tap).
  4. Keep Page Objects Focused: Each page object should represent a single screen or component.
  5. Make Timeouts Configurable: Allow custom timeouts for slow operations while using sensible defaults.

Navigation Registration Files

What Are Navigation Registration Files?

Navigation Registration files define how to navigate between different screens and states in the Firefox iOS app during UI testing. They act as a centralized map that tells the test framework:

  1. What screens exist in the app
  2. How to transition from one screen to another
  3. What actions can be performed on each screen
  4. What conditions affect navigation (e.g., logged in vs logged out)

Why Do We Use Them? (The Problem They Solve)

The Problem: Traditional UI Testing

Without navigation registration, tests look like this:

// ❌ Traditional approach
func testChangingSearchSettings() {
    app.buttons["MenuButton"].waitAndTap()
    app.tables.cells["Settings"].waitAndTap()
    sleep(2) // Hope the screen loads
    app.tables.cells["Search"].waitAndTap()
    app.switches["ShowSuggestions"].waitAndTap()
    // Now navigate back - have to remember the exact reverse path
    app.navigationBars.buttons.element(boundBy: 0).waitAndTap()
    app.navigationBars.buttons.element(boundBy: 0).waitAndTap()
}

Problems with this approach:

  • Duplicated code: Every test that needs Settings must rewrite the navigation
  • Brittle: If menu structure changes, several tests break
  • Unclear: Hard to understand what's being tested vs how to get there
  • No validation: Can create invalid navigation paths (e.g., can't go from Search to TabTray directly)
  • Maintenance nightmare: Navigation changes require updating every single test

The Solution: Navigation Registration with MappaMundi

With navigation registration:

func testChangingSearchSettings() {
    navigator.goto(SearchSettings)  // One line - handles all navigation
    // Perform your test actions
    navigator.performAction(Action.ToggleSearchSuggestions)
    // Done! Navigator knows how to get back
}

Benefits:

  • Single source of truth: Navigation defined once, used everywhere
  • Resilient: UI changes only require updating registration files
  • Clear intent: Tests focus on WHAT to test, not HOW to navigate
  • Validated paths: MappaMundi ensures you can only navigate valid routes
  • Easy maintenance: Update navigation in one place, applies to all tests

The Architecture: How It Works

1. The MappaMundi State Machine

The navigation system uses MappaMundi, a state machine library. Think of it like a graph:

[BrowserTab] --tap toolbar button--> [SettingsScreen]
[SettingsScreen] --tap Search cell--> [SearchSettings]
[SearchSettings] --back button--> [SettingsScreen]
[SettingsScreen] --back button--> [BrowserTab]

Each node is a screen state, and edges are transitions (how to move between states).

2. The Registration Flow

Here's how registration files fit into the system:

BaseTestCase.swift
    │
    ├── Creates MMScreenGraph
    │
    └── NavigationRegistry.registerAll()
            │
            ├── registerToolBarNavigation()
            ├── registerSettingsNavigation()
            ├── registerTabTrayNavigation()
            ├── registerUrlBarNavigation()
            └── ... (registration functions)
                    │
                    └── Each function defines:
                        - Screen states
                        - Transitions between states
                        - Actions on states
                        - Conditions for navigation

3. Screen States (Defined in FxScreenGraph.swift)

Screen states are String constants representing app screens:

let BrowserTab = "BrowserTab"           // Main browser screen
let TabTray = "TabTray"                 // Tab switcher
let SettingsScreen = "SettingsScreen"   // Main settings
let SearchSettings = "SearchSettings"   // Search settings
let URLBarOpen = "URLBarOpen"           // Address bar focused
// ... 80+ more states

4. Actions (Defined in FxScreenGraph.swift)

Actions represent test operations that can be performed:

class Action {
    static let LoadURL = "LoadURL"
    static let Bookmark = "Bookmark"
    static let TogglePrivateMode = "TogglePrivateBrowing"
    static let OpenNewTabFromTabTray = "OpenNewTabFromTabTray"
    // ... 50+ more actions
}

NavigationRegistry.swift - The Central Coordinator

Location: firefox-ios-tests/Tests/XCUITests/NavigationRegistry.swift

This is the entry point that orchestrates all navigation setup:

enum NavigationRegistry {
    @MainActor
    static func registerAll(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
        registerZoomNavigation(in: map, app: app)
        registerToolBarNavigation(in: map, app: app)
        registerSettingsNavigation(in: map, app: app)
        registerUrlBarNavigation(in: map, app: app)
        registerLibraryPanelNavigation(in: map, app: app)
        registerHomePanelNavigation(in: map, app: app)
        registerTabMenuNavigation(in: map, app: app)
        registerTabTrayNavigation(in: map, app: app)
        registerCommonNavigation(in: map, app: app)
        registerOnboardingNavigation(in: map, app: app)
        registerMobileNavigation(in: map, app: app)
        registerTrackingProtection(in: map, app: app)
        registerContextMenuNavigation(in: map, app: app)
        registerFxAccountNavigation(in: map, app: app)
        registerMiscellanousNavigation(in: map, app: app)
        registerMiscellanousActions(in: map)
    }
}

Anatomy of a Registration File

All registration files follow this pattern:

@MainActor
func register<FeatureName>Navigation(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
    // Define screen states and their transitions
}

Key Components

1. addScreenState - Define a Screen
map.addScreenState(SettingsScreen) { screenState in
    // Define what you can do from this screen
}
2. tap - Navigate by Tapping
// Basic tap: Tap element to go to destination
screenState.tap(table.cells["Search"], to: SearchSettings)

// With action: Perform action while staying on screen
screenState.tap(
    app.buttons["BookmarkButton"],
    forAction: Action.Bookmark,
    transitionTo: BrowserTab
)

// Conditional: Different destination based on user state
screenState.tap(
    table.cells["Sync"],
    to: SyncSettings,
    if: "fxaUsername != nil"  // Only if logged in
)
3. gesture - Navigate via Gesture
// For complex interactions (swipe, press, multi-step)
screenState.gesture(to: TabTray) {
    app.buttons["TabsButton"].waitAndTap()
}

// Gesture with action (no navigation)
screenState.gesture(forAction: Action.ToggleNoImageMode) { userState in
    app.switches["BlockImages"].waitAndTap()
}
4. press - Long Press
screenState.press(link, to: WebLinkContextMenu)
screenState.press(image, to: WebImageContextMenu)
5. backAction - Define How to Go Back
screenState.backAction = navigationControllerBackAction(for: app)
// This is a function that taps the navigation bar back button
6. dismissOnUse - Auto-Dismiss After Action
screenState.dismissOnUse = true  // For modals/menus that close after use
7. noop - No Operation (Stay on Same Screen)
screenState.noop(to: BrowserTab)  // Stay on BrowserTab after action
8. onEnter - Code to Run When Entering Screen
screenState.onEnter { userState in
    userState.numTabs = Int(app.otherElements["TabsTray"].cells.count)
}
9. addScreenAction - Global Action
// Action that can be performed from anywhere
map.addScreenAction(Action.LoadURL, transitionTo: WebPageLoading)

Real Example from the Codebase

Example: registerToolBarNavigation.swift

Purpose: Defines toolbar button interactions (back, forward, tabs, menu, reload).

File Location: registerToolbarNavigation.swift

func registerToolBarNavigation(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
    map.addScreenState(BrowserTab) { screenState in
        // Tap address bar to open URL bar
        makeURLBarAvailable(screenState, app: app)

        // Tap menu button to open menu
        screenState.tap(
            app.buttons[AccessibilityIdentifiers.Toolbar.settingsMenuButton],
            to: BrowserTabMenu
        )

        // Tap tracking protection button
        screenState.tap(
            app.buttons[AccessibilityIdentifiers.MainMenu.trackigProtection],
            to: TrackingProtectionContextMenuDetails
        )

        // Long press on reload button
        screenState.press(reloadButton, to: ReloadLongPressMenu)

        // Tap reload button (reloads page, stays on BrowserTab)
        screenState.tap(
            reloadButton,
            forAction: Action.ReloadURL,
            transitionTo: WebPageLoading
        )

        // Long press on image shows context menu
        let image = app.webViews.element(boundBy: 0).images.element(boundBy: 0)
        screenState.press(image, to: WebImageContextMenu)

        // Different behavior for iPad vs iPhone
        if isTablet {
            screenState.tap(app.buttons["TabsButton"], to: TabTray)
        } else {
            screenState.gesture(to: TabTray) {
                app.buttons["TabsButton"].waitAndTap()
            }
        }
    }
}

Key Insights:

  • Defines multiple navigation paths from BrowserTab
  • Handles device differences (iPad vs iPhone)
  • Mixes taps, presses, and gestures
  • Some actions change screens, others perform actions

The Registration Files

File Purpose
NavigationRegistry.swift Central coordinator that calls all other registration functions
registerCommonNavigation.swift App-wide common patterns (loading states, alerts)
registerToolbarNavigation.swift Toolbar buttons (back, forward, tabs, menu, reload)
registerTabTrayNavigation.swift Tab switcher, tab management, private mode toggle
registerTabMenuNavigation.swift Tab-specific menu options
registerUrlBarNavigation.swift Address bar, autocomplete, search
registerSettingsNavigation.swift All settings screens and sub-settings
registerHomePanelNavigation.swift Homepage sections (top sites, bookmarks, history)
registerLibraryPanelNavigation.swift Library (bookmarks, reading list, downloads, history)
registerOnboardingNavigation.swift First-run onboarding flow
registerMobileNavigation.swift Mobile-specific interactions
registerContextMenuNavigation.swift Long-press context menus
registerFxAccountNavigation.swift Firefox Account sign-in/sign-out flows
registerMiscellanousNavigation.swift Miscellaneous navigation patterns
registerMiscellanousActions.swift Global actions (LoadURL, SetURL)
registerTrackingProtection.swift Tracking protection UI interactions
registerZoomNavigation.swift Page zoom controls

How Tests Use Navigation

In BaseTestCase

class BaseTestCase: XCTestCase {
    var navigator: MMNavigator<FxUserState>!

    override func setUp() {
        let screenGraph = createScreenGraph(for: self, with: app)
        navigator = MMNavigator(with: screenGraph)
    }
}

In Actual Tests

class SearchTests: BaseTestCase {
    func testChangeSearchEngine() {
        // Navigate to SearchSettings in one line
        navigator.goto(SearchSettings)

        // Perform action
        navigator.performAction(Action.SelectSearchEngine)

        // Assert
        XCTAssertEqual(/* ... */)

        // Navigator automatically handles going back
    }

    func testLoadURLAndBookmark() {
        // Perform global action
        navigator.performAction(Action.LoadURL, value: "https://mozilla.org")

        // Navigator handles: BrowserTab → URLBarOpen → type URL → WebPageLoading → BrowserTab
        navigator.nowAt(BrowserTab)

        // Perform bookmark action
        navigator.performAction(Action.Bookmark)
    }
}

Writing Your Own Registration File

Step 1: Create the File

// registerMyFeatureNavigation.swift
import XCTest
import MappaMundi

@MainActor
func registerMyFeatureNavigation(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
    // Your navigation logic here
}

Step 2: Define Screen States

// Add to FxScreenGraph.swift (line 28-89)
let MyFeatureScreen = "MyFeatureScreen"
let MyFeatureSettings = "MyFeatureSettings"

Step 3: Define Actions (if needed)

// Add to FxScreenGraph.swift Action class (line 173-292)
static let PerformMyFeatureAction = "PerformMyFeatureAction"

Step 4: Register Navigation

@MainActor
func registerMyFeatureNavigation(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
    // Define how to get TO your feature screen
    map.addScreenState(BrowserTab) { screenState in
        screenState.tap(
            app.buttons["MyFeatureButton"],
            to: MyFeatureScreen
        )
    }

    // Define your feature screen
    map.addScreenState(MyFeatureScreen) { screenState in
        // Navigate to settings
        screenState.tap(
            app.buttons["FeatureSettings"],
            to: MyFeatureSettings
        )

        // Perform action without navigation
        screenState.tap(
            app.buttons["ActionButton"],
            forAction: Action.PerformMyFeatureAction,
            transitionTo: MyFeatureScreen
        )

        // Define how to go back
        screenState.backAction = navigationControllerBackAction(for: app)
    }

    // Define settings screen
    map.addScreenState(MyFeatureSettings) { screenState in
        screenState.backAction = navigationControllerBackAction(for: app)
    }
}

Step 5: Register in NavigationRegistry

// NavigationRegistry.swift
static func registerAll(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
    // ... existing registrations
    registerMyFeatureNavigation(in: map, app: app)  // Add this line
}

Step 6: Use in Tests

class MyFeatureTests: BaseTestCase {
    func testFeature() {
        navigator.goto(MyFeatureScreen)
        navigator.performAction(Action.PerformMyFeatureAction)
        // Test your feature
    }
}

Guide to Writing TAE Tests Using Page Object Model (POM)

Overview

This guide will help you write your first TAE (Test Automation Enhancement) test using the Page Object Model (POM) pattern. TAE tests are designed to be more maintainable, readable, and scalable than traditional XCUITests.

Key Benefits of TAE Tests:

  • Better Maintainability: UI changes only require updates to PageScreens, not every test
  • Improved Readability: Tests read like user stories with clear, semantic method names
  • Reusability: PageScreens and Selectors can be shared across multiple tests
  • Type Safety: Swift's strong typing catches errors at compile time
  • Separation of Concerns: Test logic is separate from element location and interaction

What is TAE?

TAE (Test Automation Enhancement) is an enhanced testing approach that uses:

  • PageScreens: Classes that represent app screens and encapsulate UI interactions
  • Selectors: Structured element locators with metadata and descriptions
  • Semantic Methods: Descriptive methods like assertTabsButtonExists() instead of raw element queries

TAE vs Traditional Tests

Traditional Test:

func testBookmark() {
    app.launch()
    mozWaitForElementToExist(app.buttons[AccessibilityIdentifiers.Toolbar.tabsButton])
    app.textFields[AccessibilityIdentifiers.Browser.AddressToolbar.searchTextField].waitAndTap()
    XCTAssertTrue(app.buttons["Save"].exists)
}

TAE Test:

func testBookmark() {
    app.launch()
    toolbarScreen.assertTabsButtonExists()
    browserScreen.tapOnAddressBar()
    bookmarkScreen.assertSaveButtonExists()
}

Architecture Overview

The TAE framework consists of three main components:

┌─────────────────┐
│   Test Class    │  → Your test file (e.g., BookmarksTests.swift)
│   (*_TAE())     │
└────────┬────────┘
         │ uses
         ▼
┌─────────────────┐
│  PageScreens    │  → BrowserScreen, ToolbarScreen, etc.
│                 │  → Contains interaction methods
└────────┬────────┘
         │ uses
         ▼
┌─────────────────┐
│   Selectors     │  → BrowserSelectors, ToolbarSelectors, etc.
│                 │  → Defines element locators
└─────────────────┘

1. Test Class

Your test file that contains test methods.

Location: Tests/XCUITests/ Example: BookmarksTests.swift, NavigationTest.swift

2. PageScreens

Classes representing app screens with methods for interactions and assertions.

Location: Tests/XCUITests/PageScreens/ Example: BrowserScreen.swift, ToolbarScreen.swift

Structure:

@MainActor
final class BrowserScreen {
    private let app: XCUIApplication
    private let sel: BrowserSelectorsSet

    init(app: XCUIApplication, selectors: BrowserSelectorsSet = BrowserSelectors()) {
        self.app = app
        self.sel = selectors
    }

    // Action methods (what the user does)
    func tapOnAddressBar() {
        let urlElement = sel.ADDRESS_BAR.element(in: app)
        urlElement.waitAndTap()
    }

    // Assertion methods (what we verify)
    func assertAddressBarExists(duration: TimeInterval = TIMEOUT) {
        BaseTestCase().mozWaitForElementToExist(sel.ADDRESS_BAR.element(in: app), timeout: duration)
    }
}
  • Note that you may see some test methods with the _TAE() suffix. This is temporary, so please don’t use them—this suffix is in the process of being removed.

3. Selectors

Protocols and structs that define element locators with metadata.

Location: Tests/XCUITests/Selectors/ Example: BrowserScreen.swift, ToolbarScreen.swift

Structure:

protocol BrowserSelectorsSet {
    var ADDRESS_BAR: Selector { get }
    var BACK_BUTTON: Selector { get }
    // ... more selectors
}

struct BrowserSelectors: BrowserSelectorsSet {
    let ADDRESS_BAR = Selector.textFieldId(
        AccessibilityIdentifiers.Browser.AddressToolbar.searchTextField,
        description: "Browser address bar",
        groups: ["browser"]
    )

    let BACK_BUTTON = Selector.buttonId(
        AccessibilityIdentifiers.Toolbar.backButton,
        description: "Back button",
        groups: ["browser"]
    )
}

Getting Started

Prerequisites

  1. Basic knowledge of Swift and XCTest
  2. Familiarity with XCUITest framework
  3. Understanding of the app's UI structure
  4. Access to the Firefox iOS codebase

Required Imports

import Common
import XCTest

Step-by-Step Guide

Step 1: Create or Identify Your Test Class

Your test should inherit from BaseTestCase or FeatureFlaggedTestBase:

class BookmarksTests: FeatureFlaggedTestBase {
    // TAE tests go here
}

Step 2: Declare PageScreen Properties

At the top of your test class, declare the PageScreens you'll need:

class BookmarksTests: FeatureFlaggedTestBase {
    private var browserScreen: BrowserScreen!
    private var toolbarScreen: ToolbarScreen!
    private var libraryScreen: LibraryScreen!
    private var firefoxHomeScreen: FirefoxHomePageScreen!
}

Step 3: Initialize PageScreens in setUp()

Override the setUp() method to initialize your PageScreens, if needed:

override func setUp() async throws {
    try await super.setUp()
    browserScreen = BrowserScreen(app: app)
    toolbarScreen = ToolbarScreen(app: app)
    libraryScreen = LibraryScreen(app: app)
    firefoxHomeScreen = FirefoxHomePageScreen(app: app)
}

Step 4: Write Your TAE Test Method

Create your test method:

// https://mozilla.testrail.io/index.php?/cases/view/2306909
// SmokeTest
func testBookmarkLibraryAddDeleteBookmark() {
    app.launch()
    navigator.nowAt(NewTabScreen)

    // Step 1: Verify initial state
    toolbarScreen.assertTabsButtonExists()
    navigator.goto(LibraryPanel_Bookmarks)
    libraryScreen.assertBookmarkList()
    libraryScreen.assertBookmarkListCount(numberOfEntries: 0)

    // Step 2: Navigate and add bookmark
    navigator.nowAt(LibraryPanel_Bookmarks)
    navigator.goto(HomePanelsScreen)
    navigator.goto(URLBarOpen)
    navigator.openURL(url_3)
    waitUntilPageLoad()
    navigator.nowAt(BrowserTab)
    bookmark()

    // Step 3: Verify bookmark was added
    navigator.goto(LibraryPanel_Bookmarks)
    libraryScreen.assertBookmarkList()
    libraryScreen.assertBookmarkListCount(numberOfEntries: 1)

    // Step 4: Delete bookmark
    libraryScreen.swipeAndDeleteBookmark(entryName: urlLabelExample_3)

    // Step 5: Verify bookmark was deleted
    libraryScreen.assertBookmarkList()
    libraryScreen.assertEmptyStateSignInButtonExists()
    libraryScreen.assertBookmarkListLabel(label: "Empty list")
}

Step 5: Create PageScreen (if needed)

If the PageScreen doesn't exist, create it in Tests/XCUITests/PageScreens/:

// NewFeatureScreen.swift
import XCTest

@MainActor
final class NewFeatureScreen {
    private let app: XCUIApplication
    private let sel: NewFeatureSelectorsSet

    init(app: XCUIApplication, selectors: NewFeatureSelectorsSet = NewFeatureSelectors()) {
        self.app = app
        self.sel = selectors
    }

    // Private computed properties for common elements
    private var saveButton: XCUIElement { sel.SAVE_BUTTON.element(in: app) }
    private var titleField: XCUIElement { sel.TITLE_FIELD.element(in: app) }

    // Action methods (user interactions)
    func tapSaveButton() {
        saveButton.waitAndTap()
    }

    func enterTitle(_ title: String) {
        titleField.waitAndTap()
        titleField.typeText(title)
    }

    // Assertion methods (verifications)
    func assertSaveButtonExists(timeout: TimeInterval = TIMEOUT) {
        BaseTestCase().mozWaitForElementToExist(saveButton, timeout: timeout)
    }

    func assertTitleFieldContains(text: String) {
        BaseTestCase().mozWaitForValueContains(titleField, value: text)
    }
}

Step 6: Create Selectors (if needed)

If the Selectors don't exist, create them in Tests/XCUITests/Selectors/:

// NewFeatureSelectors.swift
import XCTest

protocol NewFeatureSelectorsSet {
    var SAVE_BUTTON: Selector { get }
    var TITLE_FIELD: Selector { get }
    var all: [Selector] { get }
}

struct NewFeatureSelectors: NewFeatureSelectorsSet {
    private enum IDs {
        static let saveButton = AccessibilityIdentifiers.NewFeature.saveButton
        static let titleField = AccessibilityIdentifiers.NewFeature.titleField
    }

    let SAVE_BUTTON = Selector.buttonId(
        IDs.saveButton,
        description: "Save button in new feature screen",
        groups: ["newFeature"]
    )

    let TITLE_FIELD = Selector.textFieldId(
        IDs.titleField,
        description: "Title input field",
        groups: ["newFeature"]
    )

    var all: [Selector] { [SAVE_BUTTON, TITLE_FIELD] }
}

Best Practices

Naming Conventions

Test Methods
  • Use descriptive names: testFeatureDescription()
  • Include TestRail link in comments above the method. Developers don’t need to add this link — this process is handled by the Test Automation Team.
  • Include the //Smoketest comment if the test belongs to the Smoke Test Suite
// https://mozilla.testrail.io/index.php?/cases/view/2306909
// SmokeTest
func testBookmarkLibraryAddDeleteBookmark() {
    // test implementation
}
PageScreen Classes
  • End with Screen suffix: BrowserScreen, SettingsScreen
  • Use @MainActor attribute for thread safety
  • Mark as final if not meant to be subclassed
@MainActor
final class BrowserScreen {
    // implementation
}
PageScreen Methods
  • Actions: Start with verb (tap, enter, select, swipe)

    • tapOnAddressBar()
    • enterSearchText(text: String)
    • selectBookmark(name: String)
  • Assertions: Start with assert

    • assertAddressBarExists()
    • assertBookmarkListCount(numberOfEntries: Int)
    • assertSaveButtonIsEnabled()
  • Waiting: Start with wait

    • waitForPageLoad()
    • waitForElementToDisappear()
Selectors
  • Use SCREAMING_SNAKE_CASE for selector constants
  • Use descriptive names: SAVE_BUTTON, ADDRESS_BAR, TITLE_FIELD
  • Always include description and groups
let SAVE_BUTTON = Selector.buttonId(
    IDs.saveButton,
    description: "Save button in bookmark editor",
    groups: ["bookmark", "editor"]
)

Code Organization

Test Class Structure
class MyFeatureTests: FeatureFlaggedTestBase {
    // 1. PageScreen declarations
    private var browserScreen: BrowserScreen!
    private var toolbarScreen: ToolbarScreen!

    // 2. Setup/Teardown
    override func setUp() async throws {
        try await super.setUp()
        browserScreen = BrowserScreen(app: app)
        toolbarScreen = ToolbarScreen(app: app)
    }

    override func tearDown() async throws {
        // cleanup if needed
        try await super.tearDown()
    }

    // 3. Helper methods (private)
    private func bookmark() {
        browserScreen.assertAddressBar_LockIconExist()
        navigator.nowAt(BrowserTab)
        navigator.goto(BrowserTabMenu)
        navigator.performAction(Action.Bookmark)
    }

    // 4. Test methods (func test*())
    func testMyFeature() {
        // test implementation
    }
}
PageScreen Structure
@MainActor
final class MyScreen {
    // 1. Properties
    private let app: XCUIApplication
    private let sel: MySelectorsSet

    // 2. Initializer
    init(app: XCUIApplication, selectors: MySelectorsSet = MySelectors()) {
        self.app = app
        self.sel = selectors
    }

    // 3. Private computed properties for elements
    private var saveButton: XCUIElement { sel.SAVE_BUTTON.element(in: app) }

    // 4. Action methods
    func tapSaveButton() {
        saveButton.waitAndTap()
    }

    // 5. Assertion methods
    func assertSaveButtonExists() {
        BaseTestCase().mozWaitForElementToExist(saveButton)
    }

    // 6. Helper/Utility methods
    private func scrollToBottom() {
        // implementation
    }
}

Writing Assertions

Always use semantic assertion methods instead of raw XCTest assertions:

YES

func assertTabsButtonValue(expectedCount: String) {
    let tabsButtonValue = tabsButton.value as? String
    XCTAssertEqual(expectedCount, tabsButtonValue,
                   "Expected \(expectedCount) open tabs")
}

NO

// Don't do this in tests - create a PageScreen method instead
let value = app.buttons[AccessibilityIdentifiers.Toolbar.tabsButton].value
XCTAssertEqual(value, "1")

Element Waiting

Always wait for elements before interacting:

func tapSaveButton() {
    // Good practice: wait then tap
    saveButton.waitAndTap()
}

func assertElementExists(timeout: TimeInterval = TIMEOUT) {
    // Good practice: use mozWaitForElementToExist
    BaseTestCase().mozWaitForElementToExist(element, timeout: timeout)
}

Selector Strategy Selection

Choose the appropriate selector strategy:

Element Type Selector Method Example
Button with ID .buttonId() Selector.buttonId("saveButton", ...)
Button with label .buttonByLabel() Selector.buttonByLabel("Save", ...)
Text field .textFieldId() Selector.textFieldId("searchField", ...)
Static text .staticTextById() Selector.staticTextById("title", ...)
Link .linkById() Selector.linkById("moreInfo", ...)
Table .tableById() Selector.tableById("bookmarksList", ...)
Complex query .predicate() Custom NSPredicate

Examples

Example 1: Simple Test (NavigationTest.swift)

// https://mozilla.testrail.io/index.php?/cases/view/2441775
// Smoketest
func testURLBar() {
    let browserScreen = BrowserScreen(app: app)

    // User taps on address bar
    browserScreen.tapOnAddressBar()

    // Verify keyboard appears and bar has focus
    browserScreen.assertAddressBarHasKeyboardFocus()
    XCTAssert(app.keyboards.count > 0, "The keyboard is not shown")

    // User types and submits
    app.typeText("example.com\n")

    // Verify navigation occurred
    browserScreen.assertAddressBarContains(value: "example.com")
    XCTAssertFalse(app.keyboards.count > 0, "The keyboard is shown")
}

Example 2: Multi-Screen Test (NavigationTest.swift)

// https://mozilla.testrail.io/index.php?/cases/view/2306858
// Smoketest
func testSSL() {
    let sslScreen = SSLWarningScreen(app: app)

    // Navigate to expired SSL site
    navigator.openURL("https://expired.badssl.com/")

    // Verify warning appears
    sslScreen.waitForWarning()
    sslScreen.assertWarningVisible()

    // User goes back
    sslScreen.tapGoBack()
    sslScreen.waitForWarningToDisappear()

    // Open new tab and try again
    navigator.performAction(Action.OpenNewTabFromTabTray)
    navigator.openURL("https://expired.badssl.com/")

    // This time, bypass the warning
    sslScreen.waitForWarning()
    sslScreen.assertWarningVisible()
    sslScreen.tapAdvanced()
    sslScreen.tapVisitSiteAnyway()
    sslScreen.waitForPageToLoadAfterBypass()
}

Example 3: Complex Test with Multiple Assertions (BookmarksTests.swift)

// https://mozilla.testrail.io/index.php?/cases/view/2784448
// SmokeTest
func testBookmarksToggleIsAvailable() throws {
    addLaunchArgument(jsonFileName: "homepageRedesignOff",
                      featureName: "homepage-redesign-feature")
    app.launch()

    // Navigate and bookmark a page
    navigator.openURL(url_3)
    toolbarScreen.assertTabsButtonExists()
    navigator.nowAt(BrowserTab)
    bookmark()

    // Go to homepage settings
    navigator.nowAt(NewTabScreen)
    navigator.goto(HomeSettings)

    // Verify toggle exists and disable it
    homepageSettingsScreen.assertBookmarkToggleExists()
    homepageSettingsScreen.disableBookmarkToggle()
    homepageSettingsScreen.assertBookmarkToggleIsDisabled()

    // Verify bookmarks section is hidden
    navigator.nowAt(HomeSettings)
    navigator.goto(NewTabScreen)
    navigator.goto(TabTray)
    navigator.performAction(Action.OpenNewTabFromTabTray)
    browserScreen.tapCancelButtonIfExist()
    firefoxHomeScreen.assertBookmarksItemCellToNotExist()

    // Re-enable toggle
    navigator.nowAt(BrowserTab)
    navigator.goto(HomeSettings)
    homepageSettingsScreen.assertBookmarkToggleExists()
    homepageSettingsScreen.enableBookmarkToggle()
    homepageSettingsScreen.assertBookmarkToggleIsEnabled()

    // Verify bookmarks section is visible again
    navigator.nowAt(HomeSettings)
    navigator.goto(NewTabScreen)
    navigator.goto(TabTray)
    navigator.performAction(Action.OpenNewTabFromTabTray)
    browserScreen.tapCancelButtonIfExist()
    firefoxHomeScreen.assertBookmarksItemCellExist()
}

Troubleshooting

Common Issues

Issue 1: Element Not Found

Problem: Test fails with "element not found" error

Solutions:

// 1. Add explicit wait
BaseTestCase().mozWaitForElementToExist(element, timeout: TIMEOUT)

// 2. Increase timeout for slow operations
func assertSlowElement(timeout: TimeInterval = TIMEOUT_LONG) {
    BaseTestCase().mozWaitForElementToExist(element, timeout: timeout)
}

// 3. Check if element is in a different state
// e.g., button might be disabled, not missing
func assertButtonState() {
    BaseTestCase().mozWaitForElementToExist(button)
    XCTAssertTrue(button.isEnabled, "Button should be enabled")
}
Issue 2: Selector Not Working

Problem: Selector doesn't find the element despite correct ID If, for any reason, the A11y ID or element ID doesn’t work, try a different selector strategy. Remember: A11y IDs and element IDs should be the first strategy you use. Solutions:

// 1. Try different selector strategy
// Instead of buttonId, try buttonByLabel if ID matches label
let BUTTON = Selector.buttonByLabel("Save", description: "...", groups: [...])

// 2. Use predicate for complex queries
let BUTTON = Selector.predicate(
    NSPredicate(format: "elementType == %d AND label CONTAINS %@",
                XCUIElement.ElementType.button.rawValue, "Save")
)

// 3. Check accessibility identifier in the app
// Use Xcode's Accessibility Inspector or debug the app
Issue 3: Timing Issues

Problem: Test is flaky, sometimes passes, sometimes fails

Solutions:

// 1. Always wait for page load
waitUntilPageLoad()

// 2. Wait for specific element state
func waitForButtonEnabled() {
    let button = sel.BUTTON.element(in: app)
    BaseTestCase().mozWaitForElementToExist(button)
    BaseTestCase().mozWaitElementHittable(element: button, timeout: TIMEOUT)
}

// 3. Add wait between actions if needed
browserScreen.tapButton()
sleep(1) // Only use as last resort
otherScreen.assertElementExists()

Debugging Tips

  1. Use Print Statements:
func tapButton() {
    print("🔍 Attempting to tap button with ID: \(sel.BUTTON.value)")
    let button = sel.BUTTON.element(in: app)
    print("🔍 Button exists: \(button.exists)")
    print("🔍 Button isHittable: \(button.isHittable)")
    button.waitAndTap()
}
  1. Check Element Hierarchy:
func printElementTree() {
    print(app.debugDescription)
}
  1. Use Accessibility Inspector:
    • Open Xcode → Developer Tools → Accessibility Inspector
    • Inspect elements to find correct identifiers and types

Best Practices and Tips

General Best Practices

  • One Assertion Per Test (When Possible): Tests should verify one thing. This makes failures easier to diagnose.
  • Tests Should Be Independent: Each test should be able to run in any order without depending on other tests.
  • Use setUp and tearDown: Initialize common objects in setUp() and clean up in tearDown().
  • Keep Tests Fast: Minimize unnecessary waits and navigation. Fast tests = faster feedback.
  • Don't Use Sleep: Never use sleep() or hardcoded delays. Always use proper waits like mozWaitForElementToExist.
  • Write Descriptive Failure Messages: Custom assertion messages help identify failures quickly.
  • Test Happy Paths and Edge Cases: Cover both normal workflows and error scenarios.
  • Review and Refactor: Regularly review tests for duplication and opportunities to improve.

Common Pitfalls to avoid

  • Hardcoding Delays: Using sleep() makes tests slow and unreliable. Use proper waits instead.
  • Brittle Selectors: Using text labels that change frequently or generated IDs makes tests fragile.
  • Overly Complex Tests: Tests that do too much are hard to debug when they fail. Duplicate Code: Not using page objects leads to duplicated selectors and logic across tests.
  • Ignoring Flaky Tests: Address flakiness immediately. Flaky tests reduce confidence in your test suite.
  • Not Using Version Control: Always commit your tests and selectors to version control.

Appendix

Available Shortcut Methods

By Identifier (ID)

- anyId(_ id:) - Finds any element type with the specified identifier.
- buttonId(_ id:) - Finds  button with the specified ident.
- staticTextId(_ id:) - Finds static text with the specified identifier.
- textFieldId(_ id:) - Finds a text field with the specified identifier.
- imageId(_ id:) - Finds an image with the specified identifier.
- switchById(_ id:) - Finds a switch/toggle with the specified identifier.
- tableCellById(_ id:) - Finds a table cell with the specified identifier.
- cellById(_ id:) - Finds a generic cell with the specified identifier.
- linkById(_ id:) - Finds a link with the specified identifier.
- navigationBarId(_ id:) - Finds a navigation bar with the specified identifier.
- searchFieldById(_ id:) - Finds a search field with the specified identifier.

By Label (Text Content)

- staticTextByLabel(_ label:) - Finds static text matching the exact label.
- buttonByLabel(_ label:) - Find  a button by its label or identifier.
- cellByLabel(_ label:) - Finds a cell by its label.
- tableCellByLabel(_ label:) - Finds a table cell by its label or identifier.
- linkStaticTextByLabel(_ label:) - Finds static text within a link by exact label.
- navigationBarByTitle(_ label:) - Finds a navigation bar by its title (identifier or label)

By ID or Label (Flexible)

- anyIdOrLabel(_ value:) - Finds any element matching identifier OR label.
- buttonIdOrLabel(_ value:) - Find  a button matching identifier OR label.
- switchByIdOrLabel(_ value:) - Finds a switch matching identifier OR label.

Partial Text Matching

- staticTextLabelContains(_ text:) - Finds static text where the label contains the specified text.
- cellStaticTextLabelContains(_ text:) - Finds static text in cells containing the specified text (case-insensitive).
- buttonLabelContains(_ text:) - Finds a button whose label contains the specified text (case-insensitive).

Specialized Shortcuts

- collectionViewIdOrLabel(_ value:) - Finds a collection view by identifier or label.
- tableIdOrLabel(_ value:) - Finds a table by identifier or label.
- firstTable( ) - Finds the first table in the view hierarchy.
- tableFirstMatch( ) - Returns a first match strategy for tables.
- tableOtherById(_ id:) - Finds an "other" element within tables by identifier.
- webViewOtherByLabel(_ label:) - Finds an "other" element in web views by label or identifier.
- alertByTitle(_ title:) - Finds an alert containing the specified title (case-insensitive).
- springboardPasscodeField( ) - Finds the system passcode field on the springboard.

Clone this wiki locally