3

I am attempting to test whether a React component method is called using enzyme and jest. The function is supposed to be called when a <section> element becomes unfocused (on blur). The component is connected to a Redux store, but it is also exported by name, to take Redux out of the equation. Below is a simplified version of the component:

export class Section extends React.Component {
  constructor(props) {
    super(props)
    this.formRef = React.createRef();
    this.submitForm = this.submitForm.bind(this);
    this.state = {
      formSubmitted: false
    }
  }

  submitForm() {
    console.log("form submitted");
    this.setState({ formSubmitted: true })
    if (this.formRef && this.formRef.current) {
      this.formRef.current.submit();
    }
  }

  render() {
    return (
      <section onBlur={this.submitForm}>
        <form ref={this.formRef} action={url} method='post'>
          <input type="text" name="something" />
        </form>
      </section>
    );
  }
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Section);

I have tried all kinds of combinations for the spyOn portion, since it seems to be the root of the issue. All have failed.

import React from 'react';
import { shallow } from 'enzyme';
import { Section } from './Section';

describe('Section', () => {
  it('should submit form on blur', () => {
    const wrapper = shallow(<Section content={{ body: [] }} />);
    const spy = spyOn(wrapper.instance(), 'submitForm');
    const spy2 = spyOn(Section.prototype, 'submitForm');
    const spy3 = jest.spyOn(wrapper.instance(), 'submitForm');
    const spy4 = jest.spyOn(Section.prototype, 'submitForm');

    wrapper.find('section').simulate('blur');
    expect(wrapper.state().formSubmitted).toEqual(true);
    expect(spy).toHaveBeenCalled();
    expect(spy2).toHaveBeenCalled();
    expect(spy3).toHaveBeenCalled();
    expect(spy4).toHaveBeenCalled();
  })
})

I gave the component a state and tested it in addition to testing expect(...).toHaveBeenCalled in order to verify whether the function was actually called, and it seems to be the case.

The console.log('form submitted') appears in the console.

The test expect(wrapper.state().formSubmitted).toEqual(true); passes, which indicates to me that the correct function is being called. However, I don't want to have an unnecessary state just for the test. The state has just been added to assert that "submitForm" method is being called.

The assertions expect(...).toHaveBeenCalled() all fail with error Expected spy to have been called, but it was not called or Expected mock function to have been called, but it was not called.

skyboyer
  • 22,209
  • 7
  • 57
  • 64
lpytel
  • 105
  • 1
  • 9

2 Answers2

4

Interesting, this question dives right into some of the more unusual aspects of JavaScript.


What happens

submitForm is originally defined as a prototype method:

class Section extends React.Component {
  ...
  submitForm() { ... }
  ...
}

...meaning you would create the spy like this:

jest.spyOn(Section.prototype, 'submitForm');

...but then it gets redefined as an instance property within the constructor:

this.submitForm = this.submitForm.bind(this);

...meaning you would now create the spy like this:

jest.spyOn(wrapper.instance(), 'submitForm');

...but that still doesn't work because onBlur gets bound directly to this.submitForm during the render:

<section onBlur={this.submitForm}>

So the way the code is currently written actually makes it impossible to spy on submitForm because creating the spy requires an instance but the instance isn't available until the component has rendered and onBlur gets bound directly to this.submitForm during rendering.


Solution

Change onBlur to be a function that calls this.submitForm so that whenever onBlur fires it will call the current value of this.submitForm:

render() {
  return (
    <section onBlur={() => this.submitForm()}>
      <form ref={this.formRef} action={url} method='post'>
        <input type="text" name="something" />
      </form>
    </section>
  );
}

...then when you replace submitForm with the spy on the instance the spy will get called when onBlur fires:

describe('Section', () => {
  it('should submit form on blur', () => {
    const wrapper = shallow(<Section content={{ body: [] }} />);
    const spy = jest.spyOn(wrapper.instance(), 'submitForm');

    wrapper.find('section').simulate('blur');
    expect(wrapper.state().formSubmitted).toEqual(true);
    expect(spy).toHaveBeenCalled();  // Success!
  })
})
Brian Adams
  • 43,011
  • 9
  • 113
  • 111
  • Creating an arrow function on every render may cause performance issues. – Andrei Dotsenko Mar 19 '19 at 08:37
  • 1
    @AndreiDotsenko according to the official `React` doc: **Is it OK to use arrow functions in render methods?** ["Generally speaking, yes, it is OK"](https://reactjs.org/docs/faq-functions.html#is-it-ok-to-use-arrow-functions-in-render-methods). If you have performance issues then by all means remove the arrow function, but the performance cost is typically very small. – Brian Adams Mar 19 '19 at 10:55
1

Actually you want to verify if form's submit() is called when Section loses focus, right? So you don't need to verify if internal method has been called - it would not ensure if form is submitted or not. Also it would lead to false-negative results in case of refactoring(internal method is renamed and everything works but test fails).

You can mock ref object instead. Then you will be able to verify if form has been submitted

it('should submit form on blur', () => {
  const wrapper = shallow(<Section content={{ body: [] }} />);
  wrapper.instance().formRef.current = { submit: jest.fn()};
  wrapper.find('section').simulate('blur');
  expect(wrapper.instance().formRef.current.submit).toHaveBeenCalled();
})

Sure test also relies on component's internals(onto property that contains ref). But this way I believe test is more... say reliable.

skyboyer
  • 22,209
  • 7
  • 57
  • 64