enzyme icon indicating copy to clipboard operation
enzyme copied to clipboard

update() doesn't work for component conditional rendering (based on state)

Open gilneto8 opened this issue 6 years ago • 31 comments

Current behavior

I'm having a problem regarding a mounted component with conditional rendering based on state changes. Changing the state inside the test updates the state, but not what is rendered, even after invoking wrapper.update() and wrapper.instance().forceUpdate().

Expected behavior

Changing the state inside the test context, and invoking one of (if not both) the wrapper.update() or the wrapper.instance().forceUpdate() should update the component and show the new rendering configuration, since it is based on the current state.

Your environment

API

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

Version

library version
enzyme ^3.7.0
react ^16.6.1
react-dom ^16.6.1
adapter (below) ^1.6.0

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 ( )

gilneto8 avatar Jan 12 '19 15:01 gilneto8

Can you provide a code example that demonstrates the problem? Also, what does calling wrapper.debug() output?

minznerjosh avatar Jan 12 '19 20:01 minznerjosh

My speculation is that getNodesInternal in ReactWrapper.js isnt calling update on a state change, like Shallow is currently. A code snippet would help.

sstern6 avatar Jan 15 '19 01:01 sstern6

Sorry for the late reply.

Here's the test itself:

test('delete an account', async () => {
      await mounted.setState({
        activeAccount: {
          id: 'test',
        },
      });
      await mounted.instance().forceUpdate();
      mounted.update();

      expect(mounted.instance().state.activeAccount).toEqual({ id: 'test' });
      const spy = jest.spyOn(mounted.instance(), 'deleteAccount');

      const button = findByAttr(mounted, 'delete-button').first();
      button.simulate('click', mockEvent);
      expect(spy).toHaveBeenCalledTimes(1);
});

And here's the referenced part on the code itself:

{this.state.activeAccount ? (
          <Button
                    enzyme-attr="delete-button"
                    className={`${styles.button} ${styles.delete}`}
                    onClick={() => this.deleteAccount()}
         >
         {'Delete'}
         </Button>
) : null
}

The findByAttr function is something I implemented to ease the search of an element on the DOM. It searches for the attribute enzyme-attr.

Hope this helps.

gilneto8 avatar Jan 15 '19 11:01 gilneto8

thanks for the code snippet, per @minznerjosh rec, does wrapper.debug() output null? Ill have some time to look into this this week.

sstern6 avatar Jan 15 '19 18:01 sstern6

@sstern6 no, wrapper.debug() (or, in my case, mounted.debug()) returns the component rendered properly, but only for the initial state. After the state update, the component remains the same (without properly rendering the conditional part).

gilneto8 avatar Jan 15 '19 18:01 gilneto8

One thing that immediately jumps out to me is your use of async/await. The enzyme setState method does not return a Promise, so your use of await is effectively a noop. Try passing a callback as the second argument to setState instead.

minznerjosh avatar Jan 15 '19 18:01 minznerjosh

@minznerjosh I know, that part of the code came from one of my desperate iterations 😛. I also tried having the remaining code as part of the callback for setState, but that, too, didn't work...

gilneto8 avatar Jan 15 '19 18:01 gilneto8

@gilneto8 could you try it with shallow to see if it works? Also, findAttr is using enzymes .find or is this a custom built thing?

sstern6 avatar Jan 15 '19 18:01 sstern6

@sstern6 this is the findByAttr implementation:

export const findByAttr = (wrapper, val) => wrapper.find(`[enzyme-attr='${val}']`);

I don't think shallow is usable here, because the component I'm testing is wrapped with a reducer, and on our architecture I still haven't found a way to export said component without that wrapper. Nevertheless, I'll try to find out a way. Thanks for the suggestion.

gilneto8 avatar Jan 16 '19 11:01 gilneto8

I'm experiencing this same issue. Mounted component that I'm calling setState on, as well as update and the mount never updates child component props that reference state. I'm currently unable to use shallow for this particular test case. debug returns the same component structure before and after calling setState then update.

twdrake-ap avatar Jan 16 '19 15:01 twdrake-ap

thanks for the report, everyone.

sstern6 avatar Jan 16 '19 18:01 sstern6

@ljharb should this be a bug or an enhancement? Is wrapper.update() for mount "supposed" to propagate the change to children. The docs dont state that it will update the children of the wrapped component. This is related to another ticket im looking into so I dont mind looking into it. What do you think?

Thanks

sstern6 avatar Jan 20 '19 23:01 sstern6

I would expect that after wrapper.update(), the entire tree would rerender based on the new props/state/etc - so the children should be updated.

ljharb avatar Jan 21 '19 00:01 ljharb

If somebody was able to open up a PR with a failing testcase for this issue, that’d be amazing.

minznerjosh avatar Jan 21 '19 00:01 minznerjosh

@minznerjosh ill do that and look into the issue as a whole.

sstern6 avatar Jan 21 '19 03:01 sstern6

From what i can tell its working as expected locally when writing the tests so decided not to push. Thinking the issue might be something else with the way the tests are written.

@twdrake-ap can you provide a code sample as well as the version of enzyme youre using?

Thanks

sstern6 avatar Jan 22 '19 02:01 sstern6

cc @minznerjosh

sstern6 avatar Jan 30 '19 01:01 sstern6

I think the issue here might be another case of not using root instances.

I had something I had something like this (simplified):

const wrapper = mount(
    <MemoryRouter initialEntries={[{ pathname: "/", state: <some state> }]}>
        <Route path="/" component={Page} />
    </MemoryRouter>
).find(Page);

though you could simplify it further to a less meaningful example with React fragments (or anything really):

const wrapper = mount(
    <>
        <Page />
    </>
).find(Page);

I then was using wrapper.update(). Enzyme fails silently.

I think this use case is not supported unfortunately (at least in v3 anyway), but no error is thrown to indicate that. Maybe that's intentional and the reason isn't obvious to me.

Bo98 avatar Mar 09 '19 12:03 Bo98

@Bo98 that's a fair point; perhaps update should throw when it's not called on a root wrapper - however, i'm not sure how many working tests that would break :-/

ljharb avatar Mar 09 '19 21:03 ljharb

Yeah, that's a risk.

Maybe the only way would some sort of non-fatal warning. It would be better than nothing IMO until the opportunity arises that it can be raised to a fatal error.

Bo98 avatar Mar 09 '19 21:03 Bo98

I'm having this same problem.

Can find <CardBody />, can't find conditionally rendered child button, even when state preconditions are met. Removing state precondition causes test to pass.

Adding update() or .forceUpdate() doesn't help.

"react": "16.8.6" "enzyme": "3.10.0" "enzyme-adapter-react-16": "1.14.0"

Test:

    it('displays upload button on Card if file.status === Needs Input', () => {
      const buttons = shallow(
        <FileAccordion
          files={[
            {
              originalFileData: {
                path: 'Myfile.mp3',
                fileStatus: 'Needs Input'
              }
            }
          ]}
        />
      )
        .find(Accordion)
        .find(Card)
        .find(Accordion.Collapse)
        .find(Card.Body)
        .find('button')

      expect(buttons.length).toBe(1)
      expect(buttons.at('0').text()).toBe('Start Upload')
    })

Component:

// ...

<Card.Body>
  {file.status === 'Needs Input' && (<button
    onClick={(e) => {
      // ...
    }}
  >
    Start Upload
  </button>)}
</Card.Body>

// ...

JeremyEllingham avatar Jul 31 '19 10:07 JeremyEllingham

Hi @ljharb

I am facing this issue too. Conditional rendering is not working correctly, cannot find the correct node after doing a simple setProps.

"react": "^16.8.4" "enzyme": "^3.10.0" "enzyme-adapter-react-16": "^1.14.0"

nishantnaagnihotri avatar Aug 12 '19 11:08 nishantnaagnihotri

I'm also facing this. I'm using a pattern for this component of mount -> setImmediate because there are fetch calls (mocked for the tests) which happen in componentDidMount. What I notice is, for example, if I have the following div in my component:

<div className="breadcrumb-div">
  <span>
    <Link to="/app/apps/list">Connected Apps</Link>
  </span>
  {this.state.data_loaded && (
    <span>
      <span className="slash-span">/</span>
      <Link
        to={`/app/apps/listing/${this.props.match.params.integrationId}`}
      >
        {this.state.app_info!.listing.name}
      </Link>
      <span className="slash-span">/</span>
      <span>{this.state.external_resource_name}</span>
    </span>
  )}
</div>

And inside the setImmediate block of the test, I can run some logging and assertions:

const breadcrumbDiv = wrapper.find(".breadcrumb-div");
console.log(breadcrumbDiv.html());
expect(breadcrumbDiv.find('a[href="/app/apps/list"]').text()).toEqual(
  "Connected Apps"
);
expect(
  breadcrumbDiv.find('a[href="/app/apps/listing/123"]').text()
).toEqual("Typeform");

Which outputs the following:

  console.log src/components/mappings/Mappings.test.tsx:122
    <div class="breadcrumb-div"><span><a href="/app/apps/list">Connected Apps</a></span><span><span class="slash-span">/</span><a href="/app/apps/listing/123">Typeform</a><span class="slash-span">/</span><span>My Form</span></span></div>

  ● <Mappings /> › renders the breadcrumb correctly

    Method “text” is meant to be run on 1 node. 0 found instead.

      126 | // FIXME: Fails due to bug in enzyme: https://github.com/airbnb/enzyme/issues/1976
      127 | expect(
    > 128 |   breadcrumbDiv.find('a[href="/app/apps/listing/123"]').text()
          |                                                         ^
      129 | ).toEqual("Typeform");
      130 |       done();
      131 |     });

      at ReactWrapper.single (node_modules/enzyme/src/ReactWrapper.js:1166:13)
      at ReactWrapper.text (node_modules/enzyme/src/ReactWrapper.js:629:12)
      at Immediate.text (src/components/mappings/Mappings.test.tsx:128:57)

So html is correct, and the first assertion (the link outside the conditional) works, but enzyme fails to find any items inside the conditional. Methods like children also don't show anything inside the conditional.

shssoichiro avatar Sep 11 '19 13:09 shssoichiro

From communications with my coworkers who are doing similar testing, we believe this may be attributed to updates to components that are not the root component. For example, in the test above, I have this as the mounted component:

const wrapper = mount(
  <MemoryRouter
    initialEntries={[
      "/app/apps/listing/123/mappings?connection_id=test-connection&external_resource_id=asdfg"
    ]}
  >
    <Route
      exact
      path="/app/apps/listing/:integrationId/mappings"
      component={Mappings}
    />
  </MemoryRouter>
);

My colleagues are reporting that they do not experience this issue when mounting their in-test component directly.

shssoichiro avatar Sep 13 '19 14:09 shssoichiro

@shssoichiro try using the wrappingComponent option for the MemoryRouter, instead of directly wrapping the Route with it.

ljharb avatar Sep 13 '19 15:09 ljharb

I haven't faced the exact same issue, but similar ones. The problem in those cases has been the use of const instead of let on the wrapper I'm trying to update. Worth a try :)

oskarTeodor avatar Oct 28 '19 07:10 oskarTeodor

the setState() in enzyme is sync method, you don't need update;just run it ;then you can get the new state and new render structure

zhangxiangqiang avatar Jan 09 '20 09:01 zhangxiangqiang

this is a regular issue for me, i have heavily nested components (react-intl, styled-components-theme, redux providers) and so i will never get to a working state of a component without its wrappers. but i would love to see if my conditional rendered parts are working as expected in a test.

Lelith avatar Apr 06 '20 11:04 Lelith

@Lelith I had a similar issue using redux and trying to force a render of conditional child components. My fix was to export my class component in addition to my connected component, like so:

// MyComponent.js
export class MyComponent extends Component {
...
}

export default connect(mapStateToProps, mapDispatchToProps)(MyComponent)

In my component, I'm exporting both the decorated (connected) component and the undecorated component for testing.

// MyComponent.test.js
import ConnectedMyComponent, { MyComponent } from './MyComponent'

describe('MyComponent', () => {
	it('MyComponent conditional components are rendered', () => {
		let wrapper = mount(<MyComponent {...props} />)

                wrapper.setState({})
                wrapper.update()

		expect(wrapper.find('.whatever-youre-looking-for')).toHaveLength(1)
	})
        ...

Therefore I can wrap the connected component in a <Provider> while testing with redux and a mockStore, or just isolate what I need for passing props to my unconnected component and get the conditionally rendered child components.

Hope I'm not completely off-base and that this helps.

escodel avatar Apr 18 '20 22:04 escodel

@escodel thank you for your reply :) it helps at least some of the components when i do the double export. then i can use shallow render method instead of mount / mount with providers. So i can at least add now tests to some of the parts.

What still bites me in the back is if i have either a styled component defintion inside one of them (because they need the theme provider to function) or using the react-intl api directly (also needs the provider). I guess my main goal will be to refactor those.

Lelith avatar Apr 21 '20 06:04 Lelith