Behavior-driven React development with Cucumber

This banner is the result of several dozen hours in PhotoShop.

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 
  • @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(' '),
};

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);

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 => '';

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" />

Worlds 🌎

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

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.

ReactDOM.render(
<Router>
<GlobalState>
<App /> {/* <-- test this component */}
</GlobalState>
</Router>
);
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

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';

Given

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

When

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

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}
`);
}
},
);
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
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

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"
}

Conclusion 🔚

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

Senior front end engineer / charlesstover.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store