-
Notifications
You must be signed in to change notification settings - Fork 4k
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
NavigationToggle: wait for tooltip positioning in unit test #45587
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
/** | ||
* External dependencies | ||
*/ | ||
import { render, screen } from '@testing-library/react'; | ||
import { render, screen, waitFor } from '@testing-library/react'; | ||
|
||
/** | ||
* WordPress dependencies | ||
|
@@ -24,9 +24,59 @@ jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( { | |
|
||
jest.mock( '@wordpress/core-data' ); | ||
|
||
/** | ||
* Whether the element has been positioned. | ||
* True if `top` and `left` have been set, false otherwise. | ||
* | ||
* @param {Element} element Element to check. | ||
* @return {boolean} True if positioned, false otherwise. | ||
*/ | ||
function isElementPositioned( element ) { | ||
const { getComputedStyle } = element.ownerDocument.defaultView; | ||
|
||
const { top, left } = getComputedStyle( element ); | ||
return top !== '' && left !== ''; | ||
} | ||
|
||
/** | ||
* Custom jest matcher. | ||
* Determines whether an element has been positioned or not. | ||
* | ||
* @param {Element} element Element to check. | ||
* @return {Object} Matcher result | ||
*/ | ||
function toBePositioned( element ) { | ||
const isInDocument = | ||
element.ownerDocument === element.getRootNode( { composed: true } ); | ||
const isPositioned = isInDocument && isElementPositioned( element ); | ||
return { | ||
pass: isPositioned, | ||
message: () => { | ||
const is = isPositioned ? 'is' : 'is not'; | ||
return [ | ||
this.utils.matcherHint( | ||
`${ this.isNot ? '.not' : '' }.toBePositioned`, | ||
'element', | ||
'' | ||
), | ||
'', | ||
`Received element ${ is } positioned${ | ||
isInDocument ? '' : ' (element is not in the document)' | ||
}:`, | ||
` ${ this.utils.printReceived( element.cloneNode( false ) ) }`, | ||
].join( '\n' ); | ||
}, | ||
}; | ||
} | ||
|
||
// Register the custom matcher | ||
expect.extend( { | ||
toBePositioned, | ||
} ); | ||
|
||
describe( 'NavigationToggle', () => { | ||
describe( 'when in full screen mode', () => { | ||
it( 'should display a user uploaded site icon if it exists', () => { | ||
it( 'should display a user uploaded site icon if it exists', async () => { | ||
useSelect.mockImplementation( ( cb ) => { | ||
return cb( () => ( { | ||
getCurrentTemplateNavigationPanelSubMenu: () => 'root', | ||
|
@@ -40,12 +90,23 @@ describe( 'NavigationToggle', () => { | |
|
||
render( <NavigationToggle /> ); | ||
|
||
// The `NavigationToggle` component auto-focuses on mount, and that | ||
// causes the button tooltip to appear and to be positioned relative | ||
// to the button. It happens async and we need to wait for it. | ||
await waitFor( | ||
() => | ||
expect( | ||
screen.getByText( 'Toggle navigation' ).parentElement | ||
).toBePositioned(), | ||
{ timeout: 2000 } // It might take more than a second to position the popover. | ||
); | ||
|
||
const siteIcon = screen.getByAltText( 'Site Icon' ); | ||
|
||
expect( siteIcon ).toBeVisible(); | ||
Comment on lines
104
to
106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if, instead of waiting for implementation details like await expect( screen.findByText( 'Toggle navigation' ) ).resolves.toBeVisible(); I've not tested this with React 18 though so not sure if it fixes the For reference, this is how it would like in Playwright. await expect( page.getByText( 'Toggle navigation' ) ).toBeVisible(); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The example assertion you proposed is equivalent to: expect( await screen.findText( 'Toggle navigation' ) ).toBeVisible(); and what it does is:
That means that if the tooltip gets initially rendered with 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: const tooltip = screen.getByText( 'Toggle navigation' );
await waitFor( () => expect( tooltip ).toBePositionedPopover() ); 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I guess a better solution, even though not perfect, would be to manually call
The example I shared is not Testing Library's code, but Playwright's code. In Playwright, There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 (
I spent some time now carefully debugging the First, the Testing Library itself registers an The Another way the test suite avoids triggering the warning is by ending before There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 waitFor(() => {
expect( tooltip ).toHaveStyle( `
left: 0px;
top: 1px;
` );
}); It's very similar to 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yes, I've been debugging why exactly the await runTest();
for ( const hook of hooks.afterEach ) {
await runHook( hook );
} and if there are enough registered At the same time, for ( let i = 0; i < middlewares.length; i++ ) {
await middleware[ i ]();
} It's the same microtask race as the one in So, if the test is synchronous, i.e., it doesn't do any For sync test we can use the 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 commentThe reason will be displayed to describe this comment to others. Learn more. I don't think it has anything to do with "racing" though. The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There is a race, not in the It can happen that the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} ); | ||
|
||
it( 'should display a default site icon if no user uploaded site icon exists', () => { | ||
it( 'should display a default site icon if no user uploaded site icon exists', async () => { | ||
useSelect.mockImplementation( ( cb ) => { | ||
return cb( () => ( { | ||
getCurrentTemplateNavigationPanelSubMenu: () => 'root', | ||
|
@@ -59,6 +120,15 @@ describe( 'NavigationToggle', () => { | |
|
||
const { container } = render( <NavigationToggle /> ); | ||
|
||
// wait for the button tooltip to appear and to be positioned | ||
await waitFor( | ||
() => | ||
expect( | ||
screen.getByText( 'Toggle navigation' ).parentElement | ||
).toBePositioned(), | ||
{ timeout: 2000 } // It might take more than a second to position the popover. | ||
); | ||
|
||
expect( | ||
screen.queryByAltText( 'Site Icon' ) | ||
).not.toBeInTheDocument(); | ||
|
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.
One little idea: maybe an
{ interval: 10 }
option is better. It better expresses the intent: we don't really need to wait longer, we only need to check more often 🙂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.
Go for it 👍 Maybe the comment needs some improvements as well, to reflect that it's not about time but it's rather about the
waitFor()
loops ran under the hood.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.
Implemented, including a comment update. 👍