-
Notifications
You must be signed in to change notification settings - Fork 4.7k
NavigationToggle: wait for tooltip positioning in unit test #45587
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
Closed
Closed
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next
Next commit
NavigationToggle: wait for tooltip positioning in unit test
- Loading branch information
commit fd5c517804f80280de2ddf69df801e56496844c6
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if, instead of waiting for implementation details like
isPopoverwhich is harder to understand to end users, we could just auto-wait the results directly in the assertions?I've not tested this with React 18 though so not sure if it fixes the
act()issue. This technique is used by Playwright and I think it makes a lot of sense in unit testing as well.For reference, this is how it would like in Playwright.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example assertion you proposed is equivalent to:
and what it does is:
display: noneelement will be successfully found and returned byfindByText.findByTextdoes when it doesn't find the element.That means that if the tooltip gets initially rendered with
display: noneoropacity: 0, and is made visible only a bit later, the assertion will fail. We're not waiting for the element to get visible, we're waiting only for it to appear in the DOM.The test author needs to be aware how exactly the UI evolves and write the right checks.
In our case, we don't want to wait for the tooltip to become visible, but to become positioned. We could write a custom matcher for that, and then write:
This is a combination of sync and async checks. The tooltip element is in the DOM right after render, we don't need to wait. But the positioning happens async a bit later, and we need to wait.
In the Testing Library language, this is a fully synchronous check. The
awaitis redundant.getByTextdoesn't return a promise: it returns either the element it found or throws an error.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see! Thanks for the explanation! I'm a bit confused though. Isn't the
toBeVisible()assertion the only and last assertion in the test? Do we care if the tooltip is positioned correctly? I think we only care if thesiteIconis visible in this test case. Not to mention that the tooltip is sort of an unexpected side effect, or can even be considered a bug.I guess a better solution, even though not perfect, would be to manually call
cleanup()at the end of the test as shown in the doc. I'm aware of your original comment and that you mentioned that it's more like a "hack". However, compared to other solutions (manualact()and unrelatedwaitFor), I think thecleanuphack might be the best one that doesn't involve many implementation details. WDYT? I'm not sure if this hack even works in this case though 😅 .The example I shared is not Testing Library's code, but Playwright's code. In Playwright,
getByTextreturns alocator, which lazily locates the element.toBeVisible()also auto-waits and auto-retries until the assertion passes. Playwright also checks for actionability before performing the assertion. But I know that it's not the same as the Jest framework and is not really related to this issue though 😅 .There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, the test actually doesn't care about the tooltip at all. But the tooltip is there anyway and it spawns an async "thread" that does the positioning. We need to have these threads under control, because if we don't, there are rogue threads from tests that have finished a long time ago which are still fetching or writing to the DOM or whatever. And that causes trouble.
It would help if the
computePosition().then()thread was cancelled when the tooltip is unmounted, but that doesn't happen. I verified that it's still running long after the test finished.This particular tooltip is indeed bug-like: we don't really want to show it on mount, only after closing the navigation menu. After #37314 is finished, the "focus on mount" code can be removed and the tooltip will never be rendered in these tests.
Thanks for the example. I'm mostly unfamiliar with Playwright API. The API is almost identical to Testing Library, but Playwright is fully async, right? In Testing Library, there are both sync (
getBySomething) and async (findBySomething) queries.I spent some time now carefully debugging the
cleanuphack and figuring out how and why it works, and I don't like it at all 🙂 Seems to me that when it works, it works by pure accident.First, the Testing Library itself registers an
afterEachhook that runscleanup. So it always runs anyway. If I callcleanupmanually in my test, the entire difference is that it runs one or two microtasks earlier. That doesn't look like a reliable technique.The
computePositionasync thread still runs even aftercleanup. So, one way it might effectively work is thatcleanupcausescomputePositionto throw, because, for example, it can't find a DOM element? Then the.thenhandler doesn't run and doesn't do the state update that triggers the "not wrapped in act" warning.Another way the test suite avoids triggering the warning is by ending before
computePositionresolves. I saw this during a debugging session:computePositionruns and returns results, but neither the.thennor the.catchhandler is ever called. The test suite is finished, so Node.js process apparently decided to exit, probably by callingexit(0), without bother to finish executing the rogue threads.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you have a reference for this? I tried it with React 18 and it worked. It seems like it's also handled in floating-ui judging from their source code.
Yep, that's why I said that it's not really relevant here. I just wanted to throw it here for reference of how Playwright handles this kind of issue. 😅
I think you're right, but I don't think this is an accident. I think it might be related to how Jest schedules the test too. I'm not sure if this is correct but it seems like the
actwarning would only happen on a "properly cleaned up test" if it's scheduling a microtask (promise). Because Jest'safterEachwould queue another microtask (again, not sure), thecleanupfunction would happen after the state has already been updated.However, for tests that actually care about the position, like the other test below (more on that later), we still need some way to properly wait for it to finish.
await act(async () => {})works, yourtoBePositioned()works too, either way is fine to me. I would recommend a more explicit way to wait and assert the end result though. Something like:It's very similar to
toBePositioned, I think both are fine.A little bit off-topic: I feel like the snapshot test at the end of the second test is out of place. The test's title doesn't imply anything about the tooltip's position and I don't think it's something we want to test either. I think we can just remove the snapshot test and replace it with explicit assertions if needed. If we do need to check for something that the snapshot is covering, then we should create another dedicated test for that and write explicit and atomic assertions for them.
In this case, given that the tooltip thing is a side-effect, I think we don't need to test that and we can just delete the snapshot. This means we can just use the
cleanuptrick for both tests in this file without having to introducetoBePositionedor other tricks as suggested above.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I've been debugging why exactly the
cleanuptrick would work and you're exactly right. Jest runs the test in an async loop:and if there are enough registered
afterEachhooks, it will take a few ticks to run. Thejest-consolepackage that verifies that the test didn't log any rubbish to console, it registers four.At the same time,
computePosition()runs an async loop, too:It's the same microtask race as the one in
waitForthat we discussed with @tyxla. IfcomputePosition()wins the race, there is theact()warning.So, if the test is synchronous, i.e., it doesn't do any
awaiting, we can solve the issue by calling thecleanupsynchronously. That guarantees thatcomputePosition()always loses the race even before it started.For sync test we can use the
cleanupsolution. For other, more complex tests, we'll need to await the popover positioning.I implemented the alternative solution in #45726, let's review and merge it instead. The work done in this PR, namely the custom matchers, will be reused elsewhere.
That's true, we don't need to check the snapshot to achieve the test's goal, i.e., verifying that the button was rendered and displays the right site icon. I removed it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think it has anything to do with "racing" though.
cleanupis a synchronous action, it internallyunmounts the component and triggers any cleanup effect the component might register. The promise callback will still be fired (unless it usesAbortControllerwhichfloating-uidoesn't), butsetStatewon't be called to trigger theactwarning. That is, if the component properly cleans up the effect after unmount, as every component should, callingcleanuporunmountsynchronously should solve theactwarning.The
actwarning might still happen in between assertions and other updates though, which will be a different issue to tackle, and manual waiting might be required as you suggested. That will depend on whether we care about the updated position, and we can just mock out the library (if we don't) or write explicit assertions about the updated position (if we care). Either way, I think introducing thistoBePostioned()helper might be a little early. We can wait and see until there're more such cases. :)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is a race, not in the
cleanup()function itself, but in the async loop that executes theafterEachhooks. Look at the two async loops I described in the previous comment. One of them runs theafterEachhooks, the other executes thecomputePositionmiddlewares and finally callssetState.It can happen that the
setStatecalls runs on a still-mounted component, because theafterEachloop didn't get to callcleanup()yet. That's why we need to callcleanup()synchronously inside the test body if we want it to run reliably.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, what I mean is that we don't have to care about the race as test authors if we use the
cleanuptrick. Maybe there're some edge cases that it still matters though!