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 againstTS_<Type>_<Element>_<Action>and everydefineTestStep’sruncallback for thepageLogName/sectionNameleading 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.
TReturndefaults tovoid, so every step written before this generic existed compiles and behaves byte-identically — only a step whoseruncallback itself returns a value picks up a non-voidreturn 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_LOCALE → GlobalConfig.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