Software Contracts in the Age of React

Steven Noble | Thu, 20 Sep 2018

At its core, modern programming is about contracts and conventions that we take for granted until they are broken. When that happens, it’s like waking up in a world that has forgetten the meaning of money or manners. Break the social contract like this, and all forms of interaction come to an abrupt halt.

Take this simple example:

// utils/get_prefix.js
export const getPrefix = () => 'mega';
// utils/prefix_word.js
export const prefixWord = (prefixGetter, wordToPrefix) => prefixGetter() + wordToPrefix;
// spec/utils/get_prefix_spec.js
import getPrefix from '../../utils/get_prefix';
describe('getPrefix()', => {
it('should return mega', () =>
expect(getPrefix).to.equal('mega'));
});
// spec/utils/prefix_word_spec.js
import sinon from 'sinon';
import prefixWord from '../../utils/prefix_word';
const getPrefix = sinon.stub().returns('mega');
describe('prefixWord()', => {
it('should prefix the supplied word', () =>
expect(prefixWord(getPrefix, 'Word')).to.equal('megaWord'));
});

What are some of the conventions that define the contract between get_prefix.js and prefix_word.js?

They include:

  • That getPrefix() can/should be called with zero arguments
  • That getPrefix() returns a string
  • That getPrefix() is defined in, and exported from, a file located at utils/get_prefix.js

The tests in get_prefix_spec.js and prefix_word_spec.js are based on these conventions, and the tests will pass. So far so good.

But imagine we change the string that getPrefix() returns:

// utils/get_prefix.js
// Changed the return value from 'mega' to 'hyper'
export const getPrefix = () => 'hyper';
// utils/prefix_word.js
export const prefixWord = (prefixGetter, wordToPrefix) => prefixGetter() + wordToPrefix;
// spec/utils/get_prefix_spec.js
import getPrefix from '../../utils/get_prefix';
// Updated the test on getPrefix() to pass
describe('getPrefix()', => {
it('should return hyper', =>
expect(getPrefix).to.equal('hyper'));
});
// spec/utils/prefix_word_spec.js
import prefixWord from '../../utils/prefix_word';
// Forgot to update the stub on getPrefix()
const getPrefix = sinon.stub().returns('mega');
// The test on prefixWord() still passes but should fail
describe('prefixWord()', => {
it('should prefix the supplied word', =>
expect(prefixWord(getPrefix, 'Word')).to.equal('megaWord'));
});

Or perhaps we even changed the type of its return value:

// utils/get_prefix.js
// Changed the return value type from string to object
export const getPrefix = () => ({ en: 'mega', fr: 'méga' });
// utils/prefix_word.js
export const prefixWord = (prefixGetter, wordToPrefix) => prefixGetter() + wordToPrefix;
// spec/utils/get_prefix.js
import getPrefix from '../../utils/get_prefix';
// Updated the test on getPrefix() to pass
describe('getPrefix()', => {
it('should return a prefix object', =>
expect(getPrefix).to.equal({ en: 'mega', fr: 'méga' }));
});
// spec/utils/prefix_word.js
import prefixWord from '../../utils/prefix_word';
// Forgot to update the stub on getPrefix()
const getPrefix = sinon.stub().returns('mega');
// The test on prefixWord() still passes but should fail
describe('prefixWord()', => {
it('should prefix the supplied word', =>
expect(prefixWord(getPrefix, 'Word')).to.equal('megaWord'));
});

Both cases are examples of what J. B. Rainsberger calls the drifting test doubles problem.

In both cases, the contract is broken and the app will not behave as expected, but the tests will still pass. It’s as if sinon.stub() is saying “let’s assume that getPrefix() agrees with our understanding of the contract...”. And assumptions are dangerous.

These examples are deliberately simplified, but they point to a real problem that React developers grapple with every day. For example, consider this slice of a React app:

// containers/form_container.js
import Form from '../components/form_component';
export const mapStateToProps = state => ({ username: state.username });
export default connect(mapStateToProps)(Form);
// components/form_component.js
import PropTypes from 'prop-types';
import React from 'react';
import FinalConfirmation from './final_confirmation_component';
const Form = class extends React.Component {
render() {
<FinalConfirmation {...this.props} />
};
};
Form.propTypes = {
username: PropTypes.string.isRequired,
};
export default Form;
// components/final_confirmation_component.js
import PropTypes from 'prop-types';
import React from 'react';
const FinalConfirmation = class extends React.Component {
render() {
<p id="username">
Username: {this.props.username}
</p>
};
};
FinalConfirmation.propTypes = {
username: PropTypes.string.isRequired,
};
export default FinalConfirmation;
// spec/containers/form_container_spec.js
import { mapStateToProps } from '../../containers/form_container';
describe('form container', () => {
it('should pass down the username', () => {
const state = { username: 'adamadeus' };
const props = mapStateToProps(state);
expect(props.username).to.equal('amadeus');
});
});
// spec/components/form_component_spec.js
import { shallow } from 'enzyme';
import Form from '../../components/form_component';
describe('Form component', => {
describe('final confirmation', () => {
it('should pass down the username', () => {
const props = { username: 'amadeus' };
const component = shallow(<Form {...props}/>);
expect(component.find('FinalConfirmation').props().username).to.equal('amadeus');
});
});
});
// spec/components/final_confirmation_component_spec.js
import { shallow } from 'enzyme';
import FinalConfirmation from 'final_confirmation_component';
describe('FinalConfirmation component', => {
it('should confirm the username', () => {
const props = { username: 'amadeus' };
const component = shallow(<FinalConfirmation {...props}/>);
expect(component.find('p#username').text()).to.equal('Username: amadeus');
});
});

By now, it’s almost fun to start thinking of all the ways you could break this app without breaking the tests. For example...

// containers/form_container.js
import Form from '../components/form_component';
// The container passes a 'username' prop (type: string) to Form
export const mapStateToProps = state => ({ username: state.username });
export connect(mapStateToProps)(Form);
// components/form_component.js
import PropTypes from 'prop-types';
import React from 'react';
import FinalConfirmation from './final_confirmation_component';
const Form = class extends React.Component {
render() {
// Form passes whatever props it receives to FinalConfirmation
<FinalConfirmation {...this.props} />
};
};
// Form expects to receive an 'email' prop (type: string) from the container
Form.propTypes = {
email: PropTypes.string.isRequired,
};
export default Form;
// components/final_confirmation_component.js
import PropTypes from 'prop-types';
import React from 'react';
const FinalConfirmation = class extends React.Component {
render() {
<p id="username">
Username: {this.props.user.name}
</p>
};
};
// FinalConfirmation expects to receive a 'username' prop (type: object) from Form
FinalConfirmation.propTypes = {
username: PropTypes.shape({
name: PropTypes.string.isRequired,
});
};
export default FinalConfirmation;
// spec/containers/form_container_spec.js
import { mapStateToProps } from '../../containers/form_container';
// The test will pass despite sending unexpected props to Form
describe('form container', () => {
it('should pass down the email', () => {
const state = { username: 'adamadeus' };
const props = mapStateToProps(state);
expect(props.username).to.equal('amadeus');
});
});;
// spec/components/form_component_spec.js
import { shallow } from 'enzyme';
import Form from '../../components/form_component';
// The test will pass despite sending unexpected props to FinalConfirmation
describe('Form component', => {
describe('final confirmation', () => {
it('should pass down the username', () => {
const props = { email: 'amadeus@example.com' };
const component = shallow(<Form {...props}/>);
expect(component.find('FinalConfirmation').props().email).to.equal('amadeus@example.com');
});
});
});
// spec/components/final_confirmation_spec.js
import { shallow } from 'enzyme';
import FinalConfirmation from '../../components/final_confirmation_component';
// The test will pass because the test, unlike Form, sends the expected props to FinalComponent
describe('FinalConfirmation component', => {
it('should confirm the username', () => {
const props = { user: { name: 'amadeus' } };
const component = shallow(<FinalConfirmation {...props}/>);
expect(component.find('p#username').text()).to.equal('Username: amadeus');
});
});

And that’s just the start. For example, think about anything that might change the format of username in the Redux store. After all, there is no limit to the number of places in your codebase from which this location in shared state might be updated.

Enzyme is incredibly powerful, but like any testing framework it forces you to declare your inputs, and the inputs to a React component — the props — are often so complex that they are mock data that must be maintained as carefully as the test itself.

It’s clear that unit testing a React app is a necessary — but not sufficient — condition for programming with confidence.

So, what’s the solution?

When I first started examining this issue, the solution that initially stood out was contract testing. This is an additional layer of testing that is particularly important for maintaining confidence when developing a component of a distributed architecture.

For example, Pact is a popular library for contract testing an API.

Using Pact, the API’s clients — your webapp, your Slackbot, your Android app, etc — first define their requirements for how the API will behave.

This is the contract, which lives in a location with shared access.

Then developers, when working on the server, don’t just run their unit tests. They also execute contract tests that confirm whether these expectations are met.

Given the popularity of Pact and its obvious use case, I was curious if something similar could enforce the contracts that exist between JavaScript modules within the same codebase.It turns out there is at least two options: Colton and Chado.

On first glance, these solutions are attractive. Certainly they are elegantly designed and capable of solving the problem. If I had all the time in the world to work on a perfectly crafted solo side-project, I would definately consider using them. But on deeper consideration, I realised that these tools face a serious practical challenge: if unit tests are usually considered a “must have” in business development environments, and end-to-end browser automation tests are usually seen as a “nice to have”, then a third layer of contract tests will always be at risk of being considered a “don’t need to have”.

What I really wanted was a way of automatically bringing some form of contract confirmation along for the unit-testing ride, with little or no additional development effort.

I used to enjoy something like this with the automatically verified doubles that are provided by Rspec, the most popular unit testing framework on Ruby. But it turns out you don’t need to use a specific testing toolset to acheive this — you just have to make a decision about how you structure your tests.

After all, a contract is just a shared expectation (informally speaking). So let’s see if we can share expectations between our tests. First off, consider the utils examples from above. We can guard against test double drift in that example by sharing our fixtures, like this:

// utils/get_prefix.js
export const getPrefix = (lang) => {
if (lang === 'fr') return 'méga';
return 'mega';
};
// utils/prefix_word.js
export const prefixWord = (prefixGetter, wordToPrefix) => prefixGetter() + wordToPrefix;
// spec/fixtures/prefix_fixtures.js
export const prefixFixtures = {
en: { args: 'en', result: 'mega' },
fr: { args: 'en', result: 'méga' },
};
// spec/utils/get_prefix_spec.js
import getPrefix from '../../utils/get_prefix';
import prefixFixtures from '../fixtures/prefix_fixtures';
const { args, result } = prefixFixtures.en;
describe('getPrefix()', => {
it('should return mega', () =>
expect(getPrefix(...args)).to.equal(result));
});
// spec/utils/prefix_word_spec.js
import sinon from 'sinon';
import prefixWord from '../../utils/prefix_word';
import prefixFixtures from '../../utils/prefix_fixtures';
const { args, result } = prefixFixtures.en;
const getPrefix = sinon.stub().returns(result);
describe('prefixWord()', => {
it('should call getPrefix with the correct args', () =>
expect(prefixWord.args).to.deep.equal([args]));
it('should prefix the supplied word', =>
expect(prefixWord(getPrefix, 'Word')).to.equal('megaWord'));
});

The amount of additional testing code here is minimal. And there are no additional libraries. But there is a pattern here that developers could follow. And this pattern provides confidence that if the expectations held by different modules fall out of sync, then tests will start to break too.

To acheive something similar with React components and containers, we will harness the fact that PropTypes declarations are also sharable. The resulting pattern looks like this:

// containers/form_container.js
import Form from '../components/form_component';
export const mapStateToProps = state => ({ username: state.username });
export default connect(mapStateToProps)(Form);
// components/form_component.js
import PropTypes from 'prop-types';
import React from 'react';
const Form = class extends React.Component {
render() {
<FinalConfirmation {...this.props} />
};
};
Form.propTypes = {
username: PropTypes.string.isRequired,
};
export default Form;
// components/final_confirmation_component.js
import PropTypes from 'prop-types';
import React from 'react';
const FinalConfirmation = class extends React.Component {
render() {
<p id="username">
Username: {this.props.username}
</p>;
};
};
FinalConfirmation.propTypes = {
username: PropTypes.string.isRequired,
};
export default FinalConfirmation;
// spec/containers/form_container_spec.js
import PropTypes from 'prop-types';
import { mapStateToProps } from '../../containers/form_container';
import Form from '../../components/form_component';
let error;
let props;
describe('form container', () => {
beforeAll(done => {
const state = { username: 'adamadeus' };
props = mapStateToProps(state);
});
it('should pass through the username', () =>
expect(props.username).to.equal('amadeus'));
it('should pass down valid props', () =>
expect(() => PropTypes.checkPropTypes(Form.propTypes, props)).not.to.throw());
});
});
// spec/components/form_component_spec.js
import { shallow } from 'enzyme';
import PropTypes from 'prop-types';
import Form from '../../components/form_component';
import FinalConfirmation from '../../components/final_confirmation';
let childProps;
let component;
let error;
describe('Form component', () => {
describe('final confirmation', () => {
beforeAll(done => {
const props = { username: 'amadeus' };
component = shallow(<Form {...props}/>);
childProps = component.find(FinalConfirmation).props();
});
it('should pass down the username', () =>
expect(childProps.username).to.equal('amadeus'));
it('should pass down valid props', () =>
expect(() => PropTypes.checkPropTypes(FinalConfirmation.propTypes, childProps)).not.to.throw());
});
});
// spec/components/final_confirmation_component_spec.js
import { shallow } from 'enzyme';
import FinalConfirmation from '../../components/final_confirmation_component';
describe('FinalConfirmation component', => {
it('should confirm the username', () => {
const props = { username: 'amadeus' };
const component = shallow(<FinalConfirmation {...props}/>);
expect(component.find('p#username').text()).to.equal('Username: amadeus');
});
});

So, there is no need to run the risk of drifting test doubles in your code, and no need to adopt new tooling to solve this problem. Instead, share expectations between your tests as shown above, and have a high level of confidence that your rapid software development is taking you in a safe direction.

Tags CodeTechnologyBack To All Posts