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

expose `TestUtils.act()` for batching actions in tests #14744

Merged
merged 33 commits into from Feb 5, 2019

Conversation

Projects
None yet
@threepointone
Copy link
Contributor

threepointone commented Feb 1, 2019

act() for testing react components


// React DOM
import { act } from 'react-dom/test-utils';

// React Native
import { act } from 'react-test-renderer';

React, like other libraries, doesn't guarantee synchronous ordering and execution of it's own work. eg - calling this.setState() inside a class doesn't actually set the component state immediately. In fact, it might not even update the state of the component in the same 'tick'. This isn't a problem when it comes to 'users'; they interact with React surfaces asynchronously, giving react and components plenty of time to 'resolve' to particular states.

However, tests are unique in that people write code, usually sequential, to interact with React components, and some assumptions they make won't hold true. Consider - with React's new useEffect hook, a component can run a side effect as soon as it 'starts up'. As a contrived example -

function App(props) {
  useEffect(() => {
    props.callback();
  });
  return null;
}

Let's say you write a test for it like so -

let called = false;
render(
  <App
    callback={() => {
      called = true;
    }}
  />,
  document.body
);
expect(called).toBe(true); // this fails, it's false instead!

The test would fail, which seems counterintuitive at first. But the docs explain it - "The function passed to useEffect will run after the render is committed to the screen." So while the effect has been queued into it's scheduler, it's up to React to decide when to run it. React only guarantees that it'll be run before the browser has reflected changes made to the dom to the user (ie - before the browser has 'painted' the screen)

You may be tempted to refactor this like so -

// don't do this!
function App(props) {
  useLayoutEffect(() => {
    props.callback();
  });
  return null;
}

This would "work" in that your test would pass, but that's because you've explicitly using a render blocking effect where it possibly wasn't required. This is bad for a number of reasons, but in this context, it's bad because we're changing product behavior just to fix a test.

What can we do better?

Well, React could expose a helper, let's call it act, that guarantees the sequential execution of it's update queue. Let's rewrite the test -

let called = false;
act(() => {
  // this 'scope' is safe to interact with the React component,
  // rendering and clicking as you please
  render(
    <App
      callback={() => {
        called = true;
      }}
    />,
    document.body
  );
});
// at this point, we can guarantee that effects have been executed,
// so we can make assertions
expect(called).toBe(true); // this passes now!
act(() => {
  // further interactions, like clicking buttons, scrolling, etc
});
// more assertions

Note - React still doesn't synchronously execute the effect (so you still can't put your expect statements inside act), but it does guarantee to execute all enqueued effects right after act is called.

This is a nice mental model for separation of concerns when testing components - "React, here's a batch of code I'd like you to run at one go", followed by more code to test what React has actually 'done'.

@threepointone

This comment has been minimized.

Copy link
Contributor Author

threepointone commented Feb 1, 2019

@threepointone

This comment has been minimized.

Copy link
Contributor Author

threepointone commented Feb 1, 2019

moving it into TestUtils
Edit - done.

@gaearon

This comment has been minimized.

Copy link
Member

gaearon commented Feb 1, 2019

Possible follow up: warn if we detect setState on a Hook in jsdom environment?

@threepointone threepointone changed the title expose `unstable_interact` for batching actions in tests expose `TestUtils.interact()` for batching actions in tests Feb 1, 2019

@sebmarkbage

This comment has been minimized.

Copy link
Member

sebmarkbage commented Feb 1, 2019

If we're going to warn for setState on Hook outside of this thing, we should do it before release.

@threepointone

This comment has been minimized.

Copy link
Contributor Author

threepointone commented Feb 1, 2019

Noted. I'll send a followup in a bit.

@sebmarkbage
Copy link
Member

sebmarkbage left a comment

We shouldn't expose private APIs to do this in the production build since we'll have to live with those for longer. The goal is to get rid of all the other exports so it's zero private exports. We only get there if we stop going the other direction. Let's just use the hack in the test utils.

We should bikeshed the name a bit more. interact is the wrong word because not all of these are interactions, and even for things we've called interactions in the past we should rename (e.g. to "discrete events" rather than interactive events).

function batchedInteraction(callback: () => void) {
batchedUpdates(callback);
flushPassiveEffects();
}

This comment has been minimized.

@sebmarkbage

sebmarkbage Feb 1, 2019

Member

This function should move to ReactTestUtils and instead using the ReactDOM.render(null...) trick to flush the passive effects. That way we don't have to add any unnecessary invasive APIs to the production ReactDOM. We can add private APIs once we have the new bundle that is exclusively for testing.

This comment has been minimized.

@threepointone

threepointone Feb 1, 2019

Author Contributor

agreed, I felt icky doing this. will change,

@kentcdodds

This comment has been minimized.

Copy link
Contributor

kentcdodds commented Feb 1, 2019

I think this looks good. We'll probably just re-export the interact function in react-testing-library.

warn if we detect setState on a Hook in jsdom environment?

I'm not sure I understand this.

@threepointone

This comment has been minimized.

Copy link
Contributor Author

threepointone commented Feb 1, 2019

We should bikeshed the name a bit more. interact is the wrong word

strong agree. it felt wrong right there. some options - run, execute, batch, batchAndRun. I would have also liked do

@gaearon

This comment has been minimized.

Copy link
Member

gaearon commented Feb 1, 2019

I'm not sure I understand this.

We want to add a warning when you setState on a Hook-using component in jsdom outside of this "interact" scope. This is because you're usually testing the wrong behavior that doesn't occur in practice due to batching.

In fact this is already a problem in classes. setState() from test won't work like a real setState() in a class click handler. But it's too late to fix in classes since everybody does that. With Hooks we have a chance to explain this is bad, and point to the recommended solution.

@sebmarkbage

This comment has been minimized.

Copy link
Member

sebmarkbage commented Feb 1, 2019

We shouldn't warn in all jsdom environments. Only test ones, such as jest. Sometimes jsdom can be used in some other esoteric use cases - e.g. to server render webcomponents.

@sebmarkbage

This comment has been minimized.

Copy link
Member

sebmarkbage commented Feb 1, 2019

@threepointone Instead of detecting if we're inside "interact()", we can detect if we're batching updates or in concurrent mode. I.e. if we're in sync mode. It's unfortunate because we won't warn if you use batchedUpdates() which won't properly flush the effects but at least we catch the common case. That way we don't need to expose and new APIs from the ReactDOM bundle.

@kentcdodds

This comment has been minimized.

Copy link
Contributor

kentcdodds commented Feb 1, 2019

This makes sense. So correct me if I'm wrong but this basically means that if you interact with a component in any way that calls a state updater, you'll get a warning if it wasn't done within an interact callback (or whatever that ends up being called). That sounds good to me 👍 Makes the rules easier to follow. I'd love to play with this when it's ready!

@gaearon

This comment has been minimized.

Copy link
Member

gaearon commented Feb 1, 2019

We shouldn't warn in all jsdom environments. Only test ones, such as jest

This is tricky because we don't have a way to detect. We can detect Jest by global.it or global.expect (or both). But not Ava which uses non-global helpers.

We could check NODE_ENV. But not everybody sets it. Also, we can't easily do this from inside our bundles because our build step would replace it. So it would need to be threaded through somehow.

@sebmarkbage

This comment has been minimized.

Copy link
Member

sebmarkbage commented Feb 1, 2019

I suppose any jsdom server renderer should be using batchedUpdates anyway.

@@ -380,6 +380,11 @@ const ReactTestUtils = {

Simulate: null,
SimulateNative: {},

interact(callback: () => void) {

This comment has been minimized.

@sebmarkbage

sebmarkbage Feb 1, 2019

Member

Drop the inter. Just call it act. It's cleaner.

@sebmarkbage

This comment has been minimized.

Copy link
Member

sebmarkbage commented Feb 1, 2019

There is precedence in the arrange, act, assert style testing to call the phase where you are actually performing things "act" which is what you're meant to be doing in this callback. Followed by expect() calls later.

It's also short so it's not too bothersome to write all the time in all your tests.

I think it's a feature that it is non-descriptive about what actually this models. It's kind of a frame boundary but it's not always because they can also flush early. Event sequences move around a lot in various heuristics/polyfills/spec changes. So there isn't a clear semantic other than there is a bunch of work.


interact(callback: () => void) {
ReactDOM.unstable_batchedUpdates(callback);
ReactDOM.render(null, document.createElement('div'));

This comment has been minimized.

@sebmarkbage

sebmarkbage Feb 1, 2019

Member

Can we reuse a single node? Maybe put an inline React element instead so we don't bail out.

@sebmarkbage

This comment has been minimized.

Copy link
Member

sebmarkbage commented Feb 1, 2019

I believe that in the nested case, these will flush at the outer act(...) boundary which makes sense. That way I can put these on the top level always, but I can also put them inside test utilities like "dispatch" helpers.

@threepointone threepointone changed the title expose `TestUtils.interact()` for batching actions in tests expose `TestUtils.act()` for batching actions in tests Feb 1, 2019

@gaearon

This comment has been minimized.

Copy link
Member

gaearon commented Feb 1, 2019

Canonical jsdom detection jsdom/jsdom#1537 (comment)

@threepointone

This comment has been minimized.

Copy link
Contributor Author

threepointone commented Feb 2, 2019

Question - Assuming the warning exists, how would I get this test to pass?

it('lets a ticker update', () => {
  function App(){
    let [toggle, setToggle] = useState(0)
    useEffect(() => {
      let timeout = setTimeout(() => {
        setToggle(1)
      }, 200);
      return () => clearTimeout(timeout)
    })
    return toggle
  }
  const el = document.createElement('div')
  act(() => {
    ReactDOM.render(<App />, el);
  })
  jest.advanceTimersByTime(250); // this warns!!!        
  expect(el.innerHTML).toBe("1")
})

for context - I wrote ensureBatchingAndScheduleWork in ReactFiberScheduler, that dispatchAction in ReactFiberHooks would call. Running this across our tests, I found a number of failures (because we use the pattern we want to prevent - getting a pointer to a hooks setState and calling it). filtering on only jsdom occurrences, that number got smaller, but led me to writing the above test. current work here - threepointone@ac7416d

@threepointone

This comment has been minimized.

Copy link
Contributor Author

threepointone commented Feb 2, 2019

one workaround is to isolate the render call and the timer advance, so this passes the test -

act(() => {
  ReactDOM.render(<App />, container);
});
act(() => {
  jest.advanceTimersByTime(250); // doesn't warn
})

expect(container.innerHTML).toBe('1');

bit annoying though.

another alternative, also annoying -

act(() => {
  act(()=> {
    ReactDOM.render(<App />, container);  
  })      
  jest.advanceTimersByTime(250); // this warns!!!      
});

(putting them both in the same act call doesn't work, since the effect wouldn't have fired yet)

@kentcdodds

This comment has been minimized.

Copy link
Contributor

kentcdodds commented Feb 2, 2019

Good point @threepointone. I can see how this complicates the testing story quite a bit. Even experienced React engineers will struggle with writing tests for effects like this 🤔

@gaearon
Copy link
Member

gaearon left a comment

some nits on messages

if (isBatchingUpdates === false) {
warningWithoutStack(
false,
'It looks like you are in a test environment, trying to ' +

This comment has been minimized.

@gaearon

gaearon Feb 5, 2019

Member

We'll need to remember to update the wording, e.g. "UI" not "ui", and also it's not clear where to import "act" from. It's also not clear what "unexpected UI in testing" means.

if (__DEV__) {
warningWithoutStack(
false,
'Do not await an act(...) call, it is not a promise',

This comment has been minimized.

@gaearon

gaearon Feb 5, 2019

Member

Nit: Capital Promise. Also probably you mean "it does not return a Promise".

if (typeof result.then === 'function') {
addendum =
'\n\nIt looks like you wrote act(async () => ...) or returned a Promise. ' +
'Do not write async logic inside act(...)\n';

This comment has been minimized.

@gaearon

gaearon Feb 5, 2019

Member

Missing period at the end of the sentence.

Also maybe "Putting asynchronous logic inside act() is not yet supported".

This comment has been minimized.

@Jessidhia

Jessidhia Feb 5, 2019

Contributor

If you don't make it return a Promise now, you can't make it accept an async function later.

Or, well, you can, but it'll be a breaking change.

This comment has been minimized.

@gaearon

gaearon Feb 5, 2019

Member

I think it's the other way around. If we don't make it return a thenable now, people can write await act() without realizing they're awaiting undefined. Then making it actually return a Promise would be a breaking change.

By warning on await now we're effectively enforcing that you don't depend on what it returns. Therefore we can later make it actually return a Promise in some cases.

threepointone added some commits Feb 5, 2019

nit
Show resolved Hide resolved packages/react-test-renderer/src/ReactTestRenderer.js

function flushPassiveEffects() {
// Trick to flush passive effects without exposing an internal API:
// Create a throwaway root and schedule a dummy update on it.

This comment has been minimized.

@Jessidhia

Jessidhia Feb 5, 2019

Contributor

🤯

This will work for passive effects but we'll probably need something else for ConcurrentMode.

I guess that's where the potential Promise-based API change would come in.

threepointone added some commits Feb 5, 2019

hi andrew
(prettier fix)

@threepointone threepointone merged commit 267ed98 into facebook:master Feb 5, 2019

1 check passed

ci/circleci Your tests passed on CircleCI!
Details
@kentcdodds

This comment has been minimized.

Copy link
Contributor

kentcdodds commented Feb 5, 2019

👏👏👏 great job Sunil!

In the future, I'm going to subscribe to all your PRs. Watching the commit messages come in is great fun 😁

acdlite added a commit that referenced this pull request Feb 5, 2019

@ferdaber

This comment has been minimized.

Copy link

ferdaber commented Feb 5, 2019

I'm super excited for this. I can't count how many times I've had to explain to my teammates to await a set timeout when testing effects, this will make it so much less work!

]);

act(() => {
expect(ReactNoop.flush()).toEqual([

This comment has been minimized.

@threepointone

threepointone Feb 6, 2019

Author Contributor

This is a bit confusing, will fix

pull bot pushed a commit to SimenB/react that referenced this pull request Feb 6, 2019

Add 16.8.0 changelog and update some READMEs (facebook#14692)
* Add 16.8.0 changelog

* Mention ESLint plugin

* Remove experimental notices from the ESLint plugin README

* Update CHANGELOG.md

* Add more details for Hooks

* fix

* Set a date

* Update CHANGELOG.md

Co-Authored-By: gaearon <dan.abramov@gmail.com>

* Update CHANGELOG.md

* useReducer in changelog

* Add to changelog

* Update date

* Add facebook#14119 to changelog

* Add facebook#14744 to changelog

* Fix PR links

* act() method was added to test utils, too

* Updated release date to February 6th

pull bot pushed a commit to SimenB/react that referenced this pull request Feb 6, 2019

Add 16.8.0 changelog and update some READMEs (facebook#14692)
* Add 16.8.0 changelog

* Mention ESLint plugin

* Remove experimental notices from the ESLint plugin README

* Update CHANGELOG.md

* Add more details for Hooks

* fix

* Set a date

* Update CHANGELOG.md

Co-Authored-By: gaearon <dan.abramov@gmail.com>

* Update CHANGELOG.md

* useReducer in changelog

* Add to changelog

* Update date

* Add facebook#14119 to changelog

* Add facebook#14744 to changelog

* Fix PR links

* act() method was added to test utils, too

* Updated release date to February 6th

pull bot pushed a commit to chojar/react that referenced this pull request Feb 6, 2019

Add 16.8.0 changelog and update some READMEs (facebook#14692)
* Add 16.8.0 changelog

* Mention ESLint plugin

* Remove experimental notices from the ESLint plugin README

* Update CHANGELOG.md

* Add more details for Hooks

* fix

* Set a date

* Update CHANGELOG.md

Co-Authored-By: gaearon <dan.abramov@gmail.com>

* Update CHANGELOG.md

* useReducer in changelog

* Add to changelog

* Update date

* Add facebook#14119 to changelog

* Add facebook#14744 to changelog

* Fix PR links

* act() method was added to test utils, too

* Updated release date to February 6th
@willdurand

This comment has been minimized.

Copy link

willdurand commented Feb 6, 2019

A side-effect of this patch is that it broke one of our test cases that has the @jest-environment node directive. We setup Enzyme globally (in a setupFilesAfterEnv file) because all but one file use a browser env.

Because the stub element is initialized globally using document in this patch, the whole test file fails:

 FAIL  tests/unit/core/server/test_sriDataPlugin.js
  ● Test suite failed to run

    ReferenceError: document is not defined

      at node_modules/react-dom/cjs/react-dom-test-utils.development.js:944:27
      at Object.<anonymous> (node_modules/react-dom/cjs/react-dom-test-utils.development.js:1283:5)
      at Object.<anonymous> (node_modules/react-dom/test-utils.js:6:20)
      at Object.<anonymous> (node_modules/enzyme-adapter-react-16/build/ReactSixteenAdapter.js:31:18)

@willdurand willdurand referenced this pull request Feb 6, 2019

Merged

Update react monorepo to v16.8.1 #7549

0 of 1 task complete
@sakshigupta

This comment has been minimized.

Copy link

sakshigupta commented Feb 6, 2019

A side-effect of this patch is that it broke one of our test cases that has the @jest-environment node directive. We setup Enzyme globally (in a setupFilesAfterEnv file) because all but one file use a browser env.

Because the stub element is initialized globally using document in this patch, the whole test file fails:

 FAIL  tests/unit/core/server/test_sriDataPlugin.js
  ● Test suite failed to run

    ReferenceError: document is not defined

      at node_modules/react-dom/cjs/react-dom-test-utils.development.js:944:27
      at Object.<anonymous> (node_modules/react-dom/cjs/react-dom-test-utils.development.js:1283:5)
      at Object.<anonymous> (node_modules/react-dom/test-utils.js:6:20)
      at Object.<anonymous> (node_modules/enzyme-adapter-react-16/build/ReactSixteenAdapter.js:31:18)

I am also facing the same issue, any fix for this?

@Jessidhia

This comment has been minimized.

Copy link
Contributor

Jessidhia commented Feb 6, 2019

Very dirty hack around it to get the require to work: if (typeof document === 'undefined') { global.document = { createElement() { return null } } }

But this may possibly crash elsewhere.

@bvaughn

This comment has been minimized.

Copy link
Contributor

bvaughn commented Feb 6, 2019

Thanks for pointing this out, @sakshigupta. Sunil is going to look into it shortly.

facebook-github-bot added a commit to facebook/flow that referenced this pull request Feb 6, 2019

[PR] [react-dom] expose TestUtils.act()
Summary:
React is exposing a new test helper as part of the hooks release, as per facebook/react#14744. This PR brings that API to flow.

I ran `make`, and  `make test`, and it all looked good.
Pull Request resolved: #7440

Reviewed By: nmote

Differential Revision: D13940079

Pulled By: jbrown215

fbshipit-source-id: 3b9bb3c6894e40b97049473b15665544bf0649bb

Kiku-git added a commit to Kiku-git/react that referenced this pull request Feb 10, 2019

expose `TestUtils.act()` for batching actions in tests (facebook#14744)
* expose unstable_interact for batching actions in tests

* move to TestUtils

* move it all into testutils

* s/interact/act

* warn when calling hook-like setState outside batching mode

* pass tests

* merge-temp

* move jsdom test to callsite

* mark failing tests

* pass most tests (except one)

* augh IE

* pass fuzz tests

* better warning, expose the right batchedUpdates on TestRenderer for www

* move it into hooks, test for dom

* expose a flag on the host config, move stuff around

* rename, pass flow

* pass flow... again

* tweak .act() type

* enable for all jest environments/renderers; pass (most) tests.

* pass all tests

* expose just the warning from the scheduler

* don't return values

* a bunch of changes.

can't return values from .act
don't try to await .act calls
pass tests

* fixes and nits

* "fire events that udpates state"

* nit

* 🙄

* my bad

* hi andrew

(prettier fix)

Kiku-git added a commit to Kiku-git/react that referenced this pull request Feb 10, 2019

Add 16.8.0 changelog and update some READMEs (facebook#14692)
* Add 16.8.0 changelog

* Mention ESLint plugin

* Remove experimental notices from the ESLint plugin README

* Update CHANGELOG.md

* Add more details for Hooks

* fix

* Set a date

* Update CHANGELOG.md

Co-Authored-By: gaearon <dan.abramov@gmail.com>

* Update CHANGELOG.md

* useReducer in changelog

* Add to changelog

* Update date

* Add facebook#14119 to changelog

* Add facebook#14744 to changelog

* Fix PR links

* act() method was added to test utils, too

* Updated release date to February 6th
@NE-SmallTown

This comment has been minimized.

Copy link
Contributor

NE-SmallTown commented Feb 20, 2019

@threepointone

React only guarantees that it'll be run before the browser has reflected changes made to the dom to the user (ie - before the browser has 'painted' the screen)

I'm confused about this, especially the word 'before', for example:

function App(props) {
  React.useEffect(() => {
    props.callback();
  });
  return 'hi';
}

let called = false;
ReactDOM.render(
  <App
    callback={() => {
      console.log('callback be called');
      called = true;
    }}
  />,
  document.getElementById('container')
);
console.log(document.body.innerHTML);

When execute console.log(document.body.innerHTML), we will see that the body has text 'hi' in the output string, it means this content has been painted to the screen at this moment, but the console.log('callback be called)' will be called after that console.log instead 'before'

Or your suppose is based on concurrent mode?

@Jessidhia

This comment has been minimized.

Copy link
Contributor

Jessidhia commented Feb 20, 2019

I think that was kind of a mixup.

The current guarantees are, AFAIK:

  • each given useEffect will be invoked exactly once for each commit.
  • any queued useEffect will all be forced to execute before any code re-enters React (event handlers, triggering setState, etc).
  • useEffect will never block React modifying the DOM ("making a commit") or the initial browser re-painting that is done by the commit, unless they are being forced to execute by re-entering React (or by act).

useEffect right now runs in its own microtask tick that is scheduled using requestAnimationFrame, and can be delayed for up to 5s before it is called, but that's an implementation detail.

What act does is that it forces queued useEffects to execute before act returns instead of "sometime in the future".

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