-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Test Automation Efficiency UI Testing Guide for Firefox iOS
- Introduction
- Understanding Selectors
- How to create Selectors
- Building Page Object
- Navigation Registration Files
- Guide to Writing TAE Tests Using Page Object Model (POM)
- Best Practices and Tips
- Appendix
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.
- Maintainability: Decouples screens and transitions for modularity. Each screen is independent, making it easier to update when the UI changes.
- Readability: Abstraction through POM improves clarity and reduces selector duplication. Tests become more readable and easier to understand.
- Performance and Stability: Centralized waits and stabilization utilities reduce test flakiness and improve reliability.
- Observability Ready: Prepares for metrics, reporting, and AI-based test classifications to better understand test results and failures.
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 |-- ……..
-
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
SelectorShortcutsto 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.
- Tests call Page Screen to perform actions.
- Page Screens use Selectors to find elements.
-
Selectors use SelectorHelper to translate identifiers into real
XCUIElements
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.
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.
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.
- Consistency Across Tests: All tests use the same locator strategy for the same elements, reducing discrepancies and making tests more reliable.
- 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.
- Readability: Compare these two approaches:
- Type Safety and Grouping: Each selector includes metadata like description and groups, making it easier to organize, search, and document UI elements.
- Reduced Boilerplate: Common patterns (finding by ID, label, or predicate) are abstracted into simple method calls, reducing code duplication.
For the available Shortcut Methods see the appendix of this document.
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:
-
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. -
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. - 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.
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).
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.
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().
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)
}
}
- Separate Actions from Assertions: Use MARK comments to group related methods.
- Always Wait for Elements: Use mozWaitForElementToExist before interacting with elements to reduce flakiness.
- Use Descriptive Method Names: Method names should clearly describe what they do (e.g., tapTabsButton instead of tap).
- Keep Page Objects Focused: Each page object should represent a single screen or component.
- Make Timeouts Configurable: Allow custom timeouts for slow operations while using sensible defaults.
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:
- What screens exist in the app
- How to transition from one screen to another
- What actions can be performed on each screen
- What conditions affect navigation (e.g., logged in vs logged out)
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
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 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).
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 states4. 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
}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)
}
}All registration files follow this pattern:
@MainActor
func register<FeatureName>Navigation(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
// Define screen states and their transitions
}map.addScreenState(SettingsScreen) { screenState in
// Define what you can do from this screen
}// 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
)// 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()
}screenState.press(link, to: WebLinkContextMenu)
screenState.press(image, to: WebImageContextMenu)screenState.backAction = navigationControllerBackAction(for: app)
// This is a function that taps the navigation bar back buttonscreenState.dismissOnUse = true // For modals/menus that close after usescreenState.noop(to: BrowserTab) // Stay on BrowserTab after actionscreenState.onEnter { userState in
userState.numTabs = Int(app.otherElements["TabsTray"].cells.count)
}// Action that can be performed from anywhere
map.addScreenAction(Action.LoadURL, transitionTo: WebPageLoading)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
| 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 |
class BaseTestCase: XCTestCase {
var navigator: MMNavigator<FxUserState>!
override func setUp() {
let screenGraph = createScreenGraph(for: self, with: app)
navigator = MMNavigator(with: screenGraph)
}
}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)
}
}// registerMyFeatureNavigation.swift
import XCTest
import MappaMundi
@MainActor
func registerMyFeatureNavigation(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
// Your navigation logic here
}// Add to FxScreenGraph.swift (line 28-89)
let MyFeatureScreen = "MyFeatureScreen"
let MyFeatureSettings = "MyFeatureSettings"// Add to FxScreenGraph.swift Action class (line 173-292)
static let PerformMyFeatureAction = "PerformMyFeatureAction"@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)
}
}// NavigationRegistry.swift
static func registerAll(in map: MMScreenGraph<FxUserState>, app: XCUIApplication) {
// ... existing registrations
registerMyFeatureNavigation(in: map, app: app) // Add this line
}class MyFeatureTests: BaseTestCase {
func testFeature() {
navigator.goto(MyFeatureScreen)
navigator.performAction(Action.PerformMyFeatureAction)
// Test your feature
}
}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.
- 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
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
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()
}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
└─────────────────┘
Your test file that contains test methods.
Location: Tests/XCUITests/
Example: BookmarksTests.swift, NavigationTest.swift
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.
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"]
)
}- Basic knowledge of Swift and XCTest
- Familiarity with XCUITest framework
- Understanding of the app's UI structure
- Access to the Firefox iOS codebase
import Common
import XCTestYour test should inherit from BaseTestCase or FeatureFlaggedTestBase:
class BookmarksTests: FeatureFlaggedTestBase {
// TAE tests go here
}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!
}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)
}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")
}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)
}
}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] }
}- 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
}- End with
Screensuffix:BrowserScreen,SettingsScreen - Use
@MainActorattribute for thread safety - Mark as
finalif not meant to be subclassed
@MainActor
final class BrowserScreen {
// implementation
}-
Actions: Start with verb (tap, enter, select, swipe)
tapOnAddressBar()enterSearchText(text: String)selectBookmark(name: String)
-
Assertions: Start with
assertassertAddressBarExists()assertBookmarkListCount(numberOfEntries: Int)assertSaveButtonIsEnabled()
-
Waiting: Start with
waitwaitForPageLoad()waitForElementToDisappear()
- 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"]
)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
}
}@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
}
}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")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)
}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 |
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()
}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")
}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 appProblem: 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()- 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()
}- Check Element Hierarchy:
func printElementTree() {
print(app.debugDescription)
}-
Use Accessibility Inspector:
- Open Xcode → Developer Tools → Accessibility Inspector
- Inspect elements to find correct identifiers and types
- 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.
- 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.
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.