Practice BDD with Cucumber CI + tags + env
Lecture 3
BDD Lecture 3 . CI + env + tags + screenshots

Ship one BDD suite to many environments

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".

ConcernWithout disciplineWith discipline
Pull request feedback20+ minutes of full regression2 minutes of @smoke only
Environment switchingEdit code, push, waitTEST_ENV=staging npm test
Failure triageRead stack trace, guessOpen attached screenshot, see it
QuarantineComment out scenariosnot @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.

tta-cucumber-pw/ +- env/ | +- .env.dev # local docker compose | +- .env.staging # shared staging cluster | +- .env.prod # prod-like, read-only checks +- support/ | +- env.ts # loads + validates the env | +- world.ts # exposes world.config +- cucumber.cjs +- package.json

.env.dev

env/.env.dev
# Local docker-compose TTACart instance
BASE_URL=http://localhost:5173/playwright/ttacart/
API_URL=http://localhost:8080/api
TEST_USER=standard_user
TEST_PASSWORD=tta_secret
HEADLESS=false
RETRIES=0

.env.staging

env/.env.staging
BASE_URL=https://staging.app.thetestingacademy.com/ttacart/
API_URL=https://staging.api.thetestingacademy.com/v1
TEST_USER=qa_staging_user
TEST_PASSWORD=__from_vault__
HEADLESS=true
RETRIES=2

.env.prod

env/.env.prod
BASE_URL=https://app.thetestingacademy.com/ttacart/
API_URL=https://api.thetestingacademy.com/v1
TEST_USER=prod_smoke_user
TEST_PASSWORD=__from_vault__
HEADLESS=true
RETRIES=3
READ_ONLY=true   # block any mutating step
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',
  };
}
TEST_ENV=staging env-loader .env.staging + vault overrides validate + throw if missing "world.config is now typed and trusted"
One loader, one shape, every step definition consumes the same object.

04npm scripts - smoke, regression, dev/staging/prodpackage.json

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"
  }
}
ScriptTagsUse it for
test:smoke@smoke and not @wipPull request gate
test:regressionnot @wip and not @flakyNightly full run
test:checkout@checkout and not @wipFocused 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.

TagPurposeTypical pipeline use
@smokeFast confidence on top journeysPull request + post-deploy
@regressionBroad coverage for nightly / releaseNightly full pack
@criticalMust never fail on prod-like envsRelease gate
@checkout @authDomain ownershipSlack alert routing
@flakyTemporary quarantineExcluded from required checks
@wipWork in progress - never runFiltered out everywhere
@prod-safeNo mutating side effectsProd-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"
ExpressionWhat it picks
@smokeAnything tagged @smoke
not @flakyEverything except @flaky
@smoke and not @flakySmoke minus quarantined
@checkout or @authEither domain
(@smoke or @critical) and not @wipBoolean 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.

scripts/build-report.js
const reporter = require('cucumber-html-reporter');

reporter.generate({
  theme: 'bootstrap',
  jsonFile: 'reports/cucumber.json',
  output:   'reports/cucumber.html',
  reportSuiteAsScenarios: true,
  launchReport: false,
  metadata: {
    'App':         'TTACart',
    'Test env':    process.env.TEST_ENV || 'dev',
    'Browser':     'Chromium',
    'Platform':    process.platform,
    'Executed':    new Date().toISOString(),
  },
});

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.

cucumber-js reports/ cucumber.json cucumber.html screenshots/ *.png "one bucket, one URL, the whole team can read it"
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.

.github/workflows/cucumber.yml
name: cucumber-multi-env

on:
  pull_request:
  schedule:
    - cron: '0 2 * * *'   # nightly 02:00 UTC
  workflow_dispatch:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        env:   [dev, staging]
        shard: [1, 2, 3]
    env:
      TEST_ENV: ${{ matrix.env }}
      TEST_USER: ${{ secrets.TTA_USER }}
      TEST_PASSWORD: ${{ secrets.TTA_PASSWORD }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium

      - name: Run Cucumber (env=${{ matrix.env }} shard=${{ matrix.shard }})
        run: |
          npx cucumber-js \
            --tags "@smoke and not @flaky" \
            --shard ${{ matrix.shard }}/3 \
            --format json:reports/cucumber.json

      - name: Build HTML report
        if: always()
        run: npm run report:html

      - name: Upload artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: report-${{ matrix.env }}-shard${{ matrix.shard }}
          path: reports/

How the matrix expands

Jobenvshardtags
1dev1/3@smoke and not @flaky
2dev2/3@smoke and not @flaky
3dev3/3@smoke and not @flaky
4staging1/3@smoke and not @flaky
5staging2/3@smoke and not @flaky
6staging3/3@smoke and not @flaky

11The full pipeline at a glanceend-to-end

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.
env x tag x shard - the three knobs env dev / staging / prod picks .env file + secrets tag @smoke / @regression slices the scenarios shard 1/3, 2/3, 3/3 splits across runners
Three independent dimensions. Multiply for parallel runs.

12Common pitfallsdebug checklist

SymptomLikely causeFix
Tests pass locally, fail in CIHEADLESS / viewport mismatchMatch HEADLESS in .env with CI
Secrets leaked in logsEchoing process.envNever log secrets - mask in CI
Tag explosionEvery scenario carries five tagsCap at three - priority + domain + safety
Wrong env hitTEST_ENV not exportedUse cross-env in npm scripts
Screenshots never attachWrong scenario status checkCompare with Status.FAILED, not string
Stale HTML reportReport built before JSON was writtenUse && not & in scripts
One env stuck on prod dataHard-coded URL inside a stepAlways 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.