Behavior-driven React development with Cucumber

Charles Stover
Dec 26, 2019 · 11 min read
This banner is the result of several dozen hours in PhotoShop.

This article is not meant to sell you on behavior-driven development. If you are reading this, you likely already know the benefits. This article is a tutorial for getting Cucumber testing into your React project today.

Concepts 💭

I would like to cover some brief concepts of what we are trying to achieve before the how we achieve it.

Integration testing

This setup is meant to aid with integration testing. The test suite will run via command line, e.g. npm run bdd or npm test. The intention is for the Cucumber test suites to be used to validate the application’s integrity before any commit reaches master.

@testing-library/react

The test suite will use @testing-library/react, because it is a powerful and well-documented testing framework that will likely coincide with your unit tests.

React Router

This test suite will include React Router as the mechanism for routing within the React application. It will include steps to spy on and verify the route.

Cucumber CLI

Unfortunately, Jest runs via the Jest CLI and Cucumber runs via the Cucumber CLI. Integrating them together was not a part of the scope of this project. In favor of a full-featured Cucumber test suite, this project executes the integration tests via the Cucumber CLI. This is a stark contrast to the NPM package jest-cucumber, which uses the Jest CLI and replaces feature files and typical Cucumber structure with JavaScript.

Babel/TypeScript

This setup will not require that your project already be built before being integration tested. Integration tests will run against your development code. This article will cover both Babel and TypeScript configurations, supporting whichever you need for your project.

NPM packages 📦

The new NPM packages that you will need to install:

npm install @babel/register @types/cucumber @types/jsdom cucumber jsdom-global ts-node --save-dev 
  • @babel/register is for Babel transformations of your code. You most likely need this if you have a babel.config.js file.
  • @types/cucumber and @types/jsdom are for TypeScript support and editor Intellisense.
  • cucumber contains the Cucumber CLI — the meat and potatoes of our test suite.
  • jsdom-global is used to simulate a browser environment in the test suite.
  • ts-node is necessary to transform your TypeScript project in a Node environment. You do not need this if you are not using TypeScript.

Configure Cucumber 🥒

To configure Cucumber, create a cucumber.js file in the root of your project. Use the following as a foundation for your configuration, but do not be afraid to make changes as your project finds necessary.

// cucumber.js
const dotenv = require('dotenv');
const os = require('os');
dotenv.config();const CPU_COUNT = os.cpus().length;
const IS_DEV = process.env.NODE_ENV === 'development';
const FAIL_FAST = IS_DEV ? ['--fail-fast'] : [];
const FORMAT = process.env.CI || !process.stdout.isTTY ? 'progress' : 'progress-bar';
module.exports = {
default: [
'./features/*.feature',
...FAIL_FAST,
`--format ${FORMAT}`,
`--parallel ${CPU_COUNT}`,
'--require-module jsdom-global/register',
'--require-module ts-node/register',
// Dependencies
'--require ./features/utils/babel.ts',
'--require ./features/utils/loaders.ts',
'--require ./features/utils/references.ts',
// Test
'--require ./features/worlds/index.ts',
'--require ./features/step-definitions/index.ts',
].join(' '),
};

In the above case, I use the dotenv package to read developer configuration from the local .env file. This allows developers to toggle their NODE_ENV environment variable to development.

The CPU count is used to determine how many Cucumber tests should be ran in parallel.

If a developer runs the tests, it fails fast, because typically only one test is being written at a time. If another environment runs the test (such as a Continuous Integration environment), the test should not fail fast, because each failed test should output its respective problem. This allows the committing developer to address all necessary changes at once.

The format uses progress-bar where possible and progress where not. You can find a list of Cucumber formats in the official documentation.

We include jsdom-global/register as a module to create an environment that simulates a browser. This is necessary for React DOM (and @testing-library/react) to mount your components.

We include ts-node/register for React applications written in TypeScript. If your React application is not written in TypeScript, remove this line.

Use --require ./features/utils/babel.ts if you are using Babel to transpile your code. Remove this line if you are using purely TypeScript.

We use --require ./features/utils/loaders.ts to mock the import of non-JavaScript assets, such as .css, .gif, .jpg, .png, and .scss. In this article, I merely stub such CSS and image files to be empty strings; but depending on the depth of your integration tests, you may want to implement the loaders here.

Use --require ./features/utils/references.ts to reference your global TypeScript definitions (.d.ts). If you are not using TypeScript, you can remove this line.

We use --require ./features/worlds/index.ts to set up the Cucumber concept of a “world.” You can read more about Cucumber worlds in the official documentation.

We use --require ./features/step-definitions/index.ts to import our Cucumber step definitions (Given, When, and Then). You can read more about Cucumber step definitions in the official documentation.

Babel transpilation 🤖

// features/utils/babel.ts
/* eslint @typescript-eslint/no-var-requires: 0 */
const BABEL_CONFIG = require('../../babel.config.js');
require('@babel/register')(BABEL_CONFIG);

Unlike ts-node and jsdom-global, Babel needs to be registered with a parameter: babel.config.js. Since @babel/register requires parameters, it must be imported as a file via the --require parameter instead of --require-module in cucumber.js.

Loaders 🧬

Our loaders allow us to import non-JavaScript files in our project. Since we are not using Webpack and its loaders, our vanilla imports won’t work in a non-Webpack environment such as the Cucumber CLI. We add support for them here.

// features/utils/loaders.ts
require.extensions['.css'] = (): string => '';
require.extensions['.gif'] = (): string => '';
require.extensions['.jpg'] = (): string => '';
require.extensions['.png'] = (): string => '';
require.extensions['.scss'] = (): string => '';

In the above code, I merely stub CSS and image imports to be empty strings. If you want a more thorough integration test that involves real stylesheets and images, this would be the location for implementing those loaders.

References 📝

References allow TypeScript to import type definitions from other files. In our case, ts-node does not automatically include .d.ts files, even when they are included with tsconfig.json. ts-node simply ignores all tsconfig.json includes and leaves you to manually import them yourself.

// features/utils/references.ts
/// <reference types="../../src/types/modules/ascii-table" />
/// <reference types="../../src/types/modules/jsurl" />
/// <reference types="../../src/types/modules/png" />

In the above, I am directing Cucumber CLI to get its definitions for ascii-table and jsurl (two NPM packages without TypeScript definitions) and *.png (all files that end in .png; see: Loaders) from src/types/modules/, where I otherwise manage them for my React application and have tsconfig.json automatically import them in non-ts-node environments.

Worlds 🌎

// features/worlds/index.ts
import { setWorldConstructor } from 'cucumber';
import AppWorld from './app-world';
setWorldConstructor(AppWorld);

Above, I’ve separated the world definition from the code that mounts it on the off-change that extensibility requires multiple worlds. You may merge these if you do not desire this separation of concerns.

AppWorld

The AppWorld is a Cucumber world that is in charge of managing the state of the React application. In this article, I am treating the application as the entry component (<App /> as you would find in a vanilla create-react-app project), because I am discussing integration testing. You can really have a world and behavior-driven test for any component, all the way down to the <Button /> level.

Note that this article will be managing the route via dependency injection. In order to accomplish this, the <App /> must not contain your Router component. Given a hierarchy of contexts, the component you want to be mounting can be seen here:

ReactDOM.render(
<Router>
<GlobalState>
<App /> {/* <-- test this component */}
</GlobalState>
</Router>
);

By testing this inner component, you reserve the right to inject state into your tests (“Given I am logged in as ‘admin’,” or “Given I am on the ‘/whatever’ route”).

import {
RenderResult,
SelectorMatcherOptions,
act,
render,
} from '@testing-library/react/pure';
import { World } from 'cucumber';
import { Location } from 'history';
import React, { ComponentType, PropsWithChildren } from 'react';
import { MemoryRouter } from 'react-router';
import useReactRouter from 'use-react-router';
import App from '../../src/components/app';
interface WorldParams {
attach(
content: Buffer | string,
mimeType?: string,
callback?: () => void,
): void;
parameters: Record<string, unknown>;
}
export default class AppWorld implements World {
private _location: Location<{}> = {
hash: '',
pathname: '/',
search: '',
state: {},
};
private _result: null | RenderResult = null;
private _route: string = '/';
public attach: WorldParams['attach'];
public parameters: WorldParams['parameters'];
public constructor({ attach, parameters }: WorldParams) {
this._RouterSpy = this._RouterSpy.bind(this);
this.click = this.click.bind(this);
this.getButtonByText = this.getButtonByText.bind(this);
this.getByText = this.getByText.bind(this);
this.render = this.render.bind(this);
this.setRoute = this.setRoute.bind(this);
this.attach = attach;
this.parameters = parameters;
}
private _RouterSpy({
children,
}: PropsWithChildren<{}>): JSX.Element {
const { location } = useReactRouter();
if (this._location !== location) {
this._location = location;
}
return <>{children}</>;
}
private get result(): RenderResult {
if (this._result) {
return this._result;
}
this._result = this.render();
return this._result;
}
public click(element: HTMLElement): void {
act((): void => {
element.click();
});
}
public getButtonByText(text: string): HTMLButtonElement {
return this.getByText(
text,
{ selector: 'button' },
) as HTMLButtonElement;
}
public getByText(
text: string,
options?: SelectorMatcherOptions,
): HTMLElement {
return this.result.getByText(text, options);
}
public get location(): Location<{}> {
return this._location;
}
public get route(): string {
return (
this._location.pathname +
this._location.search +
this._location.hash
);
}
public setRoute(route: string): void {
this._route = route;
}
public render(): RenderResult {
const route: string = this._route;
const RouterSpy: ComponentType<PropsWithChildren<{}>> =
this._RouterSpy;
return render(
<App />,
{
wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
return (
<MemoryRouter initialEntries={[route]} initialIndex={0}>
<RouterSpy>
{children}
</RouterSpy>
</MemoryRouter>
);
},
},
);
}
}

RouterSpy

The router spy is a higher-order component that merely saves the current location to the world object, allowing our step definitions to access it for validation purposes. In the code example, it accomplishes this using hooks. If you are not using React 16.8 or newer in your project, you should be able to mount it with <Route component={RouterSpy} /> as a sibling to {children}, where RouterSpy merely returns null instead of having any children; then, the RouterSpy component can receive the location from its props instead of from hooks.

get result

The world’s result property is the return value of @testing-library/react's render. Rendering occurs on an “as needed” basis, allowing us to execute multiple setup steps before rendering.

Given I am logged in as "admin"
And I am on the "/control-panel" route

There are two state changes that need to take place prior to rendering in the above example. It is not until the when clause that we actually render the DOM — as needed.

click / getButtonByText / getByText

These helper utilities are merely abstractions over @testing-library/react. They allow us to change the testing framework as needed, and they allow us a single point of failure if logic were ever to change. For example, getButtonByText is merely a <button /> in the code provided. However, if you are using a design system, you may end up needing something more advanced — { selector: '.design-system button > span' }. Making that change only here instead of in every test that interacts with a button will save you a lot of time and headache.

get location / get route

The location and route properties of the world instance contain the current location object and URL, useful for your then clauses to validate that you are where you expect to be.

setRoute

The route setter is a method that changes the instantiated route of the application. It does not change the route post-render — your integration test should be doing that by interacting with your application (or in edge cases, the JSDOM).

TypeScript

For the full-fledged TypeScript support, we now need to be able to access this world’s properties in the step definitions (e.g. this.setRoute). Unlike the .d.ts files that need to be included in <reference />s, the world’s definition need only be accessible to your editor — anywhere your tsconfig.json includes.

// cucumber.d.ts
import {
SelectorMatcherOptions,
} from '@testing-library/react/pure';
import 'cucumber';
import { Location } from 'history';
declare module 'cucumber' {
export interface World {
click(element: HTMLElement): void;
getButtonByText(text: string): HTMLButtonElement;
getByText(
text: string,
options?: SelectorMatcherOptions,
): HTMLElement;
getInputByLabel(label: string): HTMLInputElement;
location: Location<{}>;
setRoute(route: string): void;
type(input: HTMLInputElement, value: string): void;
}
}

Step definitions 🚶‍♀️

In the previous step, we pointed Cucumber to ./features/step-definitions/index.ts. Now it’s time to make that file.

// features/step-definitions/index.ts
import './given';
import './then';
import './when';

It is simply personal preference that I have each category separated into multiple files. You are welcome to merge them.

Given

import { Given } from 'cucumber';Given('I am on the {string} route', function(route: string): void {
this.setRoute(route);
});

In this example Given step, I am telling the world to instantiate at the provided route. This will allow our integration tests to deep link directly to the desired page for testing.

When

import { When } from 'cucumber';When(
'I click the {string} button',
function(buttonText: string): void {
const button: HTMLButtonElement =
this.getButtonByText(buttonText);
this.click(button);
},
);

In this example When step, I am telling the world to click a button. These helper methods (getButtonByText and click) are merely abstractions over @testing-library/react.

Then

import { Then } from 'cucumber';Then(
'I expect to be on the {string} route',
function(route: string): void {
if (this.route !== route) {
throw new Error(`
Expected route: ${route}
Received route: ${this.route}
`);
}
},
);

In this example Then step, I am verifying that the current route equals the one on which I expect to be.

We now have step definitions that allow the following feature:

Scenario: I navigate to the contact page.
Given I am on the "/" route
When I click the "Contact" button
Then I expect to be on the "/contact" route

Author’s note: A reader has mentioned that it is best practice to give the routes names (such as “homepage”), which you would parse into the actual route via step definition code, instead of listing the route in the gherkin itself. It is worth taking this into consideration.

Given I am on the homepage
When I click the "Contact" button
Then I expect to be on the contact page

Features 🐛

You can now add and execute features — the behavior-driven test documents. In the features directory, create any number of files ending in .feature.

Feature: Homepage  Scenario: I navigate to the contact page
Given I am on the "/" route
When I click the "Contact" button
Then I expect to be on the "/contact" route

Add Given, When, and Then clauses to the given.ts, when.ts, and then.ts files as needed. Per behavior-driven design, I would start your feature implementations by writing the behavior in a feature file first, then writing the necessary clauses to test that feature, then last actually writing the code to implement it until your feature tests pass.

NPM script 📜

To actually run your test, add the following to your package.json's scripts section:

"scripts": {
"bdd": "cucumber-js --profile default",
"test": "jest && cucumber-js --profile default"
}

Above, I have created a bdd script to solely run integration tests. I have also added it to the test script so that a Continuous Integration testing suite still only has to run npm test to get both unit tests and integration tests before validating that a commit is ready for production.

Conclusion 🔚

If you have any questions or great commentary, please leave them in the comments below.

To read more of my columns, you may follow me on LinkedIn and Twitter, or check out my portfolio on CharlesStover.com.

Charles Stover

Written by

Senior Full Stack JavaScript Developer / charlesstover.com

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade