Testing Javascript for your Gutenberg Integrated Plugin

(Edit October 10, 2018) Note: This article is now out of date but is kept published for reference purposes. Nearly everything in Gutenberg is published as a package so for the purpose of testing you can include those packages as a devDependency in your package.json file and jest will know to reference those in tests. 

As a part of my work with Event Espresso I’ve been assisting them with moving over to a more modern Javascript build system.  This has been prompted by the upcoming new editor for WordPress codenamed Gutenberg.  I’m not going to spend a whole lot of time talking about Gutenberg since there’s already a ton of information on the internet.  What I want to do in this post is outline a way for plugin developers to test their custom components/blocks built for Gutenberg using the Jest framework.

Writing javascript tests using Jest is pretty straightforward and this isn’t a how-to-post since there’s a lot of that already available (including this readme the Gutenberg team prepared).  Instead I thought it’d be useful to write how I solved a problem with a particular set of tests I was trying to write for some code I had written (especially since I couldn’t find anything useful on the internet myself to help with it).

The Problem

For Event Espresso I’m creating a reusable react component that exposes a Event Selector populated with events currently on the user’s site.  This will end up getting used in various parts of the Gutenberg friendly api we’re building.  I wanted a way to test this component using jest but ran into a few problems:

  1. The EventSelect component is composed of the Placeholder, SelectControl, and Spinner components from @wordpress/components (or  wp.elements ).
  2. There’s a few other dependencies in file for this component including @wordpress/i18n and @wordpress/data
  3. Gutenberg itself is a plugin and most of the dependencies the code has on Gutenberg itself are thus not available in the context of the javascript being tested (because of assumption of Gutenberg functionality being exposed on the wp global at runtime).
  4. I want to at a minimum use jest’s snapshot feature (using shallow) to verify that EventSelect is rendered correctly depending on the various props provided to it. 

While I could mock all of the dependencies, mocking the WordPress components being used would mean needing to make the assumption they would never change.

The Solution

I’ll get into the specifics of how I fixed this first, and then I’ll take some time to explain why it works (or in some cases why I think it works because frankly I’m still learning Jest and its intricacies).

The very first thing that needs done is importing gutenberg as a package.  Wait what?  Gutenberg is an npm package?  It’s not published to npm, however technically, anything with a npm package.json file in it is already a package.  If it’s hosted on github, you can install it.  So the first step to exposing gutenberg javascript for our tests is to install it!  How do you do that?

npm install WordPress/gutenberg#master --save-dev

That’s it!  In case you’re wondering, npm automatically looks on github for packages if they aren’t in the npm package repository.  So the format I used corresponds to the github {organization}/{repo}#{branch} format.

Next there’s some configuration file changes needed.

The jest.config.json file ended up with this.

{
	"rootDir": "../../../",
	"collectCoverageFrom": [
		"assets/src/**/*.js",
		"!**/node_modules/**",
		"!**/vendor/**",
		"!**/test/**"
	],
	"moduleDirectories": ["node_modules"],
	"moduleNameMapper": {
		"@eventespresso\\/(eejs)": "assets/src/$1",
		"@wordpress\\/(blocks|components|date|editor|element|data|utils|edit-post|viewport|plugins|core-data)": "<rootDir>/node_modules/gutenberg/$1",
		"tinymce": "<rootDir>/tests/javascript-config/unit/mocks/tinymce",
		"@wordpress/i18n": "<rootDir>/tests/javascript-config/unit/mocks/i18n"
	},
	"setupFiles": [
		"core-js/fn/symbol/async-iterator",
		"<rootDir>/tests/javascript-config/unit/setup-globals"
	],
	"preset": "@wordpress/jest-preset-default",
	"testPathIgnorePatterns": [
		"/node_modules/",
		"/test/e2e"
	],
	"transformIgnorePatterns": [
		"node_modules/(?!(gutenberg)/)"
	]
}

Three particular  properties you should take note of are:

moduleNameMapper

The property allows you to map module names to a specific location or to a mock.  In this case I wanted to map all @wordpress/* packages to the gutenberg package I just installed.  So this line does exactly that:

"@wordpress\\/(blocks|components|date|editor|element|data|utils|edit-post|viewport|plugins|core-data)": "<rootDir>/node_modules/gutenberg/$1",

Wait a minute!  How can you be using things like@wordpress/data for your imports if that isn’t a package?

Most Gutenberg tutorials you see on the internet give instructions for using the wp global as an external in your js source files, resulting in something like this:

const { Placeholder } = wp.components;
const { withSelect } = wp.data;

This works just fine,  however, if Gutenberg ever publishes some of these things as a bona-fide package, and you want to use that package, you’d have to go through and change every file in your source to to imports. I’m lazy, I don’t want to have to go through all my files if that happens. So instead I want to do this:

import { Placeholder } from '@wordpress/components';
import { withSelect } from '@wordpress/data';

However, if I do that, Webpack won’t know what to do with those imports because @wordpress/components and @wordpress/data don’t exist in node_modules.  So we need to help webpack out.  In the webpack configuration file I have something like this:

const externals = {
	'@wordpress/data': {
		this: [ 'wp', 'data' ],
	},
	'@wordpress/components': {
		this: [ 'wp', 'components' ],
	},
};

The externals object is then assigned to the externals configuration property for webpack.  What this does is alias@wordpress/data to the wp.data global.  So on builds any imports are correctly translated to use the wp.data global.

However, when running jest tests, jest does not work with the webpack builds (obviously) and is oblivious to this aliasing.  So when it encounters the imports (and goes to translate them to commonjs modules) it can’t find the @wordpress/* packages.  We get around that by using the moduleNameMapper property.

You’ll also notice that I mapped tinymce to a specific location for a mock.   What I discovered is that jest will follow the package import chain all the way down.  I would hit import not found for tinymce because guess what?  Gutenberg itself sets up tinymce as an external in its webpack config.  Since I’ll never need tinymce for my own testing (and shouldn’t need it anyways), I can just mock it.  So this line ends up being:

"tinymce": "<rootDir>/tests/javascript-config/unit/mocks/tinymce",

The path it points to simply contains this the tinymce.js file I created with this as its contents:

module.exports = jest.fn()

Which is just a handy method jest provides for mocking.

Finally, you’ll see that I also mock @wordpress/i18n.  The interesting thing here is that because @wordpress/i18n exists as a package.  I didn’t have to mock it.  However, I don’t really need to initialize jed or anything with i18n so to avoid errors related to it not being initialized in the test environment (and the usage of an actual text_domain in any i18n function calls), its better to just mock it.  So this line…

"@wordpress/i18n": "<rootDir>/tests/javascript-config/unit/mocks/i18n"

…points to the i18n.js file which currently has this as its contents:

module.exports = {
	 __ : (string) => {
		return string;
	}
};

Right now I’m only mocking the __ function, but as I start testing various code that utilizes other wp.i18n functions I’ll add them to this as well.

The next property I want to zero in on in the jest.config.json file is the setupFiles property.  This property is used to indicate any files you want executed before jest runs tests.  In particular, take note of this file reference:

"<rootDir>/tests/javascript-config/unit/setup-globals"

This points to a setup-globals.js file at that path and its contents are this:

/**
 * Setup globals used in various tests
 */
// Setup eejsdata global. This is something set in EE core via
// wp_localize_script so its outside of the build process.
global.eejsdata = {
	data: {
		testData: true
	},
};

// Set up `wp.*` aliases.  Doing this because any tests importing wp stuff will
// likely run into this.
global.wp = {
	shortcode: {
		next() {},
		regexp: jest.fn().mockReturnValue( new RegExp() ),
	},
};

[
	'element',
	'components',
	'utils',
	'blocks',
	'date',
	'editor',
	'data',
	'core-data',
	'edit-post',
	'viewport',
	'plugins',
].forEach( entryPointName => {
	Object.defineProperty( global.wp, entryPointName, {
		get: () => require( 'gutenberg/' + entryPointName ),
	} );
} );

This is needed not because I’m going to be using the wp global anywhere in the source js I’m testing, but because I’m actually importing Gutenberg source for the tests, some of the Gutenberg files ARE referencing the wp globals and thus they need to be defined to avoid undefined errors when running tests.  With this bit of code, we’re linking up the wp.{module name} with its module location in the node_modules folder.  So for instance anytime jest encounters wp.blocks it will set the property to the module loaded from node_modules/gutenberg/blocks.  

The final property I want to zero in on for the jest.config.json file is the transformIgnorePatterns property.  For a while I was struggling with the following error every time I ran a test (prior to this property value being inserted):

huh? what do you mean unexpected token import!

Jest expects packages that are included to be pre-compiled (i.e from ES6 to commonjs).  So if you have any imports that point to uncompiled packages you’ll get the above kind of error.  Since we’re including gutenberg as a package straight from its repo, we don’t have the compiled assets, hence the need to let jest know it needs to transform any imports it comes across in gutenberg. I accidentally discovered this when I was reading through the Jest documentation and came across this line:

Since all files inside node_modules are not transformed by default, Jest will not understand the code in these modules, resulting in syntax errors.

Jest Documentation for transformIgnorePatterns

Oooooo, ya I’m getting syntax errors.  What I didn’t realize is that this property allows you to whitelist things you want to be transformed. Groovy, so as a reminder here’s what I ended up using:

"transformIgnorePatterns": [
		"node_modules/(?!(gutenberg)/)"
	]

This then made sure that anytime the node_modules/gutenberg package (or any dependencies in those packages) were imported, the code referenced would be transformed into something jest can work with.  Say good bye to syntax errors!

Winner winner, chicken dinner!

With all that work, the code I wanted tested:

/**
 * External dependencies
 */
import { stringify } from 'querystringify';
import moment from 'moment';
import { isUndefined, pickBy, isEmpty } from 'lodash';
import PropTypes from 'prop-types';

/**
 * WP dependencies
 */
import { Placeholder, SelectControl, Spinner } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { withSelect } from '@wordpress/data';

/**
 * Internal dependencies
 */
import { buildEventOptions } from './build-event-options';

const nowDateAndTime = moment();

export const EventSelect = ( {
	events,
	onEventSelect,
	selectLabel,
	selectedEventId,
	isLoading,
} ) => {
	if ( isLoading || isEmpty( events ) ) {
		return <Placeholder key="placeholder"
			icon="calendar"
			label={ __( 'EventSelect', 'event_espresso' ) }
		>
			{ isLoading ?
				<Spinner /> :
				__(
					'There are no events to select from. You need to create an event first.',
					'event_espresso'
				)
			}
		</Placeholder>;
	}

	return <SelectControl
		label={ selectLabel }
		value={ selectedEventId }
		options={ buildEventOptions( events ) }
		onChange={ ( value ) => onEventSelect( value ) }
	/>;
}

/**
 * @todo some of these proptypes are likely reusable in various place so we may
 * want to consider extracting them into a separate file/object that can be
 * included as needed.
 * @type {{events: *, onEventSelect, selectLabel: *, selectedEventId: *,
 *   isLoading: *, attributes: {limit: *, orderBy: *, order: *, showExpired: *,
 *   categorySlug: *, month: *}}}
 */
EventSelect.propTypes = {
	events: PropTypes.arrayOf( PropTypes.shape( {
		EVT_name: PropTypes.string.required,
		EVT_ID: PropTypes.number.required,
	} ) ),
	onEventSelect: PropTypes.func,
	selectLabel: PropTypes.string,
	selectedEventId: PropTypes.number,
	isLoading: PropTypes.bool,
	attributes: PropTypes.shape( {
		limit: PropTypes.number,
		orderBy: PropTypes.oneOf( [
			'EVT_name',
			'EVT_ID',
			'start_date',
			'end_date',
			'ticket_start',
			'ticket_end',
		] ),
		order: PropTypes.oneOf( [ 'asc', 'desc' ] ),
		showExpired: PropTypes.bool,
		categorySlug: PropTypes.string,
		month: PropTypes.month,
	} ),
};

EventSelect.defaultProps = {
	attributes: {
		limit: 20,
		orderBy: 'start_date',
		order: 'desc',
		showExpired: false,
	},
	selectLabel: __( 'Select Event', 'event_espresso' ),
	isLoading: true,
	events: [],
};

/**
 * Used to map an orderBy string to the actual value used in a REST query from
 * the context of an event.
 * @todo this should be moved to a mapper library for various EE Rest Related
 * things maybe?
 *
 * @param {string} orderBy
 *
 * @return { string } Returns an actual orderBy string for the REST query for
 * 					  the provided alias
 */
const mapOrderBy = ( orderBy ) => {
	const orderByMap = {
		start_date: 'Datetime.DTT_EVT_start',
		end_date: 'Datetime.DTT_EVT_end',
		ticket_start: 'Datetime.Ticket.TKT_start_date',
		ticket_end: 'Datetime.Ticket.TKT_end_date',
	};
	return isUndefined( orderByMap[ orderBy ] ) ? orderBy : orderByMap[ orderBy ];
};

const whereConditions = ( { showExpired, categorySlug, month } ) => {
	const where = [];
	const GREATER_AND_EQUAL = encodeURIComponent( '>=' );
	const LESS_AND_EQUAL = encodeURIComponent( '<=' );

	if ( ! showExpired ) {
		where.push( 'where[Datetime.DTT_EVT_end**expired][]=>&where[Datetime.DTT_EVT_end**expired][]=' +
			nowDateAndTime.local().format() );
	}
	if ( categorySlug ) {
		where.push( 'where[Term_Relationship.Term_Taxonomy.Term.slug]=' + categorySlug );
	}
	if ( month && month !== 'none' ) {
		where.push( 'where[Datetime.DTT_EVT_start][]=' + GREATER_AND_EQUAL + '&where[Datetime.DTT_EVT_start][]=' +
			moment().month( month ).startOf( 'month' ).local().format() );
		where.push( 'where[Datetime.DTT_EVT_end][]=' + LESS_AND_EQUAL + '&where[Datetime.DTT_EVT_end][]=' +
			moment().month( month ).endOf( 'month' ).local().format() );
	}
	return where.join( '&' );
};

export default withSelect( ( select, ownProps ) => {
	const { limit, order, orderBy } = ownProps.attributes;
	const where = whereConditions( ownProps.attributes );
	const { getEvents, isRequestingEvents } = select( 'eventespresso/lists' );
	const queryArgs = {
		limit,
		order,
		order_by: mapOrderBy( orderBy ),
	};
	let queryString = stringify( pickBy( queryArgs,
		value => ! isUndefined( value ),
	) );

	if ( where ) {
		queryString += '&' + where;
	}
	return {
		events: getEvents( queryString ),
		isLoading: isRequestingEvents( queryString ),
	};
} )( EventSelect );

And the actual test I tried to get working:

import { shallow } from 'enzyme';
import { EventSelect } from '../index';

describe( 'EventSelect', () => {
	it( 'should render', () => {
		const wrapper = shallow( <EventSelect /> );
		expect( wrapper ).toMatchSnapshot();
	} );
} );

results in a winner!

whoah, there’s that green I’m looking for!

I’m not done yet, but the big part is done, now I can go ahead and write tests for various states of the component.

Any thoughts?  I’m pretty new to jest so there may be some things I’m not doing “right”.  If you’re a javascript/jest guru and have some pointers to help improve on this I’m all ears 🙂

If you want to follow along with my journey in writing this component and the related tests etc, you can go to this pull request.

Leave a Reply

Up Next:

Something every Language/Library debate needs to keep in mind....

Something every Language/Library debate needs to keep in mind....