Skip to main content

Custom Steps Guide

Create domain-specific step definitions to extend the framework.

Overview

Custom steps let you encapsulate domain logic in reusable Gherkin patterns.

Generating Step Stubs

When you have feature files with undefined steps, use the step stub generator to create a starting point:

npm run gen:stubs

This creates features/steps/generated-stubs.ts with stub implementations:

/**
* Generated Step Stubs
* Generated: 2024-01-15T10:30:00.000Z
* Total stubs: 15
*/

import { createBdd } from 'playwright-bdd';
import { test } from './fixtures.js';

const { Given, When, Then } = createBdd(test);

// ============================================================================
// GIVEN STEPS (5)
// ============================================================================

// TODO: Implement this step
Given('a user exists with email {string}', async ({ world }, str0: string) => {
throw new Error('Step not implemented: a user exists with email {string}');
});

// ... more stubs

Then:

  1. Add the import to steps.ts: import './generated-stubs.js';
  2. Implement each stub (replace throw with actual logic)
  3. Move implemented steps to appropriate files
  4. Run npm run gen and npm test

Tip: While implementing steps incrementally, tag your feature with @wip and configure your project to exclude it with tags: '@api and not @wip'. This lets you run tests for completed features while working on new ones. See Managing Work-in-Progress Features.

Creating Custom Steps

Basic Step File

// features/steps/custom/user.steps.ts
import { createBdd } from 'playwright-bdd';
import { test } from '../fixtures';
import { interpolate } from '@esimplicity/stack-tests';

const { Given, When, Then } = createBdd(test);

Given('a user exists with email {string}', { tags: '@api' },
async ({ api, world }, email: string) => {
const resolvedEmail = interpolate(email, world.vars);

const result = await api.sendJson('POST', '/admin/users', {
email: resolvedEmail,
password: 'TestPassword123',
role: 'member',
}, world.headers);

if (result.status !== 201) {
throw new Error(`Failed to create user: ${result.status}`);
}

const userId = (result.json as any).id;
world.vars['userId'] = String(userId);
world.vars['userEmail'] = resolvedEmail;
}
);

When('the user logs in', { tags: '@ui' },
async ({ ui, world }) => {
await ui.goto('/login');
await ui.fillLabel('Email', world.vars['userEmail']);
await ui.fillLabel('Password', 'TestPassword123');
await ui.clickButton('Sign In');
}
);

Then('the user should see their dashboard', { tags: '@ui' },
async ({ ui }) => {
await ui.expectText('Dashboard');
await ui.expectUrlContains('/dashboard');
}
);

Register Custom Steps

// features/steps/steps.ts
import { test } from './fixtures';
import { registerApiSteps, registerUiSteps } from '@esimplicity/stack-tests/steps';

// Register built-in steps
registerApiSteps(test);
registerUiSteps(test);

// Import custom steps (auto-registers via createBdd)
import './custom/user.steps';
import './custom/order.steps';
import './custom/payment.steps';

export { test };

Step Patterns

Parameterized Steps

// Single parameter
Given('I am on the {string} page', async ({ ui }, pageName) => {
const routes: Record<string, string> = {
'login': '/login',
'home': '/',
'dashboard': '/dashboard',
'settings': '/settings',
};
await ui.goto(routes[pageName] || `/${pageName}`);
});

// Multiple parameters
When('I create a {string} named {string}', async ({ api, world }, type, name) => {
await api.sendJson('POST', `/admin/${type}s`, { name }, world.headers);
});

// Integer parameter
Then('I should see {int} items', async ({ ui }, count) => {
// count is a number
});

// Float parameter
Then('the total should be {float}', async ({ world }, total) => {
const actualTotal = (world.lastJson as any).total;
expect(actualTotal).toBeCloseTo(total, 2);
});

Doc String Steps

When('I create a user with details:', async ({ api, world }, docString) => {
const details = JSON.parse(docString);
const interpolatedDetails = Object.fromEntries(
Object.entries(details).map(([k, v]) => [k, interpolate(String(v), world.vars)])
);

await api.sendJson('POST', '/users', interpolatedDetails, world.headers);
});

Usage:

When I create a user with details:
"""
{
"email": "{uniqueEmail}",
"name": "Test User",
"role": "member"
}
"""

Data Table Steps

When('I create users:', async ({ api, world }, dataTable) => {
const rows = dataTable.hashes();

for (const row of rows) {
await api.sendJson('POST', '/users', {
email: interpolate(row.email, world.vars),
name: interpolate(row.name, world.vars),
role: row.role,
}, world.headers);
}
});

Usage:

When I create users:
| email | name | role |
| user1@test.com | User One | member |
| user2@test.com | User Two | admin |

Optional Parameters

// Using regex for optional parts
Given(/^I am on the homepage( as (\w+))?$/, async ({ ui, auth, world }, _, role) => {
if (role) {
if (role === 'admin') {
await auth.uiLoginAsAdmin(world);
} else {
await auth.uiLoginAsUser(world);
}
}
await ui.goto('/');
});

Usage:

Given I am on the homepage
Given I am on the homepage as admin
Given I am on the homepage as user

Tagging Steps

Project Tags

// Only available in @api scenarios
When('I call the API', { tags: '@api' }, async ({ api }) => {
// ...
});

// Only available in @ui scenarios
When('I click the submit button', { tags: '@ui' }, async ({ ui }) => {
// ...
});

// Available in multiple project types
When('I verify the data', { tags: '@api or @hybrid' }, async ({ api, world }) => {
// ...
});

No Tag (Universal)

// Available in all scenarios
Given('I set the test context', async ({ world }) => {
world.vars['testContext'] = 'active';
});

Using World State

Access Variables

When('I use the stored user', async ({ api, world }) => {
const userId = world.vars['userId'];
if (!userId) {
throw new Error('userId not set');
}

await api.sendJson('GET', `/users/${userId}`, undefined, world.headers);
});

Store Variables

Then('I remember the response ID as {string}', async ({ world }, varName) => {
const id = (world.lastJson as any).id;
world.vars[varName] = String(id);
});

Use Response Data

Then('the response should have a valid user', async ({ world }) => {
const json = world.lastJson as any;

expect(json).toBeDefined();
expect(json.id).toBeDefined();
expect(json.email).toMatch(/@/);
});

Composing Steps

Reuse Existing Adapters

import { interpolate, selectPath, registerCleanup } from '@esimplicity/stack-tests';

When('I create and verify a user', { tags: '@api' },
async ({ api, world }) => {
// Create
const email = `test-${Date.now()}@example.com`;
const createResult = await api.sendJson('POST', '/users', {
email,
password: 'Test123',
}, world.headers);

expect(createResult.status).toBe(201);
const userId = selectPath(createResult.json, 'id');

// Store for later use
world.vars['createdUserId'] = String(userId);

// Register cleanup
registerCleanup(world, { method: 'DELETE', path: `/users/${userId}` });

// Verify
const getResult = await api.sendJson('GET', `/users/${userId}`, undefined, world.headers);
expect(getResult.status).toBe(200);
expect(selectPath(getResult.json, 'email')).toBe(email);
}
);

Call Multiple Adapters

When('I create a user and verify in UI', { tags: '@hybrid' },
async ({ api, ui, world }) => {
// API: Create user
const result = await api.sendJson('POST', '/users', {
email: 'newuser@test.com',
name: 'New User',
}, world.headers);

const userId = (result.json as any).id;
world.vars['userId'] = String(userId);

// UI: Verify user appears
await ui.goto('/admin/users');
await ui.expectText('newuser@test.com');
}
);

Domain-Specific Steps

E-commerce Example

// features/steps/custom/ecommerce.steps.ts

Given('a product {string} exists with price {float}',
async ({ api, world }, name, price) => {
const result = await api.sendJson('POST', '/products', {
name,
price,
stock: 100,
}, world.headers);

world.vars['productId'] = String((result.json as any).id);
world.vars['productName'] = name;
world.vars['productPrice'] = String(price);
}
);

When('I add the product to cart', { tags: '@ui' },
async ({ ui, world }) => {
await ui.goto(`/products/${world.vars['productId']}`);
await ui.clickButton('Add to Cart');
}
);

When('I checkout with payment method {string}',
async ({ ui, world }, paymentMethod) => {
await ui.goto('/checkout');
await ui.fillLabel('Card Number', '4242424242424242');
await ui.fillLabel('Expiry', '12/25');
await ui.fillLabel('CVV', '123');
await ui.clickButton('Pay Now');
}
);

Then('the order should be confirmed', { tags: '@ui' },
async ({ ui, world }) => {
await ui.expectText('Order Confirmed');
// Extract order ID from page
}
);

Then('the order total should be {float}',
async ({ api, world }, expectedTotal) => {
const orderId = world.vars['orderId'];
const result = await api.sendJson('GET', `/orders/${orderId}`, undefined, world.headers);

const actualTotal = (result.json as any).total;
expect(actualTotal).toBeCloseTo(expectedTotal, 2);
}
);

Usage

@hybrid
Feature: Checkout

Scenario: Purchase a product
Given I am authenticated as an admin via API
And a product "Test Widget" exists with price 29.99

When I add the product to cart
And I checkout with payment method "card"

Then the order should be confirmed
And the order total should be 29.99

Testing Custom Steps

Unit Test Steps

// features/steps/custom/__tests__/user.steps.test.ts
import { describe, it, expect, vi } from 'vitest';

describe('User Steps', () => {
it('should create user with email', async () => {
const mockApi = {
sendJson: vi.fn().mockResolvedValue({
status: 201,
json: { id: '123' },
}),
};
const world = { vars: {}, headers: {} };

// Call step logic directly
await createUser(mockApi, world, 'test@example.com');

expect(mockApi.sendJson).toHaveBeenCalledWith(
'POST',
'/admin/users',
expect.objectContaining({ email: 'test@example.com' }),
{}
);
expect(world.vars['userId']).toBe('123');
});
});

Best Practices

Keep Steps Atomic

// Good - single responsibility
Given('a user exists', async () => { /* create user */ });
When('the user logs in', async () => { /* login */ });
Then('the user sees dashboard', async () => { /* verify */ });

// Avoid - too much in one step
Given('a logged in user on dashboard', async () => {
/* create user, login, navigate, verify - too much! */
});

Use Descriptive Patterns

// Good - clear intent
Given('a premium user with subscription {string}', ...);
When('the user upgrades to {string} plan', ...);
Then('the billing shows {float} per month', ...);

// Avoid - vague
Given('setup user', ...);
When('do action', ...);
Then('check result', ...);

Handle Errors Gracefully

When('I create a user', async ({ api, world }) => {
const result = await api.sendJson('POST', '/users', { ... }, world.headers);

if (result.status !== 201) {
throw new Error(
`Failed to create user: ${result.status}\n` +
`Response: ${result.text}`
);
}
});

Document Complex Steps

/**
* Creates a complete test scenario with user, team, and project.
*
* Sets variables:
* - userId: Created user ID
* - teamId: Created team ID
* - projectId: Created project ID
*
* @param teamName - Name of the team to create
*/
Given('a complete project environment for team {string}',
async ({ api, world }, teamName) => {
// ... implementation
}
);