Never render in React testing again

Leverage higher-order functions to make testing simple and fast

React testing is tedious. Especially when it comes to component classes (vs functional components). Not saying it shouldn’t be done, but testing it involves some boilerplate, and inevitably we end up going through very similar motions that we did in the days we worked directly with the DOM. The example modified from the enzyme README:

const mountSpy = sinon.spy(Foo.prototype, 'componentDidMount');
const onMountspy = sinon.spy();
const wrapper = mount(<Foo onMount={onMountSpy}/>);
t.true(Foo.prototype.componentDidMount.calledOnce);
t.true(onMountSpy.calledOnce);
Foo.prototype.componentDidMount.restore();

Just to make sure the lifecycle method triggers the onMount method, we need to spy on both of them and perform a full mount. To a lot of people, this doesn’t seem like much boilerplate, and like anything else once you get in the habit it doesn’t seem like much work. But this can add up when you have several instance methods, maybe assigning an instance value via ref callback, etc.

And you don’t need to do it.

Example time

Note: examples use the class properties proposal accessible if using the Stage 2 babel preset. If you are not using that, you can perform the same assignment to the instance in the constructor of the class.

Let’s start off basic, with a component that has an onChange event on an input:

class SuperInput extends Component {
inputValue = '';

onChangeInput = (event) => {
this.inputValue = event.currentTarget.value;

if (this.props.onChange) {
this.props.onChange(event);
}
}
  render() {
return (
<input onChange={this.onChangeInput}/>
);
}
}

In the example, whenever the input changes, it will store the value to an instance value, and fire an onChange if it exists. This is a fairly common thing if we want to save a version of local “state” without triggering any rerenders.

To test this, it looks something like this:

const spy = sinon.spy();
const wrapper = mount(<SuperInput onChange={spy}/>);
const testValue = 'test';
wrapper.find('input').simulate('change', {
currentTarget: {
value: testValue
}
});
t.is(wrapper.instance().inputValue, testValue);
t.true(spy.calledOnce);

Not crazy for those that have done this before, but we can refactor our class so that we don’t need to use spies, custom mounts, or any of that jazz.

Higher-order functions to the rescue

For those unfamiliar, higher-order functions are functions that either accept function(s) as arguments, or return a function (or both). In this case, we can structure our instance methods to instead call higher-order functions that receive an instance, and return the method we want to assign to the instance.

Our restructured component:

export const createOnChangeInput = (instance) => {
return (event) => {
instance.inputValue = event.currentTarget.value;
    if (instance.props.onChange) {
instance.props.onChange(event);
}
};
}
class SuperInput extends Component {
inputValue = '';

onChangeInput = createOnChangeInput(this);
  render() {
return (
<input onChange={this.onChangeInput}/>
);
}
}

Not a whole lot different, but first time looking at it, it can feel weird. We have moved our onChangeInput function creation to a separately-exported createOnChangeInput method.

It executes the same code, but instead of relying on the magical this, it accepts the instance as a parameter and works with it inside of the closure. That means our test can look something like this now:

const testValue = 'test';
const event = {
currentTarget: {
value: testValue
}
};
const instance = {
inputValue: ''
props: {
onChange(e) {
t.is(e, event}
}
}
};
const fn = createOnChangeInput(instance);
t.is(typeof fn, 'function');
fn(event);
t.is(instance.inputValue, testValue)'

Notice the changes here; we can completely mock out our component with POJOs that only have the values we need to test. I won’t go so far as to say these are pure functions (as the passed instance object is mutated), but it allows us to test things in a much more targeted way that also limits testing side-effects. It also makes our tests much faster!

That’s cool, but I mainly use classes for the lifecycle methods

No problem … this works for the lifecycle methods too! I know that seems weird because (unlike instance methods) you do not need to explicitly bind lifecycle methods to the instance, but it works just the same.

export const createComponentDidMount = (instance) => {
return () => {
instance.props.getStuff();
};
};
class Foo extends Component {
componentDidMount = createComponentDidMount(this);

Which you can test as easy as:

const instance = {
props: {
getStuff: sinon.spy()
}
};
const fn = createComponentDidMount(instance);
fn();
t.true(instance.props.getStuff.calledOnce);

Okay, so what are the gotchas?

The only one I can find so far is that if you try to extend a component class that is already created (class SuperFoo extends Foo) then it will not inherit from those classes. That said, doing this is considered an anti-pattern, so you really shouldn’t be doing that anyway.

My limited testing shows that the memory footprint is about the same, and if you are using babel to transpile your classes to ES5 it actually minifies smaller. I haven’t seen this paradigm anywhere on the interwebs yet, so maybe there is a downside I’m not seeing.

Hopefully this approach can make React testing a little bit easier, but I’d love to get some feedback.