Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide a way to trigger useEffect from tests #14050

Closed
ovidiuch opened this issue Oct 31, 2018 · 23 comments
Closed

Provide a way to trigger useEffect from tests #14050

ovidiuch opened this issue Oct 31, 2018 · 23 comments

Comments

@ovidiuch
Copy link

Hello,

I tried testing components that use the cool new hooks API, but useEffect doesn't seem to work with the test renderer.

Here's a small failling Jest test:

import React, { useEffect } from "react";
import { create as render } from "react-test-renderer";

it("calls effect", () => {
  return new Promise(resolve => {
    render(<EffectfulComponent effect={resolve} />);
  });
});

function EffectfulComponent({ effect }) {
  useEffect(effect);

  return null;
}

And here's a minimal reproducing repo: https://github.com/skidding/react-test-useeffect

Note that other use APIs seemed to work (eg. useContext).

@arianon
Copy link

arianon commented Oct 31, 2018

Try using useLayoutEffect like how currently the only Hook test executing on react-test-renderer is doing it

I think this is intentional behaviour, as far as I understand, useEffect executes after paint, but react-test-renderer never paints, because it doesn't use a DOM at all.

EDIT: typo'd useLayoutEffect as asLayouteffect 😄

@ovidiuch
Copy link
Author

ovidiuch commented Nov 1, 2018

Thanks @arianon. asLayoutEffect works. But I want to use useEffect.

The same issue exists for react-dom. Here's another test

import React, { useEffect } from "react";
import { render } from "react-dom";

it("calls effect", () => {
  const container = document.body.appendChild(document.createElement("div"));

  return new Promise(resolve => {
    render(<EffectfulComponent effect={resolve} />, container);
  });
});

function EffectfulComponent({ effect }) {
  useEffect(effect);

  return null;
}

Maybe the "passive" (post-paint) hooks don't work inside JSDOM?

@gaearon
Copy link
Collaborator

gaearon commented Nov 2, 2018

We should have something to trigger them.

@gaearon gaearon changed the title Bug: react-test-renderer doesn't call useEffect callback Provide a way to trigger useEffect from tests Nov 2, 2018
@ovidiuch
Copy link
Author

ovidiuch commented Nov 2, 2018

Thanks for updating the issue.

What about the fact that useEffect isn't triggered also when I render using react-dom? Or should I use a different API for rendering?

@erikras
Copy link

erikras commented Nov 4, 2018

Heh, I haven't been cut by the bleeding edge for years, but this one got me.

@skidding My guess is that we'll just have to wait.

@alexkrolick
Copy link

alexkrolick commented Nov 5, 2018

Working on a workaround for react-testing-library (which uses ReactDOM) here: testing-library/react-testing-library#216

It seems like you can trigger the effects by either rerendering the element in place, or rendering another root somewhere else in the document (even a detached node).

I am not sure why the hooks don't called in the first place though, since requestAnimationFrame/requestIdleCallback are both available in the Jest/JSDOM environment.

@flucivja
Copy link

TIP: until it will be fixed in library I fixed my tests by mocking useEffect to return useLayoutEffect just in tests.

I have own useEffect module where is just

// file app/hooks/useEffect.js
export {useEffect} from 'react';

then in my component I use

// file app/component/Component.js
import {useEffect} from '../hooks/useEffect';
...

and then in the component test I mock it like following

// file app/component/Component.test.js 
jest.mock('../hooks/useEffect', () => {
    return { useEffect: require('react').useLayoutEffect };
});
...

@danielkcz
Copy link

danielkcz commented Nov 13, 2018

I think you don't need such a hassle for this. Jest would automock if you create a file <root>/__mocks__/react.js and in there you can just...

const React = require.actual('react')
module.exports = { ...React, useEffect: React.useLayoutEffect }

This is a great workaround as you don't need to touch any code when this is somehow fixed, you will just remove the mock.

@TrySound
Copy link
Contributor

@gaearon useEffect is triggered after state change. Why it's not true for initial render?

@gaearon
Copy link
Collaborator

gaearon commented Nov 30, 2018

It's triggered for initial render after the browser is idle.

The only reason next updates trigger it is because we flush passive effects before committing the next render. This is important to avoid inconsistencies.

So as a result for every Nth render, you'll see N-1th passive effects flushed.

We'll likely offer a way to flush them on demand too.

@blainekasten
Copy link
Contributor

You can manually run tree.update() and effect hooks will be ran. Example:

const Comp = () => {
  useEffect(() => console.log('effect'));
  return null;
}

const tree = renderer.create(<Comp />); // nothing logged
tree.update(); // console.log => 'effect'

@malbernaz
Copy link

malbernaz commented Feb 11, 2019

I don't know if that's the best path but mocking both react and react-test-renderer like below solved my problems:

// <Root>/__mocks__/react.js
let React = require("react");

module.exports = {
  ...React,
  useState: initialState => {
    let [state, setState] = React.useState(initialState);
    return [
      state,
      update => {
        require("react-test-renderer").act(() => {
          setState(update);
        });
      }
    ];
  }
};

// <Root>/__mocks__/react-test-renderer.js
let TestRenderer = require("react-test-renderer");

module.exports = {
  ...TestRenderer,
  create: next => {
    let ctx;

    TestRenderer.act(() => {
      ctx = TestRenderer.create(next);
    });

    return ctx;
  }
};

@threepointone
Copy link
Contributor

I wrote a longish doc on how to use .act() to flush effects and batch user and api interactions with React. https://github.com/threepointone/react-act-examples

@malbernaz
Copy link

malbernaz commented Feb 17, 2019

@threepointone, awesome documentation! Do you see any problem in wrapping setState with act like I've demonstrated in the example above?

@threepointone
Copy link
Contributor

@malbernaz I would NOT recommend wrapping every setState like you did. The Act warning is useful for surfacing bugs, and your hack is equivalent to silencing it.

@damiangreen
Copy link

damiangreen commented Mar 25, 2019

@blainekasten your approach didn't work because the update function requires a param.
@FredyC's approach didn't work for me - I received TypeError: require.actual is not a function
I did get it working with act()

import * as React from 'react'
import { act, create } from 'react-test-renderer'
import NoSSR from './NoSSR'

describe('NoSSR', () => {
  it('renders correctly', () => {
    let wrapper: any
    act(() => {
      wrapper = create(<NoSSR>something</NoSSR>)
    })

    const x = wrapper.toJSON()
    expect(x).toMatchSnapshot()
  })
})

@threepointone
Copy link
Contributor

Just to clarify and bookend this, the recommend solution is to use act() in tests to flush effects and updates. We also just released an alpha that includes an async version of act() for situations that involve promises and the like. If there are no objections, I'll close this issue soon.

@ovidiuch
Copy link
Author

ovidiuch commented Apr 5, 2019

@threepointone Thanks for taking care of this, I appreciate your efforts in building and communicating this API!

You can probably close it, but I'll try to share my experience while we're at it since I was the one who reported this issue in the first place.

I first tried Hooks in October, but because I couldn't write tests for components using them I hit a wall and returned to using classes for the time being.

About a month ago, when the new act API became available I resumed my efforts and managed to properly test my components, so I'm all hooked now!

But while I did manage to make my tests work, there was a bit of trial and error involved and I'm not sure I'm calling act in the right places or calling it more times than necessary.

The main scenario I'm unsure about is this: Sure, I wrap any events I trigger in my test in React.act, but what if those events interact with my component asynchronously?

I can illustrate this with a window.postMessage example:

import React from 'react';

function YayOrNay() {
  const [yay, setYay] = React.useState(false);

  React.useEffect(() => {
    function onMessage() {
      setYay(true);
    }
    window.addEventListener('message', onMessage);
    return () => window.removeEventListener('message', onMessage);
  });

  return yay ? 'yay' : 'nay';
}

And here's my attempt to test this:

import ReactTestRenderer from 'react-test-renderer';
import retry from '@skidding/async-retry';

it('should yay, async () => {
  // I avoid this kind of hoisting in my tests but let's ignore for now
  let renderer;

  // act() isn't really useful here, because the message handler isn't called until the
  // next event loop
  ReactTestRenderer.act(() => {
    renderer = ReactTestRenderer.create(<YayOrNay />);
    window.postMessage({}, '*');
  });

  await retry(() => {
    expect(renderer.toJSON()).toEqual('yay');
  });
});

This test passes but I get the not-wrapped-in-act-update warning

An update the YayOrNay inside a test was not wrapped in act(...).

The odd part is that in my codebase, which has more convoluted tests that are not practical to share, the situation is different:

  • I don't get this warnings anymore
  • Updates don't apply unless I call act() either
    1. In the next loop with an empty body
    parent.postMessage(msg, '*');
    setTimeout(() => { act(() => {}); });
    1. Every time before asserting, inside retry blocks

Any guidance would be greatly appreciated!

@threepointone
Copy link
Contributor

@skidding async act (in 16.9.0-apha.0) will be your friend here. I could be certain if you had a git repo where I could mess with this example, but I think this should solve your problem

  await ReactTestRenderer.act(async () => {
    renderer = ReactTestRenderer.create(<YayOrNay />);
    window.postMessage({}, '*');
  });

I'm not sure what retry does, but the broad principle applies - wrap blocks where the update could occur with await act(async () => ...) and the warnings should disappear. so -

await ReactTestRenderer.act(async () => {
  parent.postMessage(msg, '*');
})

should work. feel free to reach out if it doesn't.

I'll close this once we ship 16.9.0.

@ovidiuch
Copy link
Author

ovidiuch commented Apr 5, 2019

Ohh, so ReactTestRenderer.act will wait until updates are scheduled even if I don't explicitly await inside the callback? That would be sweet but it also raises some questions, like does it wait until at least one update has been scheduled? If the question doesn't make sense feel free to ignore. I'll come back with a repro repo if problems persist.

16.9.0-apha.0 sounds great, I'll give it a try!

retry just calls the callback until it doesn't throw -- something I find quite useful in async tests, because once the callback "passes" or times out you get the rich output from the assertion library used inside

@threepointone
Copy link
Contributor

threepointone commented Apr 5, 2019

does it wait until at least one update has been scheduled?

No. it has to be at least within the next 'tick', ie a macrotask on the js task queue, as soon as act() exits (Or within the act() callback scope itself). I hope my explainer doc makes this clearer next week.

(Bonus: it will also recursively flush effects/updates until the queue is empty, so you don't miss any hanging effects/updates)

@threepointone
Copy link
Contributor

16.9 got released, including async act, and updated documentation https://reactjs.org/blog/2019/08/08/react-v16.9.0.html Closing this, cheers.

@Sharcoux
Copy link

Sharcoux commented Apr 25, 2020

@threepointone I am trying to understand how async act fixes it. Here is an example of component I want to test. It should listen to size changes of the screen:

const withScreenSize = Comp => ({ ...props}) => {
  const [ size, setSize ] = React.useState(Dimensions.get('window'))
  React.useEffect(() => {
    const sizeListener = () => {
      const { width, height } = Dimensions.get('window')
      setSize({ width, height })
    }
    Dimensions.addEventListener('change', sizeListener)
    return () => Dimensions.removeEventListener('change', sizeListener)
  }, [setSize])
return <Comp screenSize={size} {...props} />

To test it, I mock the Dimensions object (in React Native):

    // Mock Dimensions object to emulate a resize
    const listeners = []
    const oldDimensions = {...Dimensions}
    Dimensions.addEventListener = (type, listener) => type==='change' && listeners.push(listener)
    Dimensions.removeEventListener = (type, listener) => type==='change' && listeners.splice(listeners.indexOf(listener), 1)
    Dimensions.get = () => ({ width, height })

Now I am trying to test the following:

  • The resizeListener is correctly added on mount
  • The resizeListener is correctly removed on unmount
  • The new screen size is taken into account and the component's state is updated

This is how I will emulate the resize:

function resizeScreen() {
      Dimensions.get = () => ({ width: 200, height: 200 })
      listeners.forEach(listener => listener())
}

While trying, I encountered so many weird errors that I don't understand...

      const wrapper = TestRenderer.create(<Comp />);
      resizeScreen()
      wrapper.update()
      const updatedView = wrapper.root.findByType('View');
      // When using update with empty parameter, I get `Can't access .root on unmounted test renderer`. Though it is what appears in the code of @blainekasten above: tree.update()
      await act(async () => {
          const wrapper = TestRenderer.create(<Comp />);
          const view = wrapper.root.findByType('View');
          // Here I get: "Can't access .root on unmounted test renderer" directly on the line above.

In the end, this is how I got it to work:

      const wrapper = TestRenderer.create(<Comp />);
      const view = wrapper.root.findByType('View');
      await act(async () => {})
      resizeScreen()

Is this how I am supposed to do it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests