Canonical patterns for writing step definition classes in the target TypeScript test framework. Covers file placement, the @StepDefinitions class decorator, the @CSBDDStepDef / @Given / @When / @Then / @And / @But step decorators, page injection via @Page, parameter types, CSBDDContext access, CSValueResolver for config references, hook decorators (@CSBefore / @CSAfter / @CSBeforeStep / @CSAfterStep), step deduplication rules, reporter usage, and forbidden patterns. Load when generating, auditing, or healing any .steps.ts file.
Any generated or modified step definitions file — typically
filenames ending in .steps.ts under test/<project>/steps/.
test/<project>/steps/, or nested by module for
large projects..steps.ts (e.g.,
user-login.steps.ts, order-management.steps.ts).
NEVER PascalCase (UserLoginSteps.ts is wrong).Steps (e.g., UserLoginSteps).index.ts barrel files in the steps/ folder..steps.ts suffix —
files with a different extension won't be discovered.Standard shape for a step definitions file:
import { CSBDDStepDef, Page, StepDefinitions, When, Then, Given, And }
from '<framework>/bdd';
import { CSBDDContext } from '<framework>/bdd';
import { CSReporter } from '<framework>/reporter';
import { CSValueResolver } from '<framework>/utilities';
import { LoginPage } from '../pages/LoginPage';
import { HomePage } from '../pages/HomePage';
Rules:
A step definitions file defines a single class annotated with
@StepDefinitions. Inside the class:
@Page('<identifier>') decorator. The identifier matches the
@CSPage('<identifier>') on the target page object class.CSBDDContext.getInstance(), if the step def needs to share
state across steps within a scenario.Promise<void>.Minimal shape:
@StepDefinitions
export class UserLoginSteps {
@Page('login-page')
private loginPage!: LoginPage;
@Page('home-page')
private homePage!: HomePage;
private context = CSBDDContext.getInstance();
@When('I login as {string}')
async loginAs(username: string): Promise<void> {
CSReporter.info(`Logging in as ${username}`);
const password = CSValueResolver.resolve(
'{config:APP_PASSWORD}', this.context);
await this.loginPage.loginWithCredentials(username, password);
await this.homePage.verifyHeader();
CSReporter.pass(`Logged in as ${username}`);
}
@Then('I should see the home page header')
async verifyHomeHeader(): Promise<void> {
await this.homePage.verifyHeader();
}
}
The !: non-null assertion on injected page fields is required
because the framework initialises them via decorator reflection.
@Page('<identifier>') injects a page object instance into a
private field.<identifier> string must match the identifier used in the
page class's @CSPage('<identifier>') decorator. If they don't
match, the injection fails at runtime with a not-registered
error.The framework supports two equivalent ways to declare a step:
@CSBDDStepDef('I enter username {string}')
async enterUsername(username: string): Promise<void> {
await this.loginPage.textBoxUsername.fill(username);
}
Use @CSBDDStepDef when the step phrase doesn't cleanly map to
Given/When/Then (for example, a pure action like "I wait for
results to load" that could be any of the three).
@When('I click the Submit button')
async clickSubmit(): Promise<void> {
await this.orderPage.buttonSubmit.click();
}
@Then('I should see confirmation message {string}')
async verifyConfirmation(expected: string): Promise<void> {
await this.orderPage.verifyConfirmation(expected);
}
Use the Gherkin-style decorators when the step phrase starts with the corresponding keyword in the feature file. Matching the decorator to the keyword makes the feature-to-step mapping transparent to readers.
{string} — matches a quoted string{int} — matches an integer{float} — matches a floating-point number{word} — matches a single word@When(/^I click the (.+) button$/)Cucumber-expression types available out of the box:
{string} → string — matches quoted text{int} → number — integer{float} → number — floating point{word} → string — single word, no quotes{any} → any — anything between whitespaceRules:
{string} → username: string, {int} → count: number).CSBDDContext.getInstance() returns a singleton scenario-scoped
context. Use it to pass values between steps in the same
scenario without global mutable state.
private context = CSBDDContext.getInstance();
@When('I create a new user with email {string}')
async createUser(email: string): Promise<void> {
const userId = await UserDatabaseHelper.createTestUser(email);
this.context.set('createdUserId', userId);
this.context.set('createdUserEmail', email);
}
@Then('the created user should exist in the system')
async verifyCreatedUser(): Promise<void> {
const userId = this.context.get<string>('createdUserId');
const user = await UserDatabaseHelper.findUserById(userId);
await CSAssert.getInstance().assertNotNull(
user, `User ${userId} should exist`);
}
Available methods on CSBDDContext:
set(key, value) / get<T>(key) / has(key) / delete(key)getAll() / clear()setVariable(name, value) / getVariable<T>(name) — alternate
API for config-style variablesstoreTestData(key, data) / getTestData<T>(key) — alternate
API for structured test dataaddAssertion(description, passed, actual?, expected?) —
record a custom assertion in the scenario reportaddStepResult(step, status, duration, screenshot?) — manual
step tracking when neededContext lifetime: one scenario. The framework clears it between scenarios automatically. Never treat context as cross-scenario shared state — use a helper class with static storage for that.
Feature file parameters and data file values can reference config
variables via {config:VAR_NAME}. The step definition resolves
them using CSValueResolver.resolve(...) before passing to the
page object.
@When('I login with stored credentials for role {string}')
async loginForRole(role: string): Promise<void> {
const username = CSValueResolver.resolve(
`{config:APP_USER_${role.toUpperCase()}}`, this.context);
const password = CSValueResolver.resolve(
`{config:APP_PASSWORD_${role.toUpperCase()}}`, this.context);
await this.loginPage.loginWithCredentials(username, password);
}
Resolver syntax:
{config:VAR_NAME} — read from configuration hierarchy{env:VAR_NAME} — read from environment variable{ctx:key} — read from the current BDD context{data:field} — read from the current scenario data rowResolution happens recursively — a config value containing
another {config:...} reference resolves transitively.
Hooks live in a step definition class alongside step methods.
They use the framework's hook decorators with a tags filter to
scope execution.
Available hooks:
@CSBefore(options?) — runs before each scenario@CSAfter(options?) — runs after each scenario@CSBeforeStep(options?) — runs before each step@CSAfterStep(options?) — runs after each stepOptions:
tags?: string[] — array of tag names. The hook runs only for
scenarios carrying at least one of the listed tags.order?: number — integer; lower runs earlier. Use to sequence
multiple hooks that apply to the same tag.Example:
@StepDefinitions
export class TestDataHooks {
@CSBefore({ tags: ['@needs-test-user'], order: 1 })
async createTestUser(): Promise<void> {
CSReporter.info('Creating test user');
await UserDatabaseHelper.createTestUser('[email protected]');
}
@CSAfter({ tags: ['@needs-test-user'] })
async cleanupTestUser(): Promise<void> {
CSReporter.info('Cleaning up test user');
await UserDatabaseHelper.deleteTestUsers('test-user');
}
}
Rules:
@StepDefinitions class, not
standalone functionsPromise<void>@CSBefore fails the scenario@CSAfter is logged but does not fail the scenarioBefore writing a new step definition, search the entire
test/<project>/steps/ folder for an existing step with the same
phrase or a phrase that would match the same feature line. The
audit checklist performs this check automatically and rejects
files with duplicate patterns.
Rules:
async and returns Promise<void>CSReporter.info at the start of
the logical action and once via CSReporter.pass on successCSAssert.getInstance().assert* for any
verification — never silently returns on failureCSValueResolver.resolve to handle {config:...},
{env:...}, {ctx:...}, and {data:...} references in
parameters before passing them onThe step definition is the primary layer for scenario-level
reporting. Use these CSReporter static methods:
CSReporter.info(message) — informational (start of a step)CSReporter.pass(message) — success (end of a step)CSReporter.warn(message) — non-fatal issueCSReporter.debug(message) — verbose, shown only when debug
mode is enabledCSReporter.error(message) — error that continues the runCSReporter.fail(message) — fatal failure, typically followed
by a throwNever use console.log, console.error, or any other logger.
The framework's reporter integrates with the run log, screenshots,
and the HTML report. Raw console calls bypass all of that.
Never do any of these in a step definitions file:
new — always use @Page
injectionCSBDDContext instances with new — always use
getInstance()@playwright/test and call raw Playwright
API methods — use framework wrappersconsole.log or any non-framework loggerCSDBUtilsfunction keyword for step methods — always arrow-compat
async methods on the classhelpers/.steps.ts@StepDefinitionsSteps@Page decorator is used for every page object, with
identifiers matching the page class @CSPage valuesPromise<void>{config:...} and {env:...} values go through
CSValueResolver.resolve(...)CSBDDContext is obtained via getInstance(), not new@CSBefore / @CSAfter / @CSBeforeStep /
@CSAfterStep with tags scopingCSReporter.info at start and CSReporter.pass at end of
each step's logical actionconsole.log, no raw Playwright APIs, no SQL stringsnewCSAssert.getInstance().assert*If any item fails, fix it before calling npx tsc --noEmit via run_in_terminal. The
audit checklist enforces most of these rules.