The difference between a demo Cucumber project and a useful team framework is operational
discipline: an env-aware dotenv loader, npm scripts that slice by tag, an
After hook that attaches a failure screenshot, an HTML report, and a
GitHub Actions matrix that fans out across env x shard. We will wire all of that
against the TTACart demo.
01Why operational discipline mattersmental model
A green test on a laptop proves the spec was written. A green test in CI against the
right environment, sliced by the right tag, with a screenshot when it fails, proves
the suite is useful. This lecture is everything between "it works for me"
and "the team trusts it".
Concern
Without discipline
With discipline
Pull request feedback
20+ minutes of full regression
2 minutes of @smoke only
Environment switching
Edit code, push, wait
TEST_ENV=staging npm test
Failure triage
Read stack trace, guess
Open attached screenshot, see it
Quarantine
Comment out scenarios
not @flaky tag filter
Reports
"It failed somewhere"
HTML report with steps + media
flowchart LR
PR[Pull request] -->|tag: @smoke| FAST[2 min smoke]
N[Nightly] -->|tag: @regression| FULL[15 min full pack]
REL[Release gate] -->|tag: @prod-safe| GUARD[prod-like verification]
FAST & FULL & GUARD --> REP[HTML report]
REP --> SLK[Slack notify]
One spec base, many pipeline shapes. Tags do the slicing.
02dotenv files - .env.dev / staging / prodconfig
Environment data lives in one obvious folder. Step definitions never know whether they
are talking to dev, staging, or prod. They just
read world.config.baseUrl.
Never commit secrets. Real passwords come from the CI vault. The
repo .env.* files carry placeholders only - __from_vault__
is a sentinel that the loader rejects unless overridden.
03env-loader patternsupport/env.ts
One small helper reads the right .env file based on the
TEST_ENV shell variable, validates the values, and exposes a typed
Config object. Everything downstream consumes config.
support/env.ts
import * as dotenv from 'dotenv';
import * as path from 'path';
export type EnvName = 'dev' | 'staging' | 'prod';
export interface Config {
env: EnvName;
baseUrl: string;
apiUrl: string;
user: string;
password: string;
headless: boolean;
retries: number;
readOnly: boolean;
}
function pick(name: string, required = true): string {
const v = process.env[name];
if (required && (!v || v === '__from_vault__')) {
throw new Error(`Missing env: ${name}. Set it in the vault or shell.`);
}
return v ?? '';
}
export function loadConfig(): Config {
const env = (process.env.TEST_ENV || 'dev') as EnvName;
dotenv.config({ path: path.join(process.cwd(), 'env', `.env.${env}`) });
return {
env,
baseUrl: pick('BASE_URL'),
apiUrl: pick('API_URL'),
user: pick('TEST_USER'),
password: pick('TEST_PASSWORD'),
headless: pick('HEADLESS', false) !== 'false',
retries: Number(pick('RETRIES', false) || '0'),
readOnly: pick('READ_ONLY', false) === 'true',
};
}
One loader, one shape, every step definition consumes the same object.
The scripts in package.json are the contract with developers and CI.
Naming matters. test:smoke means smoke; test:regression
means everything; test:dev means against the dev environment.
package.json (scripts only)
{
"scripts": {
"test:smoke": "cucumber-js --tags '@smoke and not @wip'",
"test:regression": "cucumber-js --tags 'not @wip and not @flaky'",
"test:checkout": "cucumber-js --tags '@checkout and not @wip'",
"test:dev": "cross-env TEST_ENV=dev npm run test:smoke",
"test:staging": "cross-env TEST_ENV=staging npm run test:regression",
"test:prod": "cross-env TEST_ENV=prod npm run test:smoke",
"report:html": "node scripts/build-report.js",
"ci:smoke": "npm run test:smoke -- --format json:reports/cucumber.json && npm run report:html"
}
}
Script
Tags
Use it for
test:smoke
@smoke and not @wip
Pull request gate
test:regression
not @wip and not @flaky
Nightly full run
test:checkout
@checkout and not @wip
Focused troubleshooting
test:dev
(inherits smoke)
Local Docker / Vite build
test:staging
(inherits regression)
Pre-release gate
test:prod
(inherits smoke + read-only)
Post-deploy verification
05A practical tag model@smoke @regression
Tags describe intent, not implementation details. @smoke and
@critical age well. @uses-button-3 becomes noise fast.
Tag
Purpose
Typical pipeline use
@smoke
Fast confidence on top journeys
Pull request + post-deploy
@regression
Broad coverage for nightly / release
Nightly full pack
@critical
Must never fail on prod-like envs
Release gate
@checkout@auth
Domain ownership
Slack alert routing
@flaky
Temporary quarantine
Excluded from required checks
@wip
Work in progress - never run
Filtered out everywhere
@prod-safe
No mutating side effects
Prod-like verification
Rule. A scenario should carry at most three tags: one priority
(@smoke / @regression), one domain (@checkout
/ @auth), one safety flag (@prod-safe / @flaky).
Anything more is noise.
06Tag expressions - and / or / notcucumber-js --tags
Cucumber-JS supports a small boolean grammar over tags. Combine three operators -
and, or, not - to slice the suite.
Terminal
# Smoke without the flaky ones
npx cucumber-js --tags "@smoke and not @flaky"
# Full regression, exclude WIP
npx cucumber-js --tags "not @wip and not @flaky"
# Checkout OR cart only
npx cucumber-js --tags "@checkout or @cart"
# Prod-safe critical journeys
npx cucumber-js --tags "@critical and @prod-safe"
# Parens for precedence
npx cucumber-js --tags "(@smoke or @critical) and not @flaky"
Expression
What it picks
@smoke
Anything tagged @smoke
not @flaky
Everything except @flaky
@smoke and not @flaky
Smoke minus quarantined
@checkout or @auth
Either domain
(@smoke or @critical) and not @wip
Boolean grouping
07Failure screenshot in the After hooksupport/hooks.ts
The single most useful CI improvement is attaching a screenshot when a scenario fails.
The After hook runs whether the scenario passes or fails, so it can branch on
scenario.result?.status === Status.FAILED.
support/hooks.ts
import { Before, After, BeforeAll, AfterAll, Status } from '@cucumber/cucumber';
import { chromium, Browser } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { loadConfig } from './env';
import { CustomWorld } from './world';
let sharedBrowser: Browser;
const config = loadConfig();
BeforeAll(async () => {
sharedBrowser = await chromium.launch({ headless: config.headless });
});
Before(async function (this: CustomWorld) {
this.config = config;
this.context = await sharedBrowser.newContext();
this.page = await this.context.newPage();
});
After(async function (this: CustomWorld, scenario) {
if (scenario.result?.status === Status.FAILED) {
const buffer = await this.page.screenshot({ fullPage: true });
this.attach(buffer, 'image/png'); // attach to report
const dir = 'reports/screenshots';
fs.mkdirSync(dir, { recursive: true });
const safe = scenario.pickle.name.replace(/[^a-z0-9]+/gi, '_');
fs.writeFileSync(path.join(dir, `${safe}.png`), buffer); // also save to disk
}
await this.context.close();
});
AfterAll(async () => { await sharedBrowser.close(); });
sequenceDiagram
participant S as Scenario
participant H as After hook
participant W as Cucumber world
participant R as HTML report
S-->>H: result.status
alt FAILED
H->>W: page.screenshot()
H->>W: this.attach(buffer, image/png)
H->>R: writeFileSync(reports/screenshots/x.png)
else PASSED
H->>H: nothing
end
H->>W: context.close()
Attach on failure, save to disk too. Two places one image.
08Attach config + screenshot to the Cucumber worldsupport/world.ts
CustomWorld carries the per-scenario state - page,
context, the config object, and any POM instances.
Step definitions receive this as the world and read from it.
support/world.ts
import { setWorldConstructor, World, IWorldOptions } from '@cucumber/cucumber';
import { BrowserContext, Page } from '@playwright/test';
import { Config } from './env';
export class CustomWorld extends World {
context!: BrowserContext;
page!: Page;
config!: Config;
constructor(options: IWorldOptions) {
super(options);
}
// Helper - any step can call this and get a screenshot attached even mid-flow.
async snap(label: string) {
const buf = await this.page.screenshot({ fullPage: true });
this.attach(buf, 'image/png');
this.attach(label, 'text/plain');
}
}
setWorldConstructor(CustomWorld);
Why a helper? Sometimes you want a "before the click" and "after the
click" screenshot to debug timing. The snap helper makes that one line.
09HTML report generatorcucumber-html-reporter
Cucumber-JS already writes JSON. The cucumber-html-reporter package turns
that JSON into a styled HTML page that triage can open from any artifact bucket.
In CI we run npm run ci:smoke - the script runs the suite with the JSON
formatter, then invokes the report generator. The screenshots attached in the hook
show up inline next to the failed step.
JSON + screenshots merge into a single HTML report that lives in artifacts.
10GitHub Actions matrix - env x shard.github/workflows
The matrix is two-dimensional: one axis is the environment, the other is the shard.
Two envs x three shards = six parallel jobs. Each job runs a slice of the suite,
uploads its report, and the workflow ends green only when all six are green.
flowchart TB
PR[Pull request] --> SMOKE[smoke job - dev]
SMOKE --> G{green?}
G -->|yes| MERGE[merge to main]
G -->|no| BLOCK[block]
MERGE --> DEP[deploy to staging]
DEP --> REG[regression - staging]
REG --> H{green?}
H -->|yes| REL[release approval]
H -->|no| ROLLB[rollback]
REL --> PROD[deploy to prod]
PROD --> PSMOKE[smoke - prod-safe]
Three Cucumber jobs across three envs. Same spec base, different tags.Three independent dimensions. Multiply for parallel runs.
12Common pitfallsdebug checklist
Symptom
Likely cause
Fix
Tests pass locally, fail in CI
HEADLESS / viewport mismatch
Match HEADLESS in .env with CI
Secrets leaked in logs
Echoing process.env
Never log secrets - mask in CI
Tag explosion
Every scenario carries five tags
Cap at three - priority + domain + safety
Wrong env hit
TEST_ENV not exported
Use cross-env in npm scripts
Screenshots never attach
Wrong scenario status check
Compare with Status.FAILED, not string
Stale HTML report
Report built before JSON was written
Use && not & in scripts
One env stuck on prod data
Hard-coded URL inside a step
Always read from this.config.baseUrl
Read-only on prod. Whenever the loader sees READ_ONLY=true
the world refuses to run any mutating step. Build a tiny guard in the
Before hook - if the tag includes @mutating and
config.readOnly is true, skip the scenario.
DDrills - ten CI exercises
All drills assume the TTACart spec base from Lecture 1 (Setup). Start each drill on a
fresh branch so the diff is easy to review.
1Add three env files
Create env/.env.dev, .env.staging, .env.prod.
Keep the same keys in every file. Commit the dev one with real local values;
staging / prod use __from_vault__.
Hint
Use a comment header in each file so future you remembers which env it is.
2Build env-loader
Write support/env.ts that picks the right file based on
TEST_ENV and throws on missing values. Add a unit-style assertion
in the World constructor to confirm.
Hint
Wrap dotenv.config in a function so you can re-read between scenarios if needed.
3npm scripts
Add test:smoke, test:regression, test:dev,
test:staging. Confirm cross-env works on Windows by
asking a colleague to run it.
Hint
Use cross-env dev-dependency - native shell VAR=x cmd breaks on cmd.exe.
4Tag the login scenarios
Open features/login.feature from Lecture 1. Tag the standard user
scenario with @smoke @auth and the locked-out scenario with
@regression @auth.
Hint
Tags go on the line directly above Scenario:, no spaces.
5Tag expressions
Run npx cucumber-js --tags "@auth and not @flaky". Force one auth
scenario to be @flaky and confirm it is skipped on the next run.
Hint
Parens help readability: (@smoke or @critical) and not @flaky.
6Screenshot on failure
Implement the After hook that attaches a screenshot and saves to
reports/screenshots. Force a failure by asserting the wrong page
title and inspect the PNG.
Hint
Use page.screenshot({ fullPage: true }) for context.
7Attach to Cucumber world
Add a snap(label) helper on CustomWorld. Call it from
one step to attach a mid-flow screenshot. Open the report to see both attachments
on the same scenario.
Hint
Two attach calls: PNG buffer plus a text label so the report shows context.
8HTML report
Install cucumber-html-reporter, add the script in
scripts/build-report.js, and run npm run ci:smoke.
Open the generated HTML.
Hint
Use theme: 'bootstrap' - looks the most neutral across teams.
9GitHub Actions matrix
Add .github/workflows/cucumber.yml with the matrix from section 10.
Push the branch and watch six parallel jobs in the Actions tab.
Hint
Set fail-fast: false so one shard's failure does not cancel the rest.
10Read-only guard
In the Before hook, skip any scenario tagged @mutating
if config.readOnly is true. Run the suite with
TEST_ENV=prod and confirm only safe scenarios execute.
Hint
Use scenario.pickle.tags to read tags inside the hook.
SSolutions - CI / tags / env in three languages
The same setup expressed for TypeScript (Cucumber-JS), Java (cucumber-jvm), and
Python (Behave). Pick the tab that matches your stack.
support/env.ts
import * as dotenv from 'dotenv';
import * as path from 'path';
export interface Config {
env: string; baseUrl: string; apiUrl: string;
user: string; password: string;
headless: boolean; readOnly: boolean;
}
export function loadConfig(): Config {
const env = process.env.TEST_ENV || 'dev';
dotenv.config({ path: path.join(process.cwd(), 'env', `.env.${env}`) });
const need = (k: string) => {
const v = process.env[k];
if (!v || v === '__from_vault__') throw new Error(`missing ${k}`);
return v;
};
return {
env,
baseUrl: need('BASE_URL'),
apiUrl: need('API_URL'),
user: need('TEST_USER'),
password: need('TEST_PASSWORD'),
headless: (process.env.HEADLESS || 'true') !== 'false',
readOnly: (process.env.READ_ONLY || 'false') === 'true',
};
}
{
"scripts": {
"test:smoke": "cucumber-js --tags '@smoke and not @flaky and not @wip'",
"test:regression": "cucumber-js --tags 'not @flaky and not @wip'",
"test:dev": "cross-env TEST_ENV=dev npm run test:smoke",
"test:staging": "cross-env TEST_ENV=staging npm run test:regression",
"test:prod": "cross-env TEST_ENV=prod npm run test:smoke",
"report:html": "node scripts/build-report.js",
"ci:smoke": "npm run test:smoke -- --format json:reports/cucumber.json && npm run report:html"
}
}
package hooks;
import io.cucumber.java.*;
import com.microsoft.playwright.*;
import java.io.IOException;
import java.nio.file.*;
public class Hooks {
private final World w;
public Hooks(World w) { this.w = w; }
@Before
public void before(Scenario sc) {
String env = System.getenv().getOrDefault("TEST_ENV", "dev");
Config cfg = Config.load(env);
if (cfg.readOnly && sc.getSourceTagNames().contains("@mutating")) {
sc.log("Skipped - readOnly env + mutating tag");
throw new io.cucumber.java.PendingException();
}
w.config = cfg;
w.playwright = Playwright.create();
w.browser = w.playwright.chromium()
.launch(new BrowserType.LaunchOptions().setHeadless(cfg.headless));
w.context = w.browser.newContext();
w.page = w.context.newPage();
}
@After
public void after(Scenario sc) throws IOException {
if (sc.isFailed() && w.page != null) {
byte[] png = w.page.screenshot();
sc.attach(png, "image/png", "fail-shot");
Path dir = Paths.get("reports/screenshots");
Files.createDirectories(dir);
Files.write(dir.resolve(sc.getName().replaceAll("[^a-zA-Z0-9]+", "_") + ".png"), png);
}
if (w.context != null) w.context.close();
if (w.browser != null) w.browser.close();
if (w.playwright != null) w.playwright.close();
}
}
src/test/java/hooks/Config.java
package hooks;
import java.io.IOException;
import java.nio.file.*;
import java.util.Properties;
public class Config {
public String env, baseUrl, apiUrl, user, password;
public boolean headless, readOnly;
public static Config load(String envName) {
Properties p = new Properties();
try (var in = Files.newInputStream(Paths.get("env", ".env." + envName))) {
p.load(in);
} catch (IOException e) { throw new RuntimeException(e); }
Config c = new Config();
c.env = envName;
c.baseUrl = need(p, "BASE_URL");
c.apiUrl = need(p, "API_URL");
c.user = need(p, "TEST_USER");
c.password = need(p, "TEST_PASSWORD");
c.headless = !"false".equals(p.getProperty("HEADLESS", "true"));
c.readOnly = "true".equals(p.getProperty("READ_ONLY", "false"));
return c;
}
private static String need(Properties p, String k) {
String v = System.getenv(k) != null ? System.getenv(k) : p.getProperty(k);
if (v == null || v.isBlank() || v.equals("__from_vault__"))
throw new IllegalStateException("missing env: " + k);
return v;
}
}
pom.xml (cucumber + surefire excerpt)
<profile>
<id>smoke</id>
<properties>
<cucumber.filter.tags>@smoke and not @flaky</cucumber.filter.tags>
</properties>
</profile>
<profile>
<id>regression</id>
<properties>
<cucumber.filter.tags>not @flaky and not @wip</cucumber.filter.tags>
</properties>
</profile>
<!-- mvn -Psmoke -DTEST_ENV=staging test -->
features/environment.py
import os
from pathlib import Path
from dotenv import load_dotenv
from playwright.sync_api import sync_playwright
def _load_env():
name = os.environ.get('TEST_ENV', 'dev')
load_dotenv(Path('env') / f'.env.{name}')
return {
'env': name,
'baseUrl': os.environ['BASE_URL'],
'apiUrl': os.environ['API_URL'],
'user': os.environ['TEST_USER'],
'password': os.environ['TEST_PASSWORD'],
'headless': os.environ.get('HEADLESS', 'true') != 'false',
'readOnly': os.environ.get('READ_ONLY', 'false') == 'true',
}
def before_all(context):
context.cfg = _load_env()
context.pw = sync_playwright().start()
context.browser = context.pw.chromium.launch(headless=context.cfg['headless'])
def before_scenario(context, scenario):
if context.cfg['readOnly'] and 'mutating' in scenario.effective_tags:
scenario.skip(reason='readOnly env')
return
context.ctx = context.browser.new_context()
context.page = context.ctx.new_page()
def after_scenario(context, scenario):
if scenario.status == 'failed':
Path('reports/screenshots').mkdir(parents=True, exist_ok=True)
safe = ''.join(c if c.isalnum() else '_' for c in scenario.name)
png_path = f'reports/screenshots/{safe}.png'
context.page.screenshot(path=png_path, full_page=True)
scenario.attach = png_path
context.ctx.close()
def after_all(context):
context.browser.close()
context.pw.stop()
behave.ini
[behave]
default_format = pretty
junit = true
junit_directory = reports
show_skipped = false
[behave.userdata]
smoke_tags = @smoke and not @flaky
full_tags = not @flaky and not @wip
Makefile (helper)
SHELL := /bin/bash
smoke-dev:
TEST_ENV=dev behave --tags='@smoke and not @flaky'
smoke-staging:
TEST_ENV=staging behave --tags='@smoke and not @flaky'
regression-staging:
TEST_ENV=staging behave --tags='not @flaky and not @wip'
report:
python -m behave_html_pretty_formatter reports/