Cucumber and Gherkin for Cypress

The package most tutorials name is deprecated, step definitions resolve by file path rather than globally, and async step definitions break the command queue. Here is the setup that works.

Generate a Cypress .feature file

Get valid Gherkin plus edge cases, ready to add to your Cypress project.

Feature generation is free. JavaScript/TypeScript step definitions are included with Pro.

Which Cypress Cucumber preprocessor should you use?

Use @badeball/cypress-cucumber-preprocessor.

Search results for "cypress cucumber" are still dominated by cypress-cucumber-preprocessor from TheBrainFamily. That package is deprecated and has been for years. @badeball is the maintained successor, and it is not a drop-in rename — the configuration keys, the step-definition resolution rules and the bundler setup all differ.

If you are following a blog post written before 2022 and the imports do not resolve, this is why. Install the scoped package, and read its own documentation rather than the article that brought you here.

How to configure Cucumber with Cypress

Cypress does not process .feature files itself. You need three pieces: the preprocessor plugin, a bundler for it to hand code to, and a specPattern that makes Cypress look for feature files at all.

import {  defineConfig}from 'cypress';import createBundler from '@bahmutov/cypress-esbuild-preprocessor';import {  addCucumberPreprocessorPlugin}from '@badeball/cypress-cucumber-preprocessor';import createEsbuildPlugin from '@badeball/cypress-cucumber-preprocessor/esbuild'; export default defineConfig({  e2e: {    // Without this, Cypress looks for **/*.cy.{js,ts} and finds no features.    specPattern: 'cypress/e2e/**/*.feature',    async setupNodeEvents(on, config) {      await addCucumberPreprocessorPlugin(on, config);      on(      'file:preprocessor',      createBundler({        plugins: [createEsbuildPlugin(config)]      })      );      // Must be returned. The preprocessor plugin mutates config, and dropping      // the return value is why "my step definitions are not found" is the      // single most reported setup problem.      return config;    },  },});

Three separate things in that file break silently if you get them wrong. specPattern defaults to **/*.cy.{js,ts}, so without changing it Cypress reports "no specs found" while staring at a directory of features. addCucumberPreprocessorPlugin must be awaited. And setupNodeEvents must return config — the plugin mutates it, and a missing return is the most common cause of "step definitions not found" on an otherwise correct setup.

Why your Cypress step definitions are not found

This is the part that catches everyone coming from Cucumber-JVM or cucumber-js. By default, @badeball does not load every step file globally. It looks for steps in a directory named after the feature file: a feature at cypress/e2e/contact-import.feature gets its steps from cypress/e2e/contact-import/.

cypress
├── e2e
│   ├── contact-import.feature
│   └── contact-import
│       └── steps.ts
└── support
    └── step_definitions
        └── shared.ts
The default: steps live in a directory named after their feature. Shared steps need explicit configuration.

That default is a deliberate choice — it keeps steps close to the scenarios that use them and makes accidental cross-feature coupling visible. It is also completely unlike every other Cucumber implementation, which is why it reads as a bug. If you want global steps, say so in package.json or .cypress-cucumber-preprocessorrc.json:

{  "cypress-cucumber-preprocessor": {    "stepDefinitions": [      "cypress/e2e/[filepath]/**/*.{js,ts}",      "cypress/e2e/[filepath].{js,ts}",      "cypress/support/step_definitions/**/*.{js,ts}"    ]  }}

Should Cypress step definitions be async?

No, and async/await in a Cypress step definition will break it in ways that surface a step later than their cause.

The reason is the most consequential difference between Cypress step definitions and every other framework's: cy.get(...) does not return a promise. It enqueues a command. The whole chain runs after your step function has already returned.

So a step definition that looks like it needs async/await does not. Cypress commands are not promises and are not supported with await: mixing native promises into the command queue makes the two run out of order, and the failure lands nowhere near the line that caused it.

A Cypress Cucumber example: .feature file and step definitions

A file-upload requirement, because uploads are where Cypress's ergonomics are genuinely better than the alternatives — selectFile handles the file input directly rather than through a driver-level workaround.

A user uploads a CSV of contacts. Files over 5MB are rejected before uploadstarts. Rows with a malformed email are skipped and reported, but the rest of thefile still imports. When the import finishes the user sees how many rows wereimported and how many were skipped. Navigating away mid-upload cancels it.

Note that "rows with a malformed email are skipped and reported, but the rest of the file still imports" becomes one scenario with a specific count, not a vague assertion that the import "worked". Partial success is the behaviour being specified, so the numbers are the test.

Feature: Importing contacts from a CSV   Scenario: A file over the size limit is rejected before upload begins    Given the user is on the contact import page    When they choose a file of 6 MB    Then they should see the error "Files must be 5MB or smaller"    And no upload should have started   Scenario: Malformed rows are skipped and the rest import    Given the user is on the contact import page    When they upload "contacts-with-two-bad-rows.csv" containing 10 rows    Then the import summary should read "8 imported, 2 skipped"    And the skipped rows should be listed with their line numbers   Scenario: Leaving the page cancels an upload in progress    Given the user has started uploading "large-contacts.csv"    When they navigate to the dashboard    Then the upload should be cancelled    And no partial import should appear in the contact list
import {  Given, When, Then}from '@badeball/cypress-cucumber-preprocessor'; Given('the user is on the contact import page', () => {  // The intercept is registered here, not in the upload step, because  // "no upload should have started" has to read the alias in a scenario where  // no upload ever happened. An alias that is only created on the happy path  // does not exist on the path you are trying to assert about.  cy.intercept('POST', '/api/contacts/import').as('import');  cy.visit('/contacts/import');}); When('they choose a file of {int} MB', (sizeMb) => {  // Built in-memory rather than fixtured: a 6MB fixture in the repo to test a  // 5MB limit is six megabytes of git history nobody will ever read.  const oversized = Cypress.Buffer.alloc(sizeMb * 1024 * 1024, 'a');  cy.get('input[type=file]').selectFile(  {    contents: oversized, fileName: 'contacts.csv', mimeType: 'text/csv'  },  {    force: true  }  );}); When('they upload {string} containing {int} rows', (fileName) => {  cy.get('input[type=file]').selectFile(`cypress/fixtures/${fileName}`, {    force: true  });  cy.wait('@import');}); Then('the import summary should read {string}', (summary) => {  // No return, no await: cy.* commands are enqueued, and the assertion below  // runs after the ones above have resolved because Cypress ordered them.  cy.findByTestId('import-summary').should('have.text', summary);}); Then('no upload should have started', () => {  // @import.all is the list of every request the intercept matched. Asserting  // it is empty is a real assertion; asserting on an alias that was never  // registered is just an error with a confusing message.  cy.get('@import.all').should('have.length', 0);});

Cypress limitations: tabs, cross-domain flows, and one browser per spec

Worth knowing before you build a suite around it, and not always stated plainly: Cypress runs inside the browser, which is the source of both its debugging experience and its constraints. Multiple browser tabs are not supported. Neither is more than one superdomain in a single test without cy.origin(). And there is one browser per spec file, so scenarios in a feature share a browser instance in a way they would not under cucumber-js.

None of that matters for most application test suites. All of it matters for OAuth flows, cross-domain checkout handoffs, and anything that opens a new tab.

Generating Cypress .feature files and step definitions

Gherkinizer writes the feature file, parses it with the official Cucumber grammar before showing it to you, and generates step definitions using the @badeball imports and the synchronous command style described above. The export uses the JavaScript project conventions:

  • importing-contacts-from-a-csv.feature in features/
  • importing-contacts-from-a-csv.steps.js in features/step_definitions/
  • a README naming npx cypress run as the command that runs it

Move both into your cypress/e2e tree to match the step-resolution rules above. Generating the feature file and its edge cases is free; step definitions in JavaScript/TypeScript are part of Pro.

Questions

Which Cypress Cucumber preprocessor should I use?
@badeball/cypress-cucumber-preprocessor. The unscoped cypress-cucumber-preprocessor that most search results still point at is deprecated, and it is not a drop-in rename — configuration keys, step resolution and bundler setup all differ.
Why does Cypress say "no specs found" when my .feature files are right there?
Because specPattern still defaults to **/*.cy.{js,ts}. Set it to 'cypress/e2e/**/*.feature' in cypress.config.ts. Cypress is not ignoring your features; it was never looking for them.
Why are my step definitions not found?
Two usual causes. Either setupNodeEvents does not return config — the preprocessor mutates it and the return is required — or you expected global step resolution. By default @badeball looks for steps in a directory named after the feature file, not across the whole project. Configure stepDefinitions to change that.
Should Cypress step definitions be async?
No. cy.* commands enqueue work rather than returning promises, so the chain runs after your step function returns. Cypress does not support await on its commands, and mixing native promises into the command queue makes the two run out of order — the failure then surfaces a step later than its cause. Use .then() on the chainable, or cy.wrap() for genuinely external promises.
Can Cypress test a flow that crosses two domains?
Only with cy.origin(), and never across multiple tabs — Cypress runs inside the browser and does not support tab switching. This matters for OAuth redirects and hosted checkout handoffs, and not much else.

Other frameworks