diff --git a/CHANGELOG.md b/CHANGELOG.md index 10016b7cf..7a024da65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Please see [CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CO ## [Unreleased] ### Added - Add support for named hooks (see [documentation](./docs/support_files/hooks.md#named-hooks)) ([#1994](https://github.com/cucumber/cucumber-js/pull/1994)) +- Add generics support for world parameters type in world-related interfaces and classes (see [documentation](./docs/support_files/world.md#typescript)) ([#1968](https://github.com/cucumber/cucumber-js/issues/1968) [#2002](https://github.com/cucumber/cucumber-js/pull/2002)) ### Changed - Rename the `cucumber-js` binary's underlying file to be `cucumber.js`, so it doesn't fall foul of Node.js module conventions and plays nicely with ESM loaders (see [documentation](./docs/esm.md#transpiling)) ([#1993](https://github.com/cucumber/cucumber-js/pull/1993)) diff --git a/docs/support_files/world.md b/docs/support_files/world.md index b536f961d..7f4ed80cb 100644 --- a/docs/support_files/world.md +++ b/docs/support_files/world.md @@ -1,10 +1,11 @@ # World -*World*, or sometimes *context*, is an isolated scope for each scenario, exposed to the steps and most hooks as `this`. It allows you to set variables in one step and recall them in a later step. All variables set this way are discarded when the scenario concludes. It is managed by a world class, either the default one or one you create. Each scenario is given an new instance of the class when the test starts, even if it is a [retry run](../retry.md). +*World*, is an isolated scope for each scenario, exposed to the steps and most hooks as `this`. It allows you to set variables in one step and recall them in a later step. All variables set this way are discarded when the scenario concludes. It is managed by a world class, either the default one or one you create. Each scenario is given an new instance of the class when the test starts, even if it is a [retry run](../retry.md). The world is not available to the hooks `BeforeAll` or `AfterAll` as each of these executes outside any particular scenario. -##### Basic Example +Here's some simple usage of the world to retain state between steps: + ```javascript const { Given, Then } = require('@cucumber/cucumber') @@ -18,7 +19,8 @@ Then("my color should not be red", function() { } }); ``` -With those step definitions in place + +With those step definitions in place: ```gherkin Scenario: Will pass @@ -41,17 +43,17 @@ Then("my color should not be blue", () => { }); ``` -## Cucumber World +## Built-in world -Cucumber provides a number of formatting helpers that are passed into the constructor of the World. The default world binds these helpers as follows: +By default, the world is an instance of Cucumber's built-in `World` class. Cucumber provides a number of formatting helpers that are passed into the constructor as an options object. The default world binds these helpers as follows: * `this.attach`: a method for adding [attachments](./attachments.md) to hooks/steps * `this.log`: a method for [logging](./attachments.md#logging) information from hooks/steps -* `this.parameters`: an object of parameters passed in via the [CLI](../cli.md#world-parameters) +* `this.parameters`: an object of parameters passed in via configuration (see below) Your custom world will also receive these arguments, but it's up to you to decide what to do with them and they can be safely ignored. -### World Parameters +### World parameters Tests often require configuration and environment information. One of the most frequent cases is web page tests that are using a browser driver; things like viewport, browser to use, application URL and so on. @@ -62,14 +64,53 @@ The `worldParameters` configuration option allows you to provide this informatio This option is repeatable, so you can use it multiple times and the objects will be merged with the later ones taking precedence. -## Custom Worlds +## Custom worlds + +You might also want to have methods on your world that hooks and steps can access to keep their own code simple. To do this, you can write your own world implementation with its own properties and methods that help with your instrumentation, and then call `setWorldConstructor` to tell Cucumber about it: + +```javascript +const { setWorldConstructor, World, When } = require('@cucumber/cucumber') + +class CustomWorld extends World { + count = 0 + + constructor(options) { + super(options) + } + + eat(count) { + this.count += count + } +} + +setWorldConstructor(CustomWorld) + +When('I eat {int} cucumbers', function(count) { + this.eat(count) +}) +``` + +In the example above we've extended the built-in `World` class, which is recommended. You can also use a plain function as your world constructor: + +```javascript +const { setWorldConstructor, When } = require('@cucumber/cucumber') + +setWorldConstructor(function(options) { + this.count = 0 + this.eat = (count) => this.count += count +}) + +When('I eat {int} cucumbers', function(count) { + this.eat(count) +}) +``` -You might also want to have methods on your World that hooks and steps can access to keep their own code simple. To do this, you can provide your own World class with its own properties and methods that help with your instrumentation, and then call `setWorldConstructor` to tell Cucumber about it. +### Real-world example Let's walk through a typical scenario, setting up world that manages a browser context. We'll use the ES6 module syntax for this example. First, let's set up our custom world. Class files should not be loaded as steps - they should be imported. So in this example we'll presume it is in a classes folder next to the steps folder. -###### CustomWorld.js ```javascript +// CustomWorld.js import { World } from '@cucumber/cucumber'; import seleniumWebdriver from "selenium-webdriver"; @@ -117,8 +158,8 @@ export default class extends World { Now we'll use a step file to setup this custom world and declare the before hook. -###### setup.js ```javascript +// setup.js import { Before, setWorldConstructor } from '@cucumber/cucumber'; import CustomWorld from "../classes/CustomWorld.js" @@ -142,6 +183,52 @@ Given("I'm viewing the admin settings", async function(){ This pattern allows for cleaner feature files. Remember that, ideally, scenarios should be between 3-5 lines and communicate **what** the user is doing clearly to the whole team without going into the details of **how** it will be done. While steps can be reused that should not come at the expense of feature clarity. +## TypeScript + +If you're using TypeScript, you can get optimum type safety and completion based on your custom world and parameters. + +### Hooks and steps + +If you have a custom world, you'll need to tell TypeScript about the type of `this` in your hook and step functions: + +```typescript +When('I eat {int} cucumbers', function(this: CustomWorld, count: number) { + this.eat(count) +}) +``` + +### World parameters + +ℹ️ Added in v8.1.0 + +If you're using world parameters (see above), Cucumber's world-related interfaces and classes support generics to easily specify their interface: + +```typescript +interface CustomParameters { + cukeLimit: number +} + +class CustomWorld extends World { + // etc +} +``` + +### Plain functions + +If you're using a plain function as your world constructor, you'll need to define an interface for your world and type that as `this` for your function: + +```typescript +interface CustomWorld { + count: number + eat: (count: number) => void +} + +setWorldConstructor(function(this: CustomWorld, options: IWorldOptions) { + this.count = 0 + this.eat = (count) => this.count += count +}) +``` + ## Summary - The *World* provides an isolated context for your tests. - It allows you to track test state while maintaining the isolation of each scenario. diff --git a/src/support_code_library_builder/world.ts b/src/support_code_library_builder/world.ts index 1d142c4ac..1406c7c38 100644 --- a/src/support_code_library_builder/world.ts +++ b/src/support_code_library_builder/world.ts @@ -1,24 +1,27 @@ import { ICreateAttachment, ICreateLog } from '../runtime/attachment_manager' -export interface IWorldOptions { +export interface IWorldOptions { attach: ICreateAttachment log: ICreateLog - parameters: any + parameters: ParametersType } -export interface IWorld { +export interface IWorld { readonly attach: ICreateAttachment readonly log: ICreateLog - readonly parameters: any + readonly parameters: ParametersType + [key: string]: any } -export default class World implements IWorld { +export default class World + implements IWorld +{ public readonly attach: ICreateAttachment public readonly log: ICreateLog - public readonly parameters: any + public readonly parameters: ParametersType - constructor({ attach, log, parameters }: IWorldOptions) { + constructor({ attach, log, parameters }: IWorldOptions) { this.attach = attach this.log = log this.parameters = parameters diff --git a/test-d/world.ts b/test-d/world.ts index 45bc75cb6..7cf652cf1 100644 --- a/test-d/world.ts +++ b/test-d/world.ts @@ -1,4 +1,4 @@ -import { Before, setWorldConstructor, When, World } from '../' +import { Before, setWorldConstructor, When, IWorld, World } from '../' import { expectError } from 'tsd' // should allow us to read parameters and add attachments @@ -44,3 +44,32 @@ Before(async function (this: CustomWorld) { When('stuff happens', async function (this: CustomWorld) { this.doThing() }) + +// should allow us to use a custom parameters type without a custom world +interface CustomParameters { + foo: string +} +Before(async function (this: IWorld) { + this.log(this.parameters.foo) +}) +expectError( + Before(async function (this: IWorld) { + this.log(this.parameters.bar) + }) +) + +// should allow us to use a custom parameters type with a custom world +class CustomWorldWithParameters extends World { + doThing(): string { + return 'foo' + } +} +setWorldConstructor(CustomWorldWithParameters) +Before(async function (this: CustomWorldWithParameters) { + this.log(this.parameters.foo) +}) +expectError( + Before(async function (this: CustomWorldWithParameters) { + this.log(this.parameters.bar) + }) +)