Cucumber-js: Native JS module support in step definitions and support code

Created on 3 Apr 2020  ·  13Comments  ·  Source: cucumber/cucumber-js

Problem

When I attempt to run Cucumber.js with a step definition defined in a native ECMAScript module (as documented here), the attempt fails with a warning that I am using CommonJS's require() on an ES module.

Cucumber.js version: 6.0.5
Node version: 13.8.0

Steps to reproduce

  1. Setup a basic NPM package directory using npm init and npm i cucumber

    • Set "type": "module" in the package.json file to ensure that JS files will be treated as native modules

  2. Create a basic feature file, features/mjs.feature:

    Feature: Native JS Modules
    
        Scenario: Load a native JS module step definition
            Given I have 42 cucumbers in my belly
    
  3. Create a basic step definition file, features/step_definitions.js:

    import { Given } from "cucumber";
    
    Given("I have {int} cucumbers in my belly", function (cucumberCount) {
        console.log("Step parsed.");
    });
    
  4. Attempt to run Cucumber:

    $ ./node_modules/.bin/cucumber-js
    

Expected result

The step definition module should be loaded and used to parse the step.

Actual result

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /some/path/cucumbertest/features/step_definitions.js
require() of ES modules is not supported.
require() of /some/path/cucumbertest/features/step_definitions.js from /some/path/cucumbertest/node_modules/cucumber/lib/cli/index.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename step_definitions.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /some/path/cucumbertest/package.json.

    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1167:13)
    at Module.load (internal/modules/cjs/loader.js:1000:32)
    at Function.Module._load (internal/modules/cjs/loader.js:899:14)
    at Module.require (internal/modules/cjs/loader.js:1040:19)
    at require (internal/modules/cjs/helpers.js:72:18)
    at /some/path/cucumbertest/node_modules/cucumber/lib/cli/index.js:119:42
    at Array.forEach (<anonymous>)
    at Cli.getSupportCodeLibrary (/some/path/cucumbertest/node_modules/cucumber/lib/cli/index.js:119:22)
    at Cli.run (/some/path/cucumbertest/node_modules/cucumber/lib/cli/index.js:141:37)
    at async Object.run [as default] (/some/path/cucumbertest/node_modules/cucumber/lib/cli/run.js:30:14)

Closing

For what it's worth, I really appreciate the work that the Cucumber.js team has put in on this project, it has been a major asset to me and my company. My team has invested some time in building some shared application components in native JS modules, and until recently I had been assuming (based on the import syntax in the most current documentation) that this stuff would work with Cucumber when we got around to integrating with our test framework. That appears not to be the case, though. Since the shared component is already written and integrated into other parts of our application, I'm reluctant use only step definitions and support code written in CommonJS because of interoperability challenges.

Other things I attempted to get this to work included...

  • Giving the native module files .mjs extensions, per Node.js convention (this caused them to be overlooked by Cucumber's auto-loading logic, and using --require to explicitly load the step definition module threw Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
  • Wrapping the native modules in CJS modules which use the dynamic import() function; the problem was that this approach is asynchronous, and even if the import promise is exported by the CJS module, Cucumber doesn't appear to wait for that promise to resolve before proceeding, because the step was marked as undefined (suggesting that the step definition in the native module was not yet registered).

If it's within my ability, I'd be happy to submit a PR to help resolve this issue, but I'm unfamiliar with the internal workings of Cucumber.js and would appreciate if someone could point me in the right direction.

accepted enhancement

Most helpful comment

I use the .js extension and "type":"module" since I expect this will become the norm in the long term.

All 13 comments

I would like to get cucumber-js working with ESM. We had some other issues opened about it already.

We could make a CLI option (or use some other way to look for when to user it) that makes cucumber-js use import instead of require and wait on the returned promise.

Given the example you added, we could add a test case specifically for this and work on making it pass. Experimenting with switching require to import and waiting on the promise I then ran into another error:

⋊> ~/p/test ./node_modules/.bin/cucumber-js                                21:05:22
(node:7422) ExperimentalWarning: The ESM module loader is experimental.
file:///test/features/steps.js:1
import { Given } from "cucumber";
         ^^^^^
SyntaxError: The requested module 'cucumber' does not provide an export named 'Given'
    at ModuleJob._instantiate (internal/modules/esm/module_job.js:92:21)
    at async ModuleJob.run (internal/modules/esm/module_job.js:107:20)
    at async Loader.import (internal/modules/esm/loader.js:176:24)
    at async Promise.all (index 0)
    at async Cli.getSupportCodeLibrary (/test/node_modules/cucumber/lib/cli/index.js:119:5)
    at async Cli.run (/test/node_modules/cucumber/lib/cli/index.js:141:32)
    at async Object.run [as default] (/test/node_modules/cucumber/lib/cli/run.js:30:14)

I took the example (slightly modified) from the step definition documentation, but you're right, it won't work as written. Because Cucumber.js is a CommonJS module, it only provides a default export. In the short term, I would probably just import the module default and then access the Given/When/Then methods from that:

import cucumber from "cucumber";

cucumber.Given(...);

Longer term, it would be nice if Cucumber had separate entry points for import and require, as shown here.

Thanks for looking at this. For what it's worth, a CLI option to tell Cucumber to import instead of requiring would suit me fine. It would also be great if Cucumber would look for support files with .cjs and .mjs extensions, and automatically treat those as CommonJS and native modules respectively.

EDIT: just want to reiterate that I'm happy to help with any of this if I can; just point me in the right direction.

I would probably just import the module default and then access the Given/When/Then methods from that:
import cucumber from "cucumber";
cucumber.Given(...);

This doesn't work. I get the same message.
I have "type": "module" in package.json and the rest of my project is using modules so changing that is not an option.

If I use either of these:

import cucumber from '@cucumber/cucumber';
// OR
import { When } from '@cucumber/cucumber';

I get the same error message as the original post.
If I change to

const cucumber = require('@cucumber/cucumber');

Then I get this message:

Instead rename /some/path/cucumbertest/features/step_definitions.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from project/package.json.

If I rename step_definitions.js to _either_ step_definitions.cjs or step_definitions.mjs, it just gets ignored and I get generic messages from cucumber saying to create stubs for my steps:

UUUUUU

Failures:

1) Scenario: Empty Payload # spec\cucumber\features\users\create\main.feature:4
   ? When the client creates a POST request to /users
       Undefined. Implement with the following snippet:

         When('the client creates a POST request to \/users', function () {
           // Write code here that turns the phrase above into concrete actions
           return 'pending';
         });

... etc

I get the exact same messages if I delete the file completely, so it's clearly being ignored.

Solution (sort of)

In the end, the only way I've been able to get cucumber to run in a project with "type": "module" is to create a separate specpackage.json without "type": "module" and install cucumber into spec/node_modules.

So far it seems to be working. Let's see if it's still working once I've created some actual tests.

To the maintainers: this is my first time using cucumber, and I like it so far. However, this has really put a dent in my progress. Instead of spending the afternoon learning cucumber I spent it debugging imports. I know how annoying it is trying to create a library that support both ES6 imports and CJS so you have my sympathy.

A possible solution:
It's possible to publish both CJS and ES6 builds at once if you generate a second dist file:

dist/cucumber.js
dist/cucumber.module.js

Then add this line to package.json:

"module": "dist/cucumber.module.js"

This should enable node to resolve ES6 imports.

there has to be a better way to do this. I too am having that issue, and because imports cant be used with commonJS I would have to use
https://nodejs.org/api/esm.html#esm_import_expressions

everywhere...

is there a way that Cucumber can have a flag to help enable us to use ES?

Quick one for folks interested in this: when you use ESM in a project with cucumber, would you:

  • Use the .js extension and "type":"module"
  • Use the .mjs extension
  • Something else?

Thanks!

I tend to follow the convention of using .mjs and .cjs extensions for all my JavaScript files to avoid ambiguity, and it would be my preference to do so in this case as well. However, it would be no major inconvenience to set “type”: “module” at the project level and leave these as .js files if that’s the better solution for more users.

I use the .js extension and "type":"module" since I expect this will become the norm in the long term.

Thanks for the feedback @adamlacoste @looeee

@davidjgoss does #1589 being merged is enough for that issue to be closed? Or do we need something else?

Thanks for the nudge @aurelien-reeves!

Version 7.2.0 of @cucumber/cucumber is now on npm, including experimental support for ESM as described in the docs here:
https://github.com/cucumber/cucumber-js/blob/master/docs/cli.md#es-modules-experimental-nodejs-12

If those interested could give it a try and report what you find that would be great. I put a super minimal example project up here:
https://github.com/davidjgoss/cucumber-esm-example

Happy to keep this issue open for a bit just to catch the early feedback in one place.

So this change caused an issue with third-party frameworks and formatters that require certain stuff directly (as opposed to from the entry point). I've released 7.2.1 which is basically a revert to 7.1.0 to unblock those folks, and I'll dig into the cause and see if we can avoid it while still supporting ESM. In the meantime, if that doesn't affect you, 7.2.0 is still there to experiment with.

i've been attempting to give v7.2.0 a shot along with the --esm flag. i use testdouble in the tests for the package that i am attempting to convert to ESM.

in order to use td.replaceEsm, a loader must be used like --loader=testdouble. when i try to provide the loader directly to the cucumber cli, i get the following error:

> cucumber-js test/integration --esm --loader=testdouble

error: unknown option '--loader=testdouble'

since that option wasn't available, i then attempted with NODE_OPTIONS:

> NODE_OPTIONS='--loader=testdouble' cucumber-js test/integration --esm

(node:62231) ExperimentalWarning: --experimental-loader is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
internal/process/esm_loader.js:74
    internalBinding('errors').triggerUncaughtException(
                              ^

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension "" for /path/to/project/node_modules/@cucumber/cucumber/bin/cucumber-js
    at defaultGetFormat (internal/modules/esm/get_format.js:71:15)
    at getFormat (file:///path/to/project/node_modules/quibble/lib/quibble.mjs:65:12)
    at Loader.getFormat (internal/modules/esm/loader.js:104:42)
    at Loader.getModuleJob (internal/modules/esm/loader.js:242:31)
    at async Loader.import (internal/modules/esm/loader.js:176:17)
    at async Object.loadESM (internal/process/esm_loader.js:68:5) {
  code: 'ERR_UNKNOWN_FILE_EXTENSION'
}

for reference, this was against node v14.17.0

it would be nice if this worked with the NODE_OPTIONS approach, but ideally the --loader option would be supported directly by the cli like some test frameworks have started to support. please consider these approaches with your next version that approaches ESM support :)

is there an approach to defining a loader that you would expect to work with v7.2.0?

Was this page helpful?
0 / 5 - 0 ratings