react-native-testing-library icon indicating copy to clipboard operation
react-native-testing-library copied to clipboard

Error thrown - Warning: You called act(async () => ...) without await.

Open jpmonette opened this issue 4 years ago • 107 comments

Ask your Question

I have a simple test that seems to generate the right snapshot at the end of execution, but throws me a console.error during the execution.

Here are the steps to get to the expected component state:

  1. Load the component
  2. Wait for the useEffect asynchronous logic to be executed so that the SegmentedControlIOS is exposed (testID = recordTypePicker)
  3. Set the selectedSegmentIndex to "1"
  4. Wait for the component to re-render and all the async logic to be executed
  5. Assert that the rendered component includes newSObjectLayout testID

The test

import * as React from 'react';
import { fireEvent, render, waitFor } from 'react-native-testing-library';
import { NewSObjectContainer } from '../NewSObject';

describe('NewSObjectContainer', () => {
  const setup = () => {
    const route = { params: { sobject: 'Account' } };
    const navigation = { setOptions: jest.fn() };

    const container = render(<NewSObjectContainer route={route} navigation={navigation} />);
    return { container };
  };

  it('should render a NewSObjectContainer - with page layout exposed', async () => {
    const { container } = setup();

    await waitFor(() => expect(container.getByTestId('recordTypePicker')).toBeTruthy());

    const input = container.getByTestId('recordTypePicker');
    fireEvent(input, 'onChange', { nativeEvent: { selectedSegmentIndex: 1 } });

    await waitFor(() => expect(container.getByTestId('newSObjectLayout')).toBeTruthy());

    expect(container.toJSON()).toMatchSnapshot();
  });
});

The console log

./node_modules/.bin/jest src/containers/__tests__/NewSObject.spec.tsx --u
  console.error
    Warning: You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);

      at CustomConsole.console.error (node_modules/react-native/Libraries/YellowBox/YellowBox.js:63:9)
      at printWarning (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:120:30)
      at error (node_modules/react-test-renderer/cjs/react-test-renderer.development.js:92:5)
      at node_modules/react-test-renderer/cjs/react-test-renderer.development.js:14953:13
      at tryCallOne (node_modules/promise/lib/core.js:37:12)
      at node_modules/promise/lib/core.js:123:15
      at flush (node_modules/asap/raw.js:50:29)

 PASS  src/containers/__tests__/NewSObject.spec.tsx
  NewSObjectContainer
    ✓ should render a NewSObjectContainer - with page layout exposed (499ms)

 › 1 snapshot written.
Snapshot Summary
 › 1 snapshot written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       q passed, q total
Snapshots:   1 written, 1 passed, q total
Time:        4.865s, estimated 8s
Ran all test suites matching /src\/containers\/__tests__\/NewSObject.spec.tsx/i.

jpmonette avatar Jun 09 '20 10:06 jpmonette

This particular line looks a bit odd to me:

await waitFor(() => expect(container.getByTestId('recordTypePicker')).toBeTruthy())

I don't thing we support putting jest's expect into waitFor.

Could you try something like:

const element = await findByTestId('record...') // This is essentially waitFor & getByTestId
expect(element).toBeTruthy()

Let me know if you still got the same error.

mdjastrzebski avatar Jun 09 '20 13:06 mdjastrzebski

@mdjastrzebski I did update my code and still get the issue. Here's the updated test:

import * as React from 'react';
import { fireEvent, render } from 'react-native-testing-library';
import { NewSObjectContainer } from '../NewSObject';

describe('NewSObjectContainer', () => {
  const setup = () => {
    const route = { params: { sobject: 'Account' } };
    const navigation = { setOptions: jest.fn() };

    const container = render(<NewSObjectContainer route={route} navigation={navigation} />);
    return { container };
  };

  it('should render a NewSObjectContainer - with Record Type picker exposed', async () => {
    const { container } = setup();

    const input = await container.findByTestId('recordTypePicker');
    expect(input).toBeTruthy();

    expect(container.toJSON()).toMatchSnapshot();
  });

  it('should render a NewSObjectContainer - with page layout exposed', async () => {
    const { container } = setup();

    const input = await container.findByTestId('recordTypePicker');
    expect(input).toBeTruthy();

    fireEvent(input, 'onChange', { nativeEvent: { selectedSegmentIndex: 1 } });

    const layout = await container.findByTestId('newSObjectLayout');
    expect(layout).toBeTruthy();

    expect(container.toJSON()).toMatchSnapshot();
  });
});

jpmonette avatar Jun 09 '20 14:06 jpmonette

@jpmonette can you provide a repro repo?

mdjastrzebski avatar Jun 09 '20 18:06 mdjastrzebski

@mdjastrzebski I ran into a similar issue (although, don't know if it is exactly the same problem) and made a mcve here: https://github.com/jsamr/react-native-testing-library-379

jsamr avatar Aug 03 '20 17:08 jsamr

I also ran into same problem after upgrading to v7.0.1. Also, if you just run the react-navigation example in the repo itself you will see this problem.

pillu00 avatar Aug 03 '20 18:08 pillu00

@jsamr thanks for posting repo, I'll try to reproduce that tomorrow.

mdjastrzebski avatar Aug 03 '20 18:08 mdjastrzebski

@jsamr You should wrap the reload() call in act like this:

    act(() => {
      result.instance.reload();
    });

As each state mutation should be wrapped in act. In case of fireEvent this is done for you.

This leaves your code with following warning logged to console

Warning: You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);

which is simingly caused by await findByText('load-cycle-1');

mdjastrzebski avatar Aug 04 '20 08:08 mdjastrzebski

@mdjastrzebski , I ran reactnavigation example in this repo and still getting the above error. But when i downgrade to v6.0.0, it goes away. Can you please help me what's going wrong ?

pillu00 avatar Aug 04 '20 09:08 pillu00

I'm facing the same error but my test is different. Before moving from version 2 to 7 my test was the same and I have no errors, but after updating to v7 i've been getting the same error.

I have something really simple like this:

 const { getByText, update } = render(<Component />);

 fireEvent(getByText('idx-1'), 'onPress');

 await act(async () => {
    update(<Component />);
 });

I use act because when I fire the event I have some async logic in my component that will re-render it. If i don't use the last code I've got the next error:

 Warning: An update to ForwardRef(NavigationContainer) inside a test was not wrapped in act(...).
      
      When testing, code that causes React state updates should be wrapped into act(...):
      
      act(() => {
        /* fire events that update state */
      });
      /* assert on the output */

UPDATE: 11-08-2020 I changed (cleaned) my tests. I don't have to use anymore this awful thing I was doing adding await to all the act functions I was using and It worked fine. BUT, this is not always for all the cases, so you must be careful how is written your async code and then how is written your test... testing this async code. Also, you must be careful if you are using async callings inside the useEffect hook. I was concerned about this but I was doing it wrong in some parts. I'll share with you what I'm doing with my code now, which thanks to the tests and the library, I think is more solid:

I'm adding a variable to know if you component is mounted/unmounted. With this variable validate wether update or not the state of the componente, which cause to re-render the component.

// I prefer to use isMounted. This way I don't have to use a negation to validate if something is  `true` when is `false` :/
useEffect(() => {
  let isMounted = true;

 (async ( ) => {
   const result = await someAsyncMethod();

  // Here i want to update the state of my component
  if (isMounted) {
   setState(result);
  }
 })();

  return () => {
    isMounted = false;
  };
})

My test now it is like this:

it('test', () => {
 const { getByText, update } = render(<Component />);

 fireEvent(getByText('idx-1'), 'onPress');

  act(() => {
    update(<Component />);
  });
});

Hope this might help :)

One last thing! Don't forget to use the done function 😋

DracotMolver avatar Aug 06 '20 14:08 DracotMolver

This leaves your code with following warning logged to console

Warning: You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);

I’ve noticed that this error/warning happens when I use more than 1 await within a it statement

renanmav avatar Aug 12 '20 15:08 renanmav

This leaves your code with following warning logged to console

Warning: You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);

I’ve noticed that this error/warning happens when I use more than 1 await within a it statement

Same issue here. I tried all the things I mentioned earlier, but none worked here.

GleidsonDaniel avatar Aug 16 '20 02:08 GleidsonDaniel

Same issue and I'm not doing anything in my test, just rendering and await findBy

florian-milky avatar Aug 18 '20 15:08 florian-milky

I suggest use wait-for-expect directly.

mym0404 avatar Aug 19 '20 09:08 mym0404

This leaves your code with following warning logged to console

Warning: You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);

I’ve noticed that this error/warning happens when I use more than 1 await within a it statement

I guess this is the issue, too. In my case, those messages disappeared when I replaced findBy queries with getBy and left only one findBy query.

hyunjinjeong avatar Aug 24 '20 09:08 hyunjinjeong

Any updates here? This is happening when i use more than 1 await inside a it

StarryFire avatar Aug 26 '20 20:08 StarryFire

I patched react-test-renderer (as a temporary fix) since this error was failing my CI tests.

if(!argsWithFormat[0].includes(/You called act\(async \(\) => ...\) without await/)) {
  Function.prototype.apply.call(console.error, console, argsWithFormat);
}

renanmav avatar Aug 27 '20 14:08 renanmav

I am getting this without having any awaits or findBy's, this is the simplest example I could reproduce it, the error appears when I get to the second changeText fireEvent.changeText(passwordInput, '12345')

  const { getByPlaceholderText, getByText } = render(<EmailPassword />);

  const emailInput = getByPlaceholderText(/[email protected]/i);
  const passwordInput = getByPlaceholderText(/\*\*\*\*\*\*\*\*/i);
  const loginButton = getByText(/Login/i);

  fireEvent.changeText(emailInput, '[email protected]'); // valid email
  expect(loginButton).toBeDisabled(); // short password so disabled
  fireEvent.changeText(passwordInput, '12345');
  expect(loginButton).toBeDisabled(); // short password so disabled

  fireEvent.changeText(passwordInput, '123456');
  expect(loginButton).toBeEnabled();
});

Not entirely sure what I need to do?

kieran-osgood avatar Sep 26 '20 09:09 kieran-osgood

I've dug into this a bit and I believe the issue is race conditions.

Here's the act code and the block where the console log happens: https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L3680 You'll notice this just creates a resolved promise and logs in a then block (2 next ticks).

To ignore the error, the then of the initial render needs to fire here: https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L3701 However, in my testing, this is actually firing after the resolved promise above, resulting in the warnings. Why? Because RN/React rendering is slow? Not really sure, but there's definitely not an easy way to fix it, or no way in general.

milesj avatar Sep 29 '20 22:09 milesj

I have had this problem come up in a variety of places and 99% of the time the problem was that I was waiting for synchronous code. That is why for some people the error went away, after they removed an await, it is a strong sign that the test only required one await or that the await is in the wrong place. Something that works quite nicely in these situations is going through the test and remove all awaits and then look at the code that is being tested to see where the async code is actually happening and to use await only once the async code is actually triggered. If the assertion still fails because the render did not finish have a look at the bottom examples, maybe they can help because they wait until something shows up or is finished running.

The following three examples are the ways I tend to use await the most, maybe it helps clean up tests:

  1. Waiting until a assertion is finished, this is great for circumstances where I only care that the final render is correct:
it('renders the send button', async () => {
    const utils = renderConversation();

    await waitFor(() => {
        expect(utils.getByText('Send')).toBeTruthy();
    });
});
  1. Wait for an element to render, this I only really need if I want to call fireEvent on the element. If you wait for an element to render with the below method and await the assertion as well, the error will probably come up. This is a classic case of unnecessary waiting that I describe at the top. Therefore, don't wait for synchronous code.
it('hides the reply', async () => {
    const utils = renderConversationScreen();
    const replyButton = await utils.findByTestId('hideReplyButton'); // findBy... uses waitFor internally i.e. await waitFor(() => utils.getByTestId('hideReplyButton')); workes just as well
    fireEvent(reply, 'Press');

    expect(utils.queryByTestId('hideReplyButton')).toBeNull();
});
  1. FireEvent triggers async code, which should finish before asserting anything
it('queues the conversation messages', async () => {
    const utils = renderConversationScreen();
    const conversation = utils.getByTestId('Conversation');

    const reply = await utils.findByTestId('replyView'); // I wait for the reply to render
    const replyNativeEvent = { layout: { height: 50 } };
    await act(async () => {
        fireEvent(reply, 'Layout', { nativeEvent: replyNativeEvent }); // I wait for all animations to complete
    });

    expect(conversation.props.queueMessages).toBe(true);
});

@KieranO547 though the code is quite simple two follow up question:

  1. When you get that error is the code you posted the only code that is running? To make sure that is the case I would remove all other renderings and tests in the file and only run that one file. The weird thing about the error showing up for you is that the spec you posted seems to contain nothing that could trigger the error.

  2. What does the code look like, which you are testing. Are there timeouts, state changes, animations etc. just the spec code is only one side of the medal and makes it very hard to debug.

ccfz avatar Sep 30 '20 06:09 ccfz

@ccfz Thanks for all the info. So to answer the 2 follow up Q's,

  1. Yeah theres no other code running, these tests are just aiming to assert that my validation is disabling the login button based on the validation, I've implemented the validation via react-hook-forms + yup schema validation, it works just fine from a user perspective and its just a login form so there is definitely no other code running.
  2. Zero timeouts, react-hook-forms handles the form state, no animations. I do get that it wasn't enough info, i wasn't trying to hijack the post with a million lines of code. As an example, I just have a basic "form" with two input elements for username/password, the text-input comes from react-native-elements, which look like this:
      <Controller
        control={control}
        name="password"
        defaultValue=""
        render={(props) => (
          <Input
            {...props}
            onTouchStart={() => trigger('password')}
            onFocus={() => setTouched(touched + 1)}
            style={input}
            placeholder="********"
            leftIcon={
              <Icon name="md-key" size={20} color="black" style={inputIcon} />
            }
            errorStyle={inputError}
            errorMessage={errors.password?.message}
            label="Password"
            secureTextEntry={true}
            onChangeText={(text) => {
              props.onChange(text);
              trigger('password');
            }}
          />
        )}
      />

The touched state is only used for the disabled state of the login button which is exactly what I'm testing for.

Edit: I just refactored the test to be:

test.only('should disable button when password length is short', async () => {
  const { getByPlaceholderText, getByText } = render(<EmailPassword />);
  const emailInput = getByPlaceholderText(/[email protected]/i);
  const passwordInput = getByPlaceholderText(/\*\*\*\*\*\*\*\*/i);
  const loginButton = getByText(/Login/i);

  await act(async () => {
    fireEvent.changeText(emailInput, '[email protected]'); // valid email
  });
  expect(loginButton).toBeDisabled(); // short password so disabled

  await act(async () => {
    fireEvent.changeText(passwordInput, '12345');
  });
  expect(loginButton).toBeDisabled(); // short password so disabled

  await act(async () => {
    fireEvent.changeText(passwordInput, '123456');
  });
  expect(loginButton).toBeEnabled();
});

Matching your third suggestion and thats got rid of the messages for me so i suppose thats what i was missing, the error message handler is part of the component library - still getting used to testing lol - And thank you again!

kieran-osgood avatar Sep 30 '20 20:09 kieran-osgood

I'm also getting this console error logged when I call findByText more than once. I'm trying to understand if this is an actual bug as @renanmav and @milesj seem to suggest or if I'm just not understanding something about how to use react testing library properly.

A simple (albeit contrived) example I've come up with to reproduce the bug is:

function Example() {
  const [value, setValue] = useState(0);
  const asyncIncrement = useCallback(() => {
    setTimeout(() => setValue((v) => v + 1), 0);
  }, []);

  return (
    <View>
      <Text>Value is {value}</Text>
      <Button title="Increment" onPress={asyncIncrement} />
    </View>
  );
};

test("it works", async () => {
  const { findByText, getByRole, getByText } = render(<Example />);
  getByText(/Value is 0/i);
  const button = getByRole("button");

  fireEvent.press(button);
  await findByText(/Value is 1/i);

  //If I comment out these lines and no error message will be logged
  fireEvent.press(button);
  await findByText(/Value is 2/i);

  // If I uncomment these lines and two error messages will be logged
  // fireEvent.press(button);
  // await findByText(/Value is 3/i);
});

The test seems to work fine (passes as written, fails if I change expected values), but I still see the console.error message warning complaining that "You called act(async () => ...) without await"

I get this console error once per additional time (beyond the first) that I make calls to fireEvent.press(button) and findByText in the same test. I've tried suggestions by @ccfz to wrap the fireEvent() calls with await act(async ...), but that didn't help (not sure I understand how that would have helped either? Doesn't fireEvent already wrap things in act()?)

Could someone help me understand what is going wrong here?

ryanhobsonsmith avatar Oct 06 '20 00:10 ryanhobsonsmith

@ryanhobsonsmith By default fireEvent wraps in act and the handler being fired must be synchronous, but because of setTimeout, that onPress is technically asynchronous. So in your example above, the setValue is happening after act has ran.

Maybe try:

await waitFor(() => fireEvent.press(button));

milesj avatar Oct 06 '20 02:10 milesj

Thanks @milesj, your notes about the act handler make sense. Unfortunately, your proposed solution didn't work.

My understanding is that waitFor just periodically executes the interior function until it returns a value and, as you said, the fireEvent.press() looks like it returns synchronously (regardless of sync/async nature of the event handler) right?

Just in case fireEvent.press() was actually passing back the result of the event handler somehow, I switched it around so handler would actually return something to await on:

const asyncIncrement = useCallback(() => {
    return new Promise((resolve) => {
      setTimeout(() => {
        setValue((v) => v + 1);
        resolve();
      }, 0);
    });
  }, []);

Feels unnecessary, but with or without that above event handler change I'm still seeing that same error.

Again, reason I'm leaning towards this being a bug is that the test actually seems to pass and work as expected outside of this console error message. I.e. subsequent findByText calls properly wait for the value to appear like I'd expect, just getting the console spam that makes me feel like I must be doing something wrong or may have a subtle bug somewhere.

ryanhobsonsmith avatar Oct 06 '20 04:10 ryanhobsonsmith

Ok, Miles' solution was too far off actually. In case anyone else finds this and wants a quick workaround inspired by: https://github.com/facebook/react/issues/15416, then the best hack I've got work around things is to just build in some manual wait time inside of an act() call like so:

function wait(ms) {
  return act(() => new Promise((r) => setTimeout(r, ms)));
}

function fireAsyncPress(element, ms = 1) {
  fireEvent.press(element);
  return wait(ms);
}

test("it works", async () => {
  const { getByRole, getByText } = render(<Example />);
  getByText(/Value is 0/i);
  const button = getByRole("button");

  await fireAsyncPress(button);
  getByText(/Value is 1/i);

  await fireAsyncPress(button);
  getByText(/Value is 2/i);
});

Would be happy to learn if anyone has worked out a better practice for testing these kinds of async event handlers / state-changes though.

ryanhobsonsmith avatar Oct 06 '20 05:10 ryanhobsonsmith

Yeah, I actually do something similar, like this.

async function waitForEvent(cb) {
  await waitFor(async () => {
    await cb();
    await new Promise(resolve => process.nextTick(resolve));
  });
}

await waitForEvent(() => fireEvent.press());

milesj avatar Oct 06 '20 06:10 milesj

@ryanhobsonsmith I played around with your examples and I would agree that there is a bug. waitFor seems to not play nice with timeouts, I had a similar issue with a Animated callback. As the findBy queries use waitFor on the inside I think that it is related to waitFor.

My workaround is:

fireEvent.press(button);
await act(wait);
expect(...)

@ryanhobsonsmith could you maybe create a PR with your example from above

function Example() { const [value, setValue] = useState(0); const asyncIncrement = useCallback(() => { setTimeout(() => setValue((v) => v + 1), 0); }, []); ...

because that should have definitely worked and the issue does not actually have a PR yet.

ccfz avatar Oct 06 '20 06:10 ccfz

@thymikee here is a repo with the example from @ryanhobsonsmith. It would be great if someone had a look.

ccfz avatar Oct 07 '20 06:10 ccfz

I have been playing a little bit with the repo that @ccfz created.

I changed the findBy queries for waitFor + getBy queries, still got warnings. Then, in this commit I extracted the implementation of waitFor from @testing-library/react-native (in the node_modules folder) and declared the function locally (myWaitFor). When I use that function the test still passes and there are no warnings. Finally, if I use the imported waitFor instead of its local version, warnings appear again (commit)

This is definitely not a proposed solution. I'm just sharing because I find it very weird and hope that it somehow helps the person that will debug this issue.

sbalay avatar Oct 21 '20 20:10 sbalay

@sbalay seems like a splendid idea! From what you describe this seems like a pontetial solution to the act issue.

Would you be able to submit a PR for that change so we could iron out the implementation details and make sure it passes our internal tests correctly?

mdjastrzebski avatar Oct 21 '20 20:10 mdjastrzebski

@sbalay Is there duplicate react, react-test-renderer, or testing-library dependencies?

milesj avatar Oct 21 '20 20:10 milesj