Build TestSteps

Compose Controls into tester-facing TestSteps — the business-level actions a TestCase author calls by name, with a description they can read without opening the file.

← Back to overview · 🇩🇪 Deutsch · ← Add Controls · Compose your TestCase →


Understand what a TestStep models

A TestStep combines one or more Control calls into a single business-meaningful action and gives it a name a non-technical reader recognizes — TS_Main_Button_Click, not clickTheSubmitButton. Where a Control knows how to find and operate an element, a TestStep knows which Controls to call and in what order for one tester-facing action; a TestCase then only ever calls TestSteps, never a Control directly. A TestStep is also where a reference screenshot gets bound for Self-Healing — see Self-Healing — Setup — since that binding lives in the same refId argument every step below accepts.


Choose the TestStep type

The tester… Step type Example
interacts with an element on the main view TS_Main_* TS_Main_Button_Click
resolves a modal dialog TS_Dialog_* TS_Dialog_Button_Click
reacts to a confirmation or error message TS_Message_* TS_Message_Button_Click
starts/stops/navigates the App itself TS_Execution_* TS_Execution_Start
does something no Control × Action combination covers yet TS_Custom_* see Custom Steps

TS_Main/TS_Dialog/TS_Message all use the same Core.defineTestStep factory and the same Control set — only the semantic scope differs. TS_Execution uses a separate factory, Core.defineExecutionStep, since it manages the App session rather than one screen element.

🛡️ Enforced automatically. The pre-commit hook checks every TS_* export’s name against TS_<Type>_<Element>_<Action> and every defineTestStep’s run callback for the pageLogName/sectionName leading parameters shown below — see Skeleton Conventions — R2 and R3.


Build a TS_Main step

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

/** Clicks the button identified by `label`. */
export const TS_Main_Button_Click = Core.defineTestStep('YourApp', {
    descriptionI18n: {
        key: 'TS_Main_Button_Click',
        values: (pageLogName: string, _sectionName: string, label: string) => ({ pageLogName, label }),
    },
    run: (_pageLogName: string, _sectionName: string, label: string) => ControlsButton.click(label),
});

Core.defineTestStep(appId, spec) hides Inspector.bindReference(refId), the App-scoping needed to resolve appId’s Controls, and Step.numberedStep — you write descriptionI18n (what testers read) and run (what happens). The returned function’s call shape is (refId, pageLogName, sectionName, label) => Promise<void>; pass refId = '' when no Self-Healing reference is bound for this call:

await Project.YourApp.TS_Main_Button_Click('', 'Registration', '', 'Submit');

run’s first two parameters, pageLogName and sectionName, are unused inside the function body (hence the _ prefix) but still part of the signature — they flow straight into descriptionI18n.values so the rendered step title can read “On page ‘Registration’, click the button ‘Submit’” without run itself needing that context.


Build a TS_Dialog step

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

/** Clicks the button identified by `label` inside the (currently open) dialog. */
export const TS_Dialog_Button_Click = Core.defineTestStep('YourApp', {
    descriptionI18n: {
        key: 'TS_Dialog_Button_Click',
        values: (pageLogName: string, _sectionName: string, dialogName: string, label: string) => ({ pageLogName, dialogName, label }),
    },
    run: (_pageLogName: string, _sectionName: string, _dialogName: string, label: string) => ControlsButton.click(label),
});

The only structural difference from TS_Main: an extra dialogName parameter, right after pageLogName/sectionName, so the rendered title can name the dialog (“In dialog ‘Confirm delete’, click ‘Yes’”). The Control call underneath is unchanged — a modal dialog still renders into the page DOM, so ControlsButton.click(label) finds it the same way it finds a main-view button.


Build a TS_Message step

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

/** Asserts that the currently shown message contains `expectedText`. */
export const TS_Message_CheckText = Core.defineTestStep('YourApp', {
    descriptionI18n: {
        key: 'TS_Message_CheckText',
        values: (pageLogName: string, _sectionName: string, expectedText: string) => ({ pageLogName, expectedText }),
    },
    run: async (_pageLogName: string, _sectionName: string, expectedText: string) => {
        const target = await Core.findLocators(
            undefined,
            [Core.xpath`.//*[contains(@class,'message') and contains(.,'${expectedText}')]`],
            Core.Constant.searchTimeoutNotExists,
            false,
            false,
        );
        await Core.Check.exists(target, true);
    },
});

/** Clicks the button identified by `label` inside the (currently visible) message (e.g. "Dismiss"). */
export const TS_Message_Button_Click = Core.defineTestStep('YourApp', {
    descriptionI18n: {
        key: 'TS_Message_Button_Click',
        values: (pageLogName: string, _sectionName: string, label: string) => ({ pageLogName, label }),
    },
    run: (_pageLogName: string, _sectionName: string, label: string) => ControlsButton.click(label),
});

A confirmation/error message rarely deserves a dedicated Control — a one-off Core.findLocators check directly inside run, like TS_Message_CheckText above, is the pragmatic choice for a single call site. Reach for Core.Check.exists when only presence matters, or thread the resolved locator into Core.Check.textOrValueIsSet when the exact text matters too.


Match dynamic text with the ** wildcard

A label, pageLogName, dialogName, or expected-content string can embed the token ** to match a stable part of a text while skipping over a part that changes at runtime — a running order number, a timestamp, a generated id — without hand-writing an XPath:

Match string Matches
Order confirmed exact text Order confirmed (no ** present — today’s exact-match behavior, unchanged)
Order** starts with Order
**confirmed ends with confirmed
**Order** contains Order anywhere
Order**2024 starts with Order AND ends with 2024, in that order
await Project.YourApp.TS_Main_Label_ByXpath_CheckIsEqual('Orders', '', './/tr[1]/td[2]', 'Order #**');
await Project.YourApp.TS_Main_Button_Click('Orders', '', 'Delete order **');

Whitespace around the matched text is normalized before comparing, so incidental formatting differences never break a match on their own. A match string with no ** behaves exactly as it always has — this is purely additive.

The wildcard works wherever a Control looks up an element by its label or text, and wherever a Check.textContent* / textOrValueIsSet assertion compares expected content. It does not apply to values you write INTO the AUT — Textfield.fill, Combobox.select, and Link.checkHref always treat their argument as a literal.

💡 A single *. Only the exact two-character token ** triggers wildcard mode — one * has no special meaning and is matched as an ordinary character.


Guard against the wrong page or dialog: checkTitleFromPageExists / checkTitleFromDialogExists

Every TS_Main_* step accepts a trailing, optional checkTitleFromPageExists: boolean = false; every TS_Dialog_* step accepts the equivalent checkTitleFromDialogExists: boolean = false. Set it to true and the step first confirms that pageLogName (or dialogName) actually appears somewhere on the current page or dialog, before performing its own Control action:

await Project.YourApp.TS_Dialog_Button_Click('Confirm delete', '', 'Yes', true);

Without the check, a step acts on whatever page or overlay is currently in front — usually correct, but silently wrong if a test navigated somewhere unexpected or two overlays are open at once. With the check on, a mismatch throws a clear, immediately visible “wrong page/dialog” error instead of a passing test that acted on the wrong screen.

The flag also disambiguates two dialogs open at the same time: when it’s true, dialogName doubles as a title guard, and the step only proceeds against the overlay whose content actually matches it. dialogName/pageLogName can use the ** wildcard from the section above at the same time, so checkTitleFromDialogExists combined with 'Order ** confirmed' resolves whichever order’s confirmation dialog is currently open.

Leaving the flag at its default false keeps every existing call working unchanged — no title probe runs, and the step resolves the first matching page/overlay it finds, exactly as before this flag existed.


Return a value from a Get/Read step

Core.defineTestStep<TArgs, TReturn = void> is generic over the value run hands back to the caller. Every step above returns void — an action performs something, a Check* step asserts and throws on mismatch, neither hands anything back. A Get*/Read*/List* step is different: it determines a value, so run’s own return type flows straight through — no type argument to write by hand, TypeScript infers TReturn from run’s return type:

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

/** Reads the current value of the text field identified by `label`. */
export const TS_Main_Textfield_GetText = Core.defineTestStep('YourApp', {
    descriptionI18n: {
        key: 'TS_Main_Textfield_GetText',
        values: (pageLogName: string, _sectionName: string, label: string) => ({ pageLogName, label }),
    },
    run: (_pageLogName: string, _sectionName: string, label: string) => ControlsTextfield.getText(label),
});

The exported function’s call shape becomes (refId, pageLogName, sectionName, label) => Promise<string> — the value is still logged via Core.Step.logParam(...) exactly as before, but a TestCase can now also assign it and use it directly:

// 3_Cases/TC_MyFlow.spec.ts
import * as Project from '@GlobalRef';

const currentValue = await Project.YourApp.TS_Main_Textfield_GetText('', 'Profile', '', 'Display name');
await Core.Check.textOrValueIsSet(currentValue, true, 'Jane Doe');
Step verb TReturn Behavior
Get* / Read* / List* the determined value’s type returns the value and logs it, same call shape otherwise
Check* void (default) stays an assertion — throws on mismatch, nothing to return
any action verb (Click, Fill, Start, …) void (default) stays fire-and-forget

💡 Nothing to migrate. TReturn defaults to void, so every step written before this generic existed compiles and behaves byte-identically — only a step whose run callback itself returns a value picks up a non-void return automatically.


Build a TS_Execution step

// 2_Apps/YourApp/2_Steps/TS_Execution.ts
import * as Core from '@Core/References';

/** Starts the browser and navigates to `env.url`. */
export const TS_Execution_Start = Core.defineExecutionStep(
    'YourApp',
    'Start',
    (env) => ({
        descriptionI18n: {
            key: 'TS_Execution_Start',
            values: () => ({ url: env.url }),
        },
        run: () => Core.I_BrowserHandler.start(env.page, env.url, { waitForReady: true }),
    }),
);

/** Logically closes the browser session and navigates to `about:blank`. */
export const TS_Execution_Close = Core.defineExecutionStep(
    'YourApp',
    'Close',
    (env) => ({
        descriptionI18n: {
            key: 'TS_Execution_Close',
            values: () => ({}),
        },
        run: () => Core.I_BrowserHandler.close(),
    }),
);

defineExecutionStep(appName, action, factory) is env-aware and family-aware: factory receives { page, url, appConfig } for a Web AUT or { executable, appiumUrl, appConfig } for a Desktop one — resolved from GlobalConfig.apps['YourApp'] automatically, no page/url typed by hand anywhere in this file. See API Reference — Section 16 for the full family-aware env shape.


Add step descriptions your testers can read

descriptionI18n.key looks itself up in a sibling .i18n.json catalog, one entry per step, ${placeholder} names matching the parameters passed to values:

{
  "TS_Main_Button_Click": {
    "en": "On the page '${pageLogName}' click on the button with the label '${label}'",
    "de": "Auf der Seite '${pageLogName}' auf die Schaltfläche mit dem Label '${label}' klicken"
  }
}

Every locale variant must reuse the exact same ${placeholder} names as the en entry — a validator enforces this at commit-time, so a translator renaming or dropping a placeholder fails the build instead of silently rendering undefined in one locale. Matching placeholders is also what lets values stay a single function shared across every locale: it returns one plain object, and each locale’s catalog string decides which of its keys to show and in what order. See Step-Description Localization for the full placeholder contract, adding a new locale, and the runtime resolution order (Core.i18n.setLocale(...)CC_TESTFRAMEWORK_LOCALEGlobalConfig.language'en').


Where to place the TestStep file

2_Apps/YourApp/2_Steps/TS_Main.ts
2_Apps/YourApp/2_Steps/TS_Main.i18n.json
2_Apps/YourApp/2_Steps/TS_Dialog.ts
2_Apps/YourApp/2_Steps/TS_Dialog.i18n.json

One file per step family, its .i18n.json catalog directly next to it — the pre-commit hook rejects a step file with at least one defineTestStep call but no sibling catalog. See Writing Your First TestCase — Re-export convention for why 2_Apps/1_Global/References.ts re-exports every step flat (no Steps-prefixed namespace, unlike Controls) and how a TestCase then calls it.


Next step: compose your TestCase

With TestSteps in place for every action your business flow needs, compose them into a TestCase.

Next: Writing Your First TestCase — naming conventions, the re-export barrel, and your first passing flow.


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

itsbusiness AG · Bern · Switzerland