Skip to main content

Enhancing Steps With Cucumber Expressions

In the last section we looked at setting up a builder for our request DTOs. Now we'll look at using cucumber expressions to take instantiation logic away from our step definitions.

Cucumber Expressions

Cucumber expressions are a way of defining a pattern that can be used to match a step definition to a step. They are similar to regular expressions, but are more readable and easier to use.

For example, the following step:

Feature: Cucumber Expressions

Scenario: Using Cucumber Expressions
Given I have a product named 'Foo Phone'

We can extract the name using the string expression:

import { Given } from "@autometa/runner";

Given("I have a product named {string}", (name, { world }) => {
world.productBuilder = new ProductBuilder().name(name);
});

However we can create a new Cucumber Expression which will create the builder for us, and we can just assign it to the world.

// src/product/product.params.ts
import { defineExpression } from "@autometa/runner";

defineExpression({
name: "builder:product"
regex: [/'([^']*)'/, /"([^"]*)"/],
transform: (name: string) => new ProductBuilder().name(name)
});

This will match any substring that is surrounded by single or double quotes, and returns a ProductBuilder instance with the name set.

To ensure this expression is loaded, we'll add a glob to our config matching .params.ts files.

// autometa.config.ts
import { defineConfig } from "@autometa/runner";

export default defineConfig({
runner: "jest",
roots: {
features: ["integration/features"],
steps: ["integration/steps"],
app: ["src/app.ts"],
// vvvvvvvvvv
parameterTypes: ["src/**/*.params.ts"]
},
shim: {
errorCauses: true
}
});

We also need to update our Types interface to recognize the builder expression.

import { App as MyApp, World as MyWorld } from "../src/app";
import { ProductBuilder } from "../src/product/product.builder";

declare module "@autometa/runner" {
interface App extends MyApp {}
interface World extends MyWorld {}
interface Types {
"builder:product": ProductBuilder;
}
}

We can now easily generate new product builders from steps using expressions.

import { Given } from "@autometa/runner";

Given("I have a product named {builder:product}", (builder, { world }) => {
world.productBuilder = builder;
});

We can also create an expression to extract the property keys from the Product type:

// src/product/product.params.ts
import { defineExpression } from "@autometa/runner";

defineExpression({
name: "builder:product"
regex: [/'([^']*)'/, /"([^"]*)"/, /[^\s]+/],
transform: (name: string) => new ProductBuilder().name(name)
}, {
name: "product:property",
transform: (value) => value as keyof Product
});

This allows us to match either a whole word without quotation marks, or a quoted string which represents the property name as multiple words.

So where name is the property, both of these will match:

* `I have a product with name 'Foo Phone'`
* `I have a product with 'name' 'Foo Phone'`

Some properties are multiple words with camelCase, like discountPercentage. It would be nice if we could avoid the "code speak" and use a more natural language like discount percentage. We can accomplish that with phrases, which we'll discuss in the next section.

Before then, lets add a validation to our expression so it can fast-fail the test when given an invalid input from gherkin. You may not want to do this step if you want to be able to test for input with unknown properties.

Since interfaces/types don't exist at run time, and a DTO is empty until it is built, we cannot compare our expression value to those. The builder, however, has a method of the same name as the DTO property that is guaranteed to be defined if the DTO was properly decorated with @Property.

// src/product/product.params.ts
import { defineExpression } from "@autometa/runner";
import { ProductBuilder } from './'

defineExpression({
name: "builder:product"
regex: [/'([^']*)'/, /"([^"]*)"/, /[^\s]+/],
transform: (name: string) => new ProductBuilder().name(name)
}, {
name: "product:property",
transform: (value) => {
return value as keyof Product
}
});

Finally we can update our types:

import type { App as MyApp, World as MyWorld } from "../src/app";
import type { ProductBuilder, Product } from "../src/product";

declare module "@autometa/runner" {
interface App extends MyApp {}
interface World extends MyWorld {}
interface Types {
"builder:product": ProductBuilder;
"product:property": keyof Product;
}
}

We can now use a "Builder Pattern" in our Gherkin steps to reduce the number of Step definitions we have defined.

Feature: Adding a Product

Scenario: I add a new product
Given I have a product named 'Foo Phone'
* I set the product description to 'A phone that is foo'
* I set the product price to 100
import { ProductBuilder } from "../../src/products/product.builder";
import { Given } from "@autometa/runner";

Given("I have a product named {string}", (name, { world }) => {
world.productBuilder = new ProductBuilder().withName(name);
});

Given(
"I set the product {builder:property} to {primitive}",
(property, value, { world }) => {
world.productBuilder = world.productBuilder.assign(property, value);
// or
world.productBuilder = world.productBuilder[property](value);
}
);

Next we'll look at using phrases to make our steps more readable and hide implementation detail.