enzyme icon indicating copy to clipboard operation
enzyme copied to clipboard

wrapper.instance() is null on class component

Open bschmalz opened this issue 5 years ago • 18 comments

Current behavior

wrapper.instance() and wrapper.state() are not working on a class component.

Expected behavior

wrapper.instance() and wrapper.state() work on a class component.

Your environment

I'm really confused by a component not working with instances. I have many other similar components that have a the same structure that are working. I have broken this down to a very simple component and test to show the problem.

React Component

import React from 'react';

export class EnzymeTest extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      test: true
    }
  }

  componentDidMount() {
    this.setState({test: false})
  }

  render() {
    if (this.state.test) return <div>Test is True</div>
    else return <div>Test is False</div>
  }
};

Enzyme Test

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

let wrapper;

describe('isOutsideDateRange', () => {
  beforeEach(() => {
    wrapper = shallow(<EnzymeTest /> )
  });

  beforeEach(() => {
    wrapper.unmount();
  });

  test('has state', () => {
    expect(wrapper.state('test')).toBe('blah');
  });

  test('has a wrapper instance', () => {
    expect(wrapper.instance()).toBeTruthy();
  });
});

Both of the above tests fail. The state test shows this error: ShallowWrapper::state() can only be called on class components

and wrapper.instance() in null.

This is a stateful class component so I'm confused about this behavior.

API

  • [ x] shallow
  • [ ] mount
  • [ ] render

Version

library version
enzyme 3.3
react 16.3.2
react-dom 16.3.2
react-test-renderer 16.3.2
adapter (below)

Adapter

  • [x ] enzyme-adapter-react-16
  • [ ] enzyme-adapter-react-16.3
  • [ ] enzyme-adapter-react-16.2
  • [ ] enzyme-adapter-react-16.1
  • [ ] enzyme-adapter-react-15
  • [ ] enzyme-adapter-react-15.4
  • [ ] enzyme-adapter-react-14
  • [ ] enzyme-adapter-react-13
  • [ ] enzyme-adapter-react-helper
  • [ ] others ( )

bschmalz avatar Apr 03 '19 23:04 bschmalz

In this case, wrapper isn’t an EnzymeTest, it’s whatever EnzymeTest renders. You can verify this with wrapper.debug().

ljharb avatar Apr 03 '19 23:04 ljharb

I'm not trying to test against wrapper.instance(). However, the actual component that I'm trying to test has class methods that I want to unit test, so I want to unit test them via wrapper.instance().someMethod()... bu t I can't because wrapper.instance() returns null.

What am I doing wrong? How can I run tests against those class methods? I'm doing the same type of testing in plenty of other components so I'm confused why instance() is null in this case.

bschmalz avatar Apr 03 '19 23:04 bschmalz

Can you share the actual unmodified component and test code? “Simplified” examples rarely simplify things, unfortunately.

ljharb avatar Apr 04 '19 00:04 ljharb

React Component

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { fetchAirports } from '~/services/airports';
import DayPicker from '../DayPicker';
import DropDown from '../DropDown';
import SearchInput from '../SearchInput';

/** Controlled form that allows users to input the necessary info to search for flights */

export class FlightPicker extends Component {
  constructor(props) {
    super(props);
    this.options = [
      { key: 0, text: 'Any Time',  value: 0 },
      { key: 8, text: 'Morning 6AM-10AM',  value: 8 },
      { key: 9, text: 'Midday 10AM-2PM',  value: 9 },
      { key: 10, text: 'Afternoon 3PM-7PM',  value: 10 },
      { key: 11, text: 'Evening 8PM-12AM',  value: 11 },
      { key: 1, text: '5AM-7AM',  value: 1 },
      { key: 2, text: '7AM-9AM',  value: 2 },
      { key: 3, text: '9AM-12PM', value: 3 },
      { key: 4, text: '12PM-3PM', value: 4 },
      { key: 5, text: '3PM-5PM',  value: 5 },
      { key: 6, text: '5PM-7PM',  value: 6 },
      { key: 7, text: '7PM-11PM',  value: 7 },
    ];
    this.mapAirport = this.mapAirport.bind(this);
    this.mapAirports = this.mapAirports.bind(this);
  }

  isOutsideDateRange(date) {
    return date.isBefore(this.props.outboundDate);
  }

  setOutboundDate = (date) => {
     date && date.isBefore(this.props.returnDate) ?  this.props.onChange({outboundDate: date}) : this.props.onChange({outboundDate: date, returnDate: date})
  }

  mapAirport(airport) {
    return {
      title: `${airport.city}, ${airport.countryName} (${airport.iata})`,
      iata: airport.iata,
      name: airport.name
    }
  }

  mapAirports(airports) {
    // Map our airport code matches
    const codeMatches = airports.iataCodeMatches.map(this.mapAirport);
    // Put airport code matches in an object to easily filter them out of the name results
    const codeMatchesObj = {};
    codeMatches.forEach(el => codeMatchesObj[el.iata] = true);
    return codeMatches.concat(airports.cityNameMatches.filter(city => city.iata && !codeMatchesObj[city.iata]).map(this.mapAirport));
  }

  resultRenderer = ({title, name}) => (
    <div>
      <p style={{fontSize: '14px', margin: '4px 0', fontWeight: '600'}}>{title}</p>
      <p style={{fontSize: '10px', margin: '4px 0', color: 'gray'}}>{name}</p>
    </div>
  );

  searchAirports(searchString) {
    return fetchAirports(searchString.toLowerCase());
  }

  render() {
    const {
      fromAirport,
      outboundDate,
      outboundTimeSlotId,
      toAirport,
      returnDate,
      returnTimeSlotId,
      isRoundTrip,
      onChange
    } = this.props;

    return (
      <div className="flight-picker component">
        <div className="from-airport-picker">
          <span className="form-label">Flying From</span>
          <SearchInput
            className='from-airport'
            defaultValue={fromAirport ? fromAirport.title : ''}
            handleSearch={this.searchAirports}
            mapResults={this.mapAirports}
            placeholder="City or Airport"
            reset={() => onChange({fromAirport: ''})}
            resultRenderer={this.resultRenderer}
            setSelection={(airport) => onChange({fromAirport: airport})} />
        </div>
        <div className="to-airport-picker">
          <span className="form-label">Flying To</span>
          <SearchInput
            className='to-airport'
            defaultValue={toAirport ? toAirport.title : ''}
            handleSearch={this.searchAirports}
            mapResults={this.mapAirports}
            placeholder="City or Airport"
            reset={() => onChange({toAirport: ''})}
            resultRenderer={this.resultRenderer}
            setSelection={(airport) => onChange({toAirport: airport})} />
        </div>
         <div className='outbound'>
          <span className="form-label">Departing</span>
          <div className='outbound-date-time'>
            <div className='date-picker'>
              <DayPicker
                date={outboundDate}
                onDateChange={date => this.setOutboundDate(date)}
              />
            </div>
            <div className='time-picker'>
                <DropDown
                  centered
                  leftIcon='icon-time'
                  value={outboundTimeSlotId}
                  onChange={(value) => onChange({outboundTimeSlotId: value})}
                  options={this.options}
                />
            </div>
          </div>
        </div>
          {!isRoundTrip ? null : (
          <div className='return'>
            <span className="form-label">Returning</span>
            <div className='return-date-time'>
              <div className='date-picker'>
                <DayPicker
                  date={returnDate}
                  disabled={!outboundDate}
                  isOutsideRange={(day) => this.isOutsideDateRange(day)}
                  onDateChange={date => onChange({returnDate: date})}
                />
              </div>
              <div className='time-picker'>
                  <DropDown
                    centered
                    leftIcon='icon-time'
                    value={returnTimeSlotId}
                    onChange={(value) => onChange({returnTimeSlotId: value})}
                    options={this.options}
                  />
              </div>
            </div>
          </div> ) }
        </div>
    );
  }
}

FlightPicker.propTypes = {
  /** Data for the origin airport */
  fromAirport: PropTypes.object,
  /** Moment object for day of outbound flight */
  outboundDate: PropTypes.object,
  /** User selection for timeslot of outbound flight */
  outboundTimeSlotId: PropTypes.number,
  /** Data for the destination airport */
  toAirport: PropTypes.object,
  /** Moment object for day of return flight */
  returnDate: PropTypes.object,
  /** User selection for timeslot of return flight */
  returnTimeSlotId: PropTypes.number,
  /** Toggle for whether we are searching round trip or one way */
  isRoundTrip: PropTypes.bool.isRequired,
  /** Handles all changes from the flightpicker */
  onChange: PropTypes.func.isRequired
};

export default FlightPicker;

Test File

import React from 'react';
import { shallow } from 'enzyme';
import { FlightPicker } from './index.js';
import moment from 'moment';

let wrapper;
const onChange = jest.fn();

describe('basic render Check', () => {
  beforeEach(() => {
    wrapper = shallow(<FlightPicker onChange={onChange} isRoundTrip={true} /> )
  });

  beforeEach(() => {
    wrapper.unmount();
  });

  test('it renders a flight picker div', () => {
    expect(wrapper.find('.flight-picker').length).toBe(1); // This passes which indicates the wrapper is able to render
  });
});


describe('isOutsideDateRange', () => {
  beforeEach(() => {
    const today = moment();
    wrapper = shallow(<FlightPicker onChange={onChange} isRoundTrip={true} outboundDate={today}/> )
  });

  beforeEach(() => {
    wrapper.unmount();
  });

  test('returns false if the argument is after the outboundDate prop', () => {
    const tomorrow = moment().add(1, 'days');
    const result = wrapper.instance().isOutsideDateRange(tomorrow); // This line throws TypeError: Cannot read property 'isOutsideDateRange' of null
    expect(result).toBe(false);
  })
});

bschmalz avatar Apr 04 '19 16:04 bschmalz

In this case, it seems like you're trying to unit-test the isOutsideDateRange method - can you do:

  test('returns false if the argument is after the outboundDate prop', () => {
    const tomorrow = moment().add(1, 'days');
    const result = FlightPicker.prototype.isOutsideDateRange.call({ props: { outboundDate: tomorrow } });
    expect(result).toBe(false);
  });

Alternatively, if you want to test the react render tree, I'd suggest this:

  test('returns false if the argument is after the outboundDate prop', () => {
    const tomorrow = moment().add(1, 'days');
    const result = wrapper.find(DayPicker).prop('isOutsideDateRange')(tomorrow);
    expect(result).toBe(false);
  });

The latter one means you're not coupling your tests to the implementation details of FlightPicker - instead, you're testing the thing you actually care about, which is that the callback passed to DayPicker behaves properly.

ljharb avatar Apr 04 '19 18:04 ljharb

Thanks for the prompt response. The latter approach seems like a good one, and it did work out for me with some slight syntax modifications.

I am still curious why wrapper.instance() is returning null for this component though, so if you have any insight on that I would greatly appreciate it.

bschmalz avatar Apr 04 '19 21:04 bschmalz

Again, it’s because the shallow wrapper isn’t a FlightPicker, it’s a div (specifically, the root div that FlightPicker renders), and html elements don’t have an instance.

Shallow wrappers are what the component renders - mount wrappers, on the other hand, are the component itself.

ljharb avatar Apr 05 '19 01:04 ljharb

I guess my point of confusion is that I'm using .instance() on shallow wrappers for other components with no issue, and when I do wrapper.debug() on those tests, I see that the wrapper is the div that the component renders. So, I have no idea why wrapper.instance() is working on shallow wrappers in some cases but not this one.

bschmalz avatar Apr 05 '19 18:04 bschmalz

Can you share the component and test code for one of the ones that works? Note that if the other ones have an HOC, then wrapper would be the wrapped component instead of the wrapper.

ljharb avatar Apr 05 '19 21:04 ljharb

Here's a simple example.

import React, {Component} from 'react';
import {Image} from 'semantic-ui-react';
import {fetchData} from '~/services/airlines';
import PropTypes from 'prop-types'

/**
 * Airline card for flight itineraries.
 */

export class Airline extends Component {
  static defaultProps = {
    width: 25
  }

  state = {}

  componentDidMount() {
    this.fetchAirline();
  }

  componentDidUpdate(prevProps) {
    if (prevProps.code !== this.props.code) {
      this.setState({airline: null});
      this.fetchAirline();
    }
  }

  fetchAirline() {
    const { code, width } = this.props;
    fetchData({"airlinecode": code, width})
      .then(airline => this.setState({airline}))
      .catch(() => console.log('error fetching airline'));
  }

  render() {
    const { showCode, showName } = this.props;
    const { airline } = this.state;
    if(airline)
      return (
        <div className="airline snippet">
          <Image src={airline.secondaryImage} title={airline.name} verticalAlign="bottom" inline/>
          {showCode && <span> {airline.code}</span>}
          {showName && <span> {airline.name}</span>}
        </div>
      );
    return null;
  }
}

Airline.propTypes = {
  /** Airline code used for logo lookup */
  code: PropTypes.string.isRequired,
  /** Determines whether or not we show the airline code */
  showCode: PropTypes.bool,
  /** Determines whether or not we show the airline name */
  showName: PropTypes.bool,
  /** Determines the width of the airline logo. */
  width: PropTypes.number
}

export default Airline;
import React from 'react';
import Airline from './index';
import { shallow } from 'enzyme';
import * as services from '~/services/airlines';

let oldFetchData, wrapper;

describe('fetchAirline', () => {
  beforeEach(() => {
    oldFetchData = services.fetchData;
    wrapper = shallow(<Airline code='AA' />);
    wrapper.setState({airline: 'originalAirline'});
    services.fetchData = jest.fn(() => Promise.resolve('test'));
  });

  afterEach(() => {
    services.fetchData = oldFetchData;
    wrapper.unmount();
  });

  test('fetchAirline calls fetchData with the expected arguments', () => {
    expect(services.fetchData.mock.calls.length).toBe(0);
    wrapper.instance().fetchAirline();
    expect(services.fetchData.mock.calls.length).toBe(1);
    expect(services.fetchData.mock.calls[0][0].airlinecode).toBe('AA');
    expect(services.fetchData.mock.calls[0][0].width).toBeTruthy();
  });

  test('fetchAirline sets state to the airline returned by fetchData', async () => {
    expect(wrapper.state('airline')).toBe('originalAirline');
    await wrapper.instance().fetchAirline();
    expect(wrapper.state('airline')).toBe('test');
  });
});

bschmalz avatar Apr 09 '19 16:04 bschmalz

and which test line doesn’t work?

ljharb avatar Apr 09 '19 17:04 ljharb

i see a problem (maybe not the one for this issue tho). When you call fetchAirline, that fires off a promise chain. Your test completes many thousands of ms before the setState is called.

You have to wait on the same promises that are inside fetchAirline - in this case, if you make it return the promise, then you can do:

import React from 'react';
import Airline from './index';
import { shallow } from 'enzyme';
import * as services from '~/services/airlines';

let oldFetchData, wrapper;

describe('fetchAirline', () => {
  beforeEach(() => {
    oldFetchData = services.fetchData;
    wrapper = shallow(<Airline code='AA' />);
    wrapper.setState({airline: 'originalAirline'});
    services.fetchData = jest.fn(() => Promise.resolve('test'));
  });

  afterEach(() => {
    services.fetchData = oldFetchData;
    wrapper.unmount();
  });

  test('fetchAirline calls fetchData with the expected arguments', async () => {
    expect(services.fetchData.mock.calls.length).toBe(0);
    await wrapper.instance().fetchAirline();
    expect(services.fetchData.mock.calls.length).toBe(1);
    expect(services.fetchData.mock.calls[0][0].airlinecode).toBe('AA');
    expect(services.fetchData.mock.calls[0][0].width).toBeTruthy();
  });

  test('fetchAirline sets state to the airline returned by fetchData', async () => {
    expect(wrapper.state('airline')).toBe('originalAirline');
    await wrapper.instance().fetchAirline();
    expect(wrapper.state('airline')).toBe('test');
  });
});

(note how the first one wasn’t awaiting; and your actual impl isn’t returning the promise, only the mock is)

ljharb avatar Apr 09 '19 17:04 ljharb

Please check out this codesandbox instance. I am getting null for an instance created by a class component. https://codesandbox.io/s/dreamy-tesla-3rktt I am getting null when calling instance() on it

export default class Tab extends React.Component {
  constructor(props) {
    super(props);
    this.myRef = createRef();
  }

  render() {
    return (
      <div className="tab_bar">
        <ul
          ref={this.myRef}
          className="tab__list direction--row"
          role="tablist"
        >
          <li>TEST</li>
        </ul>
      </div>
    );
  }
}

describe("Tab component", () => {
  it("should render", () => {
    const wrapper = mount(<Tab />);
    const ulElement = wrapper.find("ul");
    expect(ulElement.instance()).not.toBeNull();
    expect(wrapper.instance()).not.toBeNull();

  });
});

ddc22 avatar Oct 30 '19 05:10 ddc22

@jdc91 tests are passing for me in that sandbox. what should i be looking for?

ljharb avatar Oct 30 '19 06:10 ljharb

Sorry check again!

ddc22 avatar Oct 30 '19 06:10 ddc22

The ul shouldn't necessarily have an instance; but the fact that wrapper.instance() seems to throw is definitely a problem.

However, your sandbox is using the 16.3 adapter with React 16.8.

if you upgrade the adapter: https://codesandbox.io/s/enzyme-instance-wrvig?fontsize=14 or downgrade React: https://codesandbox.io/s/enzyme-instance-uxn18?fontsize=14

everything passes.

Remember, when npm ls exits nonzero - in any node project - your dep graph is invalid, and you must not rely on anything working.

ljharb avatar Oct 30 '19 06:10 ljharb

Thanks @ljharb :)

ddc22 avatar Oct 30 '19 06:10 ddc22

In case it helps anyone, I was experiencing a similar problem; in my case, the issue was triggered by updating react-redux to v6 or higher, and seems to have been resolved by adding an extra .dive() to shallow wrappers on problematic tests.

gwhobbs avatar Jan 19 '21 18:01 gwhobbs