React component does not re-render under Jest on state change
Asked Answered
E

4

6

Component:

const MyComponent = props => {
  const {price} = props;
  const result1 = useResult(price);

  return (
    <div>...</div>
  )
}     

Custom Hook:

export const useResult = (price) => {
  const [result, setResult] = useState([]);

  useEffect(() => {
    const data = [{price: price}]
    setResult(data);        
  }, [price]);

  return result;
};

Jest test:

  it('should ...', async () => {
    render(
        <MyComponent price={300}/>)
    )
    await waitFor(() => {
      expect(...).toBeInTheDocument();
    });
  });

What it does happen with the above code is that MyComponent, when running the test, renders only once instead of two (when the application runs). After the initial render where result1 is an empty array, useEffect of useResult is running and since there is a state change due to setResult(data), I should expect MyComponent to be re-rendered. However, that's not the case and result1 still equals to [] whereas it should equal to [{price:300}].

Hence, it seems custom hooks under testing behave differently than the real app. I thought it would be okay to test them indirectly through the component that calls them.

Any explanation/thoughts for the above?

UPDATE

The issue that invoked the above erroneous behaviour was state mutation!! It worked with the app but not with the test! My mistake was to attempt to use push in order to add an element to an array that was a state variable...

Expositor answered 8/11, 2022 at 19:10 Comment(10)
const data = ... //we build an array somehow - is this a synchronous operation?Curlpaper
Yes. It is a synchronous one.Expositor
Tests are synchronous, React state updates are not synchronously processed. The test needs to wait for the component to rerender with any updated UI you are trying to assert on.Elver
Okay. However, what's the answer to my question? When debugging the test, there is no re-rendering of the component. Why?Expositor
What's the goal of your test from a user's point of view? The user won't really care how many times it re-renders. I'd suggest filling out both the code and test a bit more to give us more insights into what you're actually trying to test.Munda
I copy your example and run it and I can't reproduce your issue. I can test the hook without any problem if I use waitFor or findBy. Maybe can you share more of your code?Maryettamaryjane
The component does not re-render after state change. Even if waiting till next century, I won't get what I expect. So, do not advise using waitFor. I cannot be more clear...Expositor
Perhaps we can't be more clear that the test needs to wait for the component to rerender. That's a simple fact for testing asynchronous updates. I suspect the component isn't updating state (or whatever it is it's updating) to correctly trigger a rerender. Can you edit the post to include a more complete and comprehensive minimal reproducible example?Elver
Hi man! Is there any update on that? Faced with the same issueCentigram
@Centigram see the UPDATE at the bottom of the question. If that's not helpful raise a question with your code and let me know.Expositor
T
3

Well, it seems that you are asking a very specific thing about testing a custom hook. In that case, I also had some issues in the past testing custom hooks through @testing-library and a different package was created (and recently incorporated into the @testing-library) that provides the renderHook() function for testing custom hooks. I suggest you to test that.

You can read more about it in this blog post from Kent C. Dodds.

I also suggest you create a "state change" to test your component and test the hook with the renderHook().

Here is a simple codesandbox with some tests for a component similar to your case.

Original Answer

Essentially, your test is not waiting for the component to perform the side effects. There are 2 ways of waiting for that:

  • Using waitFor()
import { waitFor, screen } from '@testing-library/react'

// ...
  // add the `async` before the callback function
  it('should ...', async () => {
    render(<MyComponent price={300}/>);
    
    await waitFor(() =>
      expect(screen.getByText('your-text-goes-here')).toBeInTheDocument()
    )
  });
import { screen } from '@testing-library/react'

// ...
  // add the `async` before the callback function
  it('should ...', async () => {
    render(<MyComponent price={300}/>);

    expect(await screen.findByText('your-text-goes-here')).toBeInTheDocument();
  });
Tavares answered 11/11, 2022 at 16:26 Comment(0)
E
2

Step 1: the code being tested

If, as mentioned in the comments of the question, the operation inside the effect is synchronous, then using useEffect for setting this state based on the props is undesirable in all cases. Not only for testing.

The component will render, update the DOM and immediately need to re render the following frame because it's state was updated. It causes a flash effect for the user and needlessly slows the app down.

If the operation is cheap, it's way more efficient to just execute it on every render.

If the operation can be more expensive, you can wrap it in useMemo to ensure it only happens when there's changes to the inputs.

export const useResult = (price) => {
  return useMemo(
    // I assume this is a stub for a expensive operation.
    () => [{price: price}],
    [price]
  );
};

If, for some obscure reason, you do need to do this in an effect anyway (you probably don't but there's edge cases), you can use a layoutEffect instead. It will be processed synchronously and avoid the flashing frame. Still wouldn't recommend it but it's a slight improvement over a regular effect.

Step 2: Testing

If you changed the component to not use an effect, it should now be correct from the first render, and you don't have the problem anymore. Avoiding having a problem in the first place is also a valid solution :D

If you do find the need to flush something synchronously in a test, there's now the flushSync function which does just that.

Perhaps it would also flush the state update in the effect, causing your test to work with no other changes. I guess it should, as new updates triggered by effects while flushing should continue to be processed before returning.

flushSync(() => {
  render(
    <MyComponent price={300}/>)
  )
})

In any case there's no point doing this if you can instead improve the component to fix the additional render introduced by setting state in an effect.

Emery answered 12/11, 2022 at 18:35 Comment(0)
M
-1

you can do:

The test will have to be async: it('should ...',  async() => { ....

await screen.findByText('whatever');
This is async so it will wait to find whatever and fail if it can't find it

or you can do
await waitFor (() => {
   const whatever = screen.getByText('whatever');
   expect(whatever).toBeInTheDocument();
})
Maryettamaryjane answered 10/11, 2022 at 19:32 Comment(0)
S
-1

You are not waiting for the component to be rerendered

import { waitFor, screen } from 'testing-library/react'

it('should ...',  async () => {
    render(
        <MyComponent price={300}/>)
    )
    
    await waitFor (() => {
        // check that props.price is shown
        screen.debug() // check what's renderered
        expect(screen.getByText(300)).toBeInTheDocument();
    });
  });
Sollie answered 11/11, 2022 at 15:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.