Add Controls

Model the interactive elements on a screen as Controls — locators plus actions — so every TestStep built on top resolves the same element the same way.

← Back to overview · 🇩🇪 Deutsch · ← Add a New App · Build TestSteps →


Understand what a Control models

A Control wraps everything needed to find and act on one kind of element on your screen: the locator strategy (an ordered list of Core.xpath fragments) plus a small set of exported functions (click, fill, checkVisible, …) that TestSteps call by label. One Control file covers every instance of that element kind on the screen — a single Button.ts handles every button, parametrized by its label, rather than one file per concrete button. See Concepts — 1_Controls/ for how this fits into the three-layer picture; this page covers writing one for real.


Pick locators for your Control

Tag every locator with Core.xpath instead of a raw template-literal string:

Core.xpath`.//button[.='${label}']`

Core.xpath produces the exact same string as the equivalent raw template literal at runtime — its only extra job is marking the locator as writeback-eligible, so a Self-Healing repair can find and update this exact line later. Tagging costs nothing and is always safe to do, whether or not you ever turn Self-Healing on.

A parametric locator interpolates a runtime value (${label}, ${rowIndex}) instead of hardcoding one — every Control above is parametric, since a single locate(label) function serves every button on the screen. A concrete locator hardcodes a specific value and only matches one element. Prefer parametric whenever the same shape of element repeats with different labels — which is the common case — since a healed value that matches your template’s shape gets re-emitted as a template extension, not a one-off literal. See Self-Healing — Recognize when a healed value already fits a parametric locator for what that means for a heal in practice.


Add a Button Control

// 2_Apps/YourApp/1_Controls/Button.ts
import * as Core from '@Core/References';

async function locate(
    label: string,
    searchTimeout: number = Core.Constant.searchTimeout,
    checkExists: boolean = true,
    checkVisible: boolean = true,
): Promise<Core.Locator | undefined> {
    return Core.findLocators(
        undefined,
        [
            Core.xpath`.//button[.='${label}']`,
            Core.xpath`.//*[@role='button' and .='${label}']`,
        ],
        searchTimeout,
        checkExists,
        checkVisible,
    );
}

/** Clicks the button identified by `label`. */
export async function click(label: string): Promise<void> {
    const target = await locate(label);
    await Core.Action.click(target);
}

/** Asserts that the button identified by `label` is visible. */
export async function checkVisible(label: string): Promise<void> {
    const target = await locate(label, Core.Constant.searchTimeoutNotExists, false, false);
    await Core.Check.isVisible(target, true);
}

One locate helper, private to the file, resolves the element; every exported function calls it and then does exactly one thing — an action (Core.Action.*) or an assertion (Core.Check.*). searchTimeoutNotExists is a shorter timeout for assertions that are allowed to fail fast (checking visibility shouldn’t wait the full default timeout before concluding “not visible”).


Add a Textfield Control

// 2_Apps/YourApp/1_Controls/Textfield.ts
import * as Core from '@Core/References';

async function locate(
    label: string,
    searchTimeout: number = Core.Constant.searchTimeout,
    checkExists: boolean = true,
    checkVisible: boolean = true,
): Promise<Core.Locator | undefined> {
    return Core.findLocators(
        undefined,
        [
            Core.xpath`.//input[@aria-label='${label}']`,
            Core.xpath`.//label[.='${label}']/following::input[1]`,
        ],
        searchTimeout,
        checkExists,
        checkVisible,
    );
}

/** Fills the text field identified by `label` with `value`. */
export async function fill(label: string, value: string): Promise<void> {
    const target = await locate(label);
    await Core.Action.fill(target, value);
}

/** Reads the current value of the text field identified by `label`. */
export async function getText(label: string): Promise<string> {
    const target = await locate(label);
    return target instanceof Core.StrategyLocator ? target.getText() : ((await target?.inputValue()) ?? '');
}

label is the parametric part of every locator above — the same two-line locate shape covers every text field on the screen, keyed by its accessible label rather than a hardcoded selector per field.


Add a Combobox Control

// 2_Apps/YourApp/1_Controls/Combobox.ts
import * as Core from '@Core/References';

async function locate(
    label: string,
    searchTimeout: number = Core.Constant.searchTimeout,
    checkExists: boolean = true,
    checkVisible: boolean = true,
): Promise<Core.Locator | undefined> {
    return Core.findLocators(
        undefined,
        [Core.xpath`.//select[@aria-label='${label}']`],
        searchTimeout,
        checkExists,
        checkVisible,
    );
}

/** Selects `option` in the combobox identified by `label`. */
export async function select(label: string, option: string): Promise<void> {
    const target = await locate(label);
    await Core.Action.selectOption(target, option);
}

/** Asserts that the combobox identified by `label` currently has `expected` selected. */
export async function checkOption(label: string, expected: string): Promise<void> {
    const target = await locate(label, Core.Constant.searchTimeoutNotExists, false, false);
    await Core.Check.textOrValueIsSet(target, expected);
}

Same three-part shape again — locate plus one action (select) plus one assertion (checkOption) — even though a combobox has more moving parts than a button. Add getValue() (mirroring Textfield.getText() above) once a TestStep needs to read the current selection back rather than just asserting it.


Add locators that survive UI changes

Order fragments most-specific-first and stay structural — text content, ARIA role, associated <label> — rather than reaching for @id, [@data-testid], or [@data-cy]:

// Avoid — brittle, tied to a specific build's generated attribute
Core.xpath`.//*[@id='submit-btn-42']`

// Prefer — structural, survives a markup/build-tool change
Core.xpath`.//button[.='${label}']`

This is the exact rule the pre-commit hook enforces as R4 — XPath Style; see Skeleton Conventions — R4 for the full check and how to fix a violation.

💡 Desktop Controls only: Core.SemanticType. A Web Control never needs this — the DOM tag and ARIA role already tell a Vision-based heal what kind of element it’s looking at. A Desktop/Appium-Windows Control benefits from passing the matching Core.SemanticType value (SemanticType.Button, SemanticType.Textfield, …) alongside its locator: it gives a heal attempt the same “what kind of element is this” hint a Web Control gets for free from markup.


Where to place the Control file

2_Apps/YourApp/1_Controls/Button.ts
2_Apps/YourApp/1_Controls/Textfield.ts
2_Apps/YourApp/1_Controls/Combobox.ts

One file per element kind, named after that kind (Button.ts, not Control_Button.ts or ButtonControl.ts), directly under 1_Controls/. 2_Apps/1_Global/References.ts re-exports each file with a Controls-prefixed namespace:

// 2_Apps/1_Global/References.ts
export * as ControlsButton from '../YourApp/1_Controls/Button';
export * as ControlsTextfield from '../YourApp/1_Controls/Textfield';
export * as ControlsCombobox from '../YourApp/1_Controls/Combobox';

A TestStep then imports and calls it as ControlsButton.click(label) — see Build TestSteps next for how a Control turns into a tester-facing step.


Next step: build TestSteps

With your Controls in place, compose them into TestSteps a TestCase author calls by name — no locator in sight.

Next: Build TestStepsCore.defineTestStep, step types, and localized descriptions.


📧 Questions? Contact: jens.szelag@itsbusiness.ch

itsbusiness AG · Bern · Switzerland