Skip to main content

Adding Step Definitions

Guide to creating new step definitions for @esimplicity/stack-tests.

Overview

Step definitions bridge Gherkin feature files with port operations. They translate natural language into code.

Step Definition Structure

Basic Pattern

// steps/email.basic.ts

import type { BddWorld } from '../fixtures.js';

export function registerEmailSteps(
Given: GivenFunction,
When: WhenFunction,
Then: ThenFunction
): void {

When(
'I send an email to {string} with subject {string}',
async function(this: BddWorld, to: string, subject: string) {
await this.email.sendEmail(to, subject, 'Default body');
}
);

}

With DocString

When(
'I send an email to {string} with subject {string} and body:',
async function(this: BddWorld, to: string, subject: string, body: string) {
await this.email.sendEmail(to, subject, body);
}
);

Usage in feature file:

When I send an email to "user@test.com" with subject "Welcome" and body:
"""
Hello!

Welcome to our platform.
"""

With Data Table

When(
'I send emails to the following recipients:',
async function(this: BddWorld, dataTable: DataTable) {
const rows = dataTable.hashes();

for (const row of rows) {
await this.email.sendEmail(row.to, row.subject, row.body);
}
}
);

Usage:

When I send emails to the following recipients:
| to | subject | body |
| alice@test.com | Hello Alice | Welcome Alice |
| bob@test.com | Hello Bob | Welcome Bob |

Step Categories

Given Steps (Setup)

// Preconditions - setup state before action
Given(
'an email exists for {string}',
async function(this: BddWorld, email: string) {
// Seed test data via mock or API
if (this.email instanceof MockEmailAdapter) {
this.email.addEmail({
from: 'system@test.com',
to: email,
subject: 'Test Email',
body: 'Test content',
receivedAt: new Date(),
});
}
}
);

Given(
'no emails exist',
async function(this: BddWorld) {
await this.email.clearEmails();
}
);

When Steps (Actions)

// Actions that trigger behavior
When(
'I send an email to {string}',
async function(this: BddWorld, to: string) {
await this.email.sendEmail(to, 'Default Subject', 'Default Body');
this.variables['lastEmailTo'] = to;
}
);

When(
'I wait for an email to {string}',
async function(this: BddWorld, to: string) {
const email = await this.email.waitForEmail(to);
this.lastEmail = email;
}
);

Then Steps (Assertions)

// Verify outcomes
Then(
'an email should have been sent to {string}',
async function(this: BddWorld, to: string) {
const email = await this.email.getLastEmail(to);
expect(email).not.toBeNull();
}
);

Then(
'the email subject should be {string}',
async function(this: BddWorld, expectedSubject: string) {
expect(this.lastEmail).toBeDefined();
expect(this.lastEmail.subject).toBe(expectedSubject);
}
);

Then(
'the email body should contain {string}',
async function(this: BddWorld, text: string) {
expect(this.lastEmail).toBeDefined();
expect(this.lastEmail.body).toContain(text);
}
);

Step-by-Step Guide

1. Plan Step Definitions

List the Gherkin steps you want to support:

# Email steps to implement
Given no emails exist
Given an email exists for {string} with subject {string}
When I send an email to {string} with subject {string}
When I send an email to {string} with subject {string} and body:
When I wait for an email to {string}
When I wait for an email to {string} with subject containing {string}
Then an email should have been sent to {string}
Then the email subject should be {string}
Then the email subject should contain {string}
Then the email body should contain {string}
Then I store the email subject as {string}

2. Create Step File

// src/steps/email.basic.ts

import { expect } from '@playwright/test';
import type { DataTable } from '@cucumber/cucumber';
import type { BddWorld } from '../fixtures.js';
import type { GivenFunction, WhenFunction, ThenFunction } from './types.js';

/**
* Register basic email step definitions.
*
* These steps require the @email tag on scenarios and
* an EmailPort adapter configured in fixtures.
*
* @example
* ```gherkin
* @email
* Scenario: Send welcome email
* When I send an email to "user@test.com" with subject "Welcome"
* Then an email should have been sent to "user@test.com"
* ```
*/
export function registerEmailSteps(
Given: GivenFunction,
When: WhenFunction,
Then: ThenFunction
): void {

// ============================================
// Given Steps (Setup)
// ============================================

Given(
'no emails exist',
async function(this: BddWorld) {
await this.email?.clearEmails();
}
);

Given(
'an email exists for {string} with subject {string}',
async function(this: BddWorld, to: string, subject: string) {
// This typically requires mock adapter for seeding
throw new Error(
'Seeding emails requires MockEmailAdapter. ' +
'Use real email flow in integration tests.'
);
}
);

// ============================================
// When Steps (Actions)
// ============================================

When(
'I send an email to {string} with subject {string}',
async function(this: BddWorld, to: string, subject: string) {
const resolvedTo = this.resolveVariables(to);
const resolvedSubject = this.resolveVariables(subject);

await this.email!.sendEmail(resolvedTo, resolvedSubject, '');
this.variables['lastEmailTo'] = resolvedTo;
this.variables['lastEmailSubject'] = resolvedSubject;
}
);

When(
'I send an email to {string} with subject {string} and body:',
async function(this: BddWorld, to: string, subject: string, body: string) {
const resolvedTo = this.resolveVariables(to);
const resolvedSubject = this.resolveVariables(subject);
const resolvedBody = this.resolveVariables(body);

await this.email!.sendEmail(resolvedTo, resolvedSubject, resolvedBody);
}
);

When(
'I wait for an email to {string}',
async function(this: BddWorld, to: string) {
const resolvedTo = this.resolveVariables(to);
const email = await this.email!.waitForEmail(resolvedTo);
this.lastEmail = email;
}
);

When(
'I wait for an email to {string} with subject containing {string}',
async function(this: BddWorld, to: string, subjectContains: string) {
const resolvedTo = this.resolveVariables(to);
const resolvedSubject = this.resolveVariables(subjectContains);

const email = await this.email!.waitForEmail(resolvedTo, {
subjectContains: resolvedSubject,
});
this.lastEmail = email;
}
);

// ============================================
// Then Steps (Assertions)
// ============================================

Then(
'an email should have been sent to {string}',
async function(this: BddWorld, to: string) {
const resolvedTo = this.resolveVariables(to);
const email = await this.email!.getLastEmail(resolvedTo);

expect(email, `Expected email to ${resolvedTo}`).not.toBeNull();
}
);

Then(
'the email subject should be {string}',
async function(this: BddWorld, expectedSubject: string) {
const resolved = this.resolveVariables(expectedSubject);

expect(this.lastEmail, 'No email captured').toBeDefined();
expect(this.lastEmail!.subject).toBe(resolved);
}
);

Then(
'the email subject should contain {string}',
async function(this: BddWorld, text: string) {
const resolved = this.resolveVariables(text);

expect(this.lastEmail, 'No email captured').toBeDefined();
expect(this.lastEmail!.subject).toContain(resolved);
}
);

Then(
'the email body should contain {string}',
async function(this: BddWorld, text: string) {
const resolved = this.resolveVariables(text);

expect(this.lastEmail, 'No email captured').toBeDefined();
expect(this.lastEmail!.body).toContain(resolved);
}
);

Then(
'I store the email subject as {string}',
async function(this: BddWorld, varName: string) {
expect(this.lastEmail, 'No email captured').toBeDefined();
this.variables[varName] = this.lastEmail!.subject;
}
);
}

3. Export from Steps Index

Update src/steps/index.ts:

// Existing exports
export { registerApiSteps } from './api.basic.js';
export { registerApiAuthSteps } from './api.auth.js';
export { registerUiSteps } from './ui.basic.js';
export { registerTuiSteps } from './tui.basic.js';
export { registerSharedSteps } from './shared.variables.js';
export { registerHybridSteps } from './hybrid.js';

// New export
export { registerEmailSteps } from './email.basic.js';

// Convenience function to register all steps
export function registerAllSteps(
Given: GivenFunction,
When: WhenFunction,
Then: ThenFunction
): void {
registerApiSteps(Given, When, Then);
registerApiAuthSteps(Given, When, Then);
registerUiSteps(Given, When, Then);
registerTuiSteps(Given, When, Then);
registerSharedSteps(Given, When, Then);
registerHybridSteps(Given, When, Then);
registerEmailSteps(Given, When, Then); // New
}

4. Update BddWorld (if needed)

Add any new state properties:

// src/fixtures.ts

export interface BddWorld {
// Existing...

// New state for email steps
lastEmail?: Email;
}

5. Document Steps

Create docs/reference/steps/email-steps.md:

# Email Step Definitions

Step definitions for testing email functionality.

## Tag

Use `@email` tag on scenarios that use these steps.

## Setup Steps

### Clear emails
\`\`\`gherkin
Given no emails exist
\`\`\`

## Action Steps

### Send email
\`\`\`gherkin
When I send an email to "user@test.com" with subject "Welcome"
\`\`\`

### Send email with body
\`\`\`gherkin
When I send an email to "user@test.com" with subject "Welcome" and body:
"""
Hello and welcome!
"""
\`\`\`

... etc

Best Practices

1. Variable Resolution

Always resolve variables in string parameters:

When(
'I send an email to {string}',
async function(this: BddWorld, to: string) {
// Resolves {varName} patterns
const resolvedTo = this.resolveVariables(to);
await this.email!.sendEmail(resolvedTo, 'Subject', 'Body');
}
);

Usage:

Given I set variable "userEmail" to "test@example.com"
When I send an email to "{userEmail}"

2. Clear Error Messages

Then(
'the email body should contain {string}',
async function(this: BddWorld, text: string) {
expect(
this.lastEmail,
'No email captured. Use "When I wait for an email" first.'
).toBeDefined();

expect(
this.lastEmail!.body,
`Email body should contain "${text}"`
).toContain(text);
}
);

3. Store State for Chaining

When(
'I wait for an email to {string}',
async function(this: BddWorld, to: string) {
const email = await this.email!.waitForEmail(to);

// Store for subsequent Then steps
this.lastEmail = email;

// Also store key fields as variables
this.variables['emailSubject'] = email.subject;
this.variables['emailFrom'] = email.from;
}
);

4. Optional Port Guards

When(
'I send an email to {string}',
async function(this: BddWorld, to: string) {
if (!this.email) {
throw new Error(
'EmailPort not configured. Add createEmail to your fixture options.'
);
}

await this.email.sendEmail(to, 'Subject', 'Body');
}
);

5. Consistent Naming

Follow the pattern: [action] [object] [details]

# Good - consistent pattern
When I send an email to {string}
When I send a request to {string}
When I click the button {string}

# Avoid - inconsistent
When email is sent to {string}
When {string} receives an email
When clicking on {string}

Testing Steps

// tests/steps/email.steps.test.ts

import { describe, it, expect, beforeEach } from 'vitest';
import { registerEmailSteps } from '../../src/steps/email.basic.js';
import { MockEmailAdapter } from '../../src/adapters/email/mock-email.adapter.js';

describe('Email Step Definitions', () => {
let world: BddWorld;
let steps: Map<string, Function>;

beforeEach(() => {
world = createTestWorld({
email: new MockEmailAdapter(),
});

steps = new Map();
registerEmailSteps(
(pattern, fn) => steps.set(`Given ${pattern}`, fn.bind(world)),
(pattern, fn) => steps.set(`When ${pattern}`, fn.bind(world)),
(pattern, fn) => steps.set(`Then ${pattern}`, fn.bind(world))
);
});

describe('When I send an email to {string}', () => {
it('should call email port sendEmail', async () => {
const step = steps.get('When I send an email to {string} with subject {string}');

await step('test@example.com', 'Hello');

const emails = (world.email as MockEmailAdapter).getAllEmails();
expect(emails).toHaveLength(1);
expect(emails[0].to).toBe('test@example.com');
});
});

describe('Then an email should have been sent to {string}', () => {
it('should pass when email exists', async () => {
(world.email as MockEmailAdapter).addEmail({
from: 'sender@test.com',
to: 'recipient@test.com',
subject: 'Test',
body: 'Body',
receivedAt: new Date(),
});

const step = steps.get('Then an email should have been sent to {string}');

await expect(step('recipient@test.com')).resolves.not.toThrow();
});

it('should fail when email missing', async () => {
const step = steps.get('Then an email should have been sent to {string}');

await expect(step('missing@test.com')).rejects.toThrow();
});
});
});

Checklist

  • Steps follow Given/When/Then pattern
  • Variable resolution in all string params
  • Clear error messages
  • State stored for step chaining
  • Exported from steps index
  • JSDoc documentation
  • Unit tests written
  • Reference documentation updated