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.tsThat 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 listimport { 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.featureinfeatures/importing-contacts-from-a-csv.steps.jsinfeatures/step_definitions/- a README naming
npx cypress runas 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.