QA How-To
Playwright hover: Examples and Best Practices
Explore playwright hover examples for menus, tooltips, cards, charts, styling, network previews, debugging, and reliable, practical TypeScript test design.
23 min read | 3,039 words
TL;DR
Reliable Playwright hover examples use await locator.hover() and a web-first assertion for the menu, tooltip, card action, chart value, or visual state. Scope repeated elements, control spatial data, and test alternative input paths instead of relying on sleeps or synthetic events.
Key Takeaways
- Structure every hover scenario around deterministic setup, one clear pointer action, and an observable outcome.
- Scope repeated controls to a meaningful card, row, navigation region, or component root.
- Use element-relative position for controlled charts and page.mouse only when pointer travel matters.
- Synchronize delayed and network-backed previews with assertions and targeted response waits, not sleeps.
- Test hover exit plus keyboard or touch alternatives when content or actions are essential.
- Use traces, trial mode, and failure classification before adding retries, force, or longer timeouts.
These Playwright hover examples show how to test menus, tooltips, repeated cards, charts, nested navigation, and hover-only visual states with current Playwright Test APIs. The central rule is consistent: perform the pointer action through a Locator, then assert the product outcome with a retrying assertion.
This guide is organized around realistic scenarios rather than one long API tour. Each example is runnable TypeScript that you can adapt to a local application, and each pattern explains what makes the test stable or fragile.
TL;DR
import { test, expect } from '@playwright/test';
test('opens a user menu on hover', async ({ page }) => {
await page.goto('/dashboard');
const trigger = page.getByRole('button', { name: 'User settings' });
await trigger.hover();
const menu = page.getByRole('menu', { name: 'User settings' });
await expect(menu).toBeVisible();
await expect(menu.getByRole('menuitem', { name: 'Profile' })).toBeVisible();
});
| Scenario | Start with | Assert |
|---|---|---|
| Menu | getByRole('button').hover() | Menu visibility and an important item |
| Tooltip | Trigger.hover() | Tooltip role, text, or accessible description |
| Repeated card | Scoped card.hover() | Action inside the same card |
| Chart or map | hover({ position }) | Value tied to a controlled coordinate |
| Pointer path | page.mouse.move() | Output affected by the movement path |
| Hover styling | locator.hover() | Stable CSS property or locator screenshot |
The examples avoid hard sleeps, arbitrary force, DOM-order selectors, and JavaScript event injection. Those shortcuts can make a test pass while the actual user interaction remains broken.
1. Playwright Hover Examples: A Reliable Test Shape
A maintainable hover test has Arrange, Act, and Assert boundaries even when they are not labeled. Arrange places the page in deterministic state. Act performs one user interaction. Assert confirms a user-visible consequence.
Use this self-contained example to see the pattern without depending on an external site:
import { test, expect } from '@playwright/test';
test('shows an action when a card is hovered', async ({ page }) => {
await page.setContent(`
<style>
.card button { opacity: 0; }
.card:hover button { opacity: 1; }
</style>
<article class="card" aria-label="Annual report">
<h2>Annual report</h2>
<button>Download</button>
</article>
`);
const card = page.getByRole('article', { name: 'Annual report' });
await card.hover();
await expect(card.getByRole('button', { name: 'Download' })).toBeVisible();
});
page.setContent is useful for learning and component reproduction. In an end-to-end suite, navigate to the actual route and create state through fixtures, APIs, or controlled seed data.
The locator expresses product meaning instead of CSS structure. The action is awaited. The assertion is web-first, so it retries while the browser applies the hover style. This shape also produces a useful Playwright action log. If it fails, you can distinguish navigation, target actionability, and outcome assertion instead of debugging a large helper that swallowed the context.
2. Navigation Menu and Nested Submenu Example
Desktop navigation often opens a panel on hover and keeps it open while the pointer enters a submenu. Test both states and scope locators to the navigation landmark.
import { test, expect } from '@playwright/test';
test('navigates through a hover mega menu', async ({ page }) => {
await page.goto('/');
const nav = page.getByRole('navigation', { name: 'Primary' });
await nav.getByRole('button', { name: 'Products' }).hover();
const productsMenu = nav.getByRole('menu', { name: 'Products' });
await expect(productsMenu).toBeVisible();
const testingItem = productsMenu.getByRole('menuitem', { name: 'Testing' });
await testingItem.hover();
const testingMenu = nav.getByRole('menu', { name: 'Testing' });
await expect(testingMenu).toBeVisible();
await testingMenu.getByRole('menuitem', { name: 'Automation' }).click();
await expect(page).toHaveURL(/\/products\/testing\/automation$/);
await expect(page.getByRole('heading', { name: 'Test automation' })).toBeVisible();
});
Do not move the mouse to an arbitrary empty coordinate between the first and second level. That may close the parent by design. Moving straight to the submenu item reproduces the user path.
If the submenu closes while crossing a small visual gap, capture trace evidence and discuss the hit area with the product team. Extending a close delay or adding a pointer corridor can improve real usability. Automation should reveal that weakness, not conceal it with dispatchEvent. For foundational locator choices, use the Playwright locator best practices alongside this example.
3. Tooltip Examples With Text and Accessibility
A tooltip example should prove content, not just the presence of a floating div. Prefer getByRole('tooltip') when the component exposes the expected semantics.
test('explains the disabled export action', async ({ page }) => {
await page.goto('/reports/quarterly');
const exportButton = page.getByRole('button', { name: 'Export CSV' });
await expect(exportButton).toBeDisabled();
await exportButton.hover();
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toHaveText('Choose a date range before exporting');
});
Some component libraries wrap a disabled button because disabled native controls may not receive pointer events consistently. In that interface, locate and hover the documented wrapper while still asserting the button and tooltip:
const exportHelp = page.getByTestId('export-help-trigger');
await exportHelp.hover();
await expect(page.getByRole('button', { name: 'Export CSV' })).toBeDisabled();
await expect(page.getByRole('tooltip')).toContainText('Choose a date range');
Do not force hover on the disabled child merely to trigger a library implementation detail. Test the element that a desktop user can actually target.
Add keyboard coverage for essential information:
await exportButton.focus();
await expect(page.getByRole('tooltip')).toHaveText(
'Choose a date range before exporting'
);
The keyboard case may require focusing the wrapper if that is the accessible trigger. Check the rendered semantics rather than assuming. A tooltip should also disappear after moving away or pressing Escape when that behavior is part of the component contract.
4. Repeated Product Cards Without first()
Catalogs and dashboards repeat identical controls. A global getByRole('button', { name: 'Quick view' }) may match dozens of elements. Scope by a stable card identity, then keep all interactions and assertions inside that card.
test('opens quick view for the selected product', async ({ page }) => {
await page.goto('/products');
const card = page.getByRole('article', { name: /Trail Camera X2/ });
await card.hover();
const quickView = card.getByRole('button', { name: 'Quick view' });
await expect(quickView).toBeVisible();
await quickView.click();
const dialog = page.getByRole('dialog', { name: 'Trail Camera X2' });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('$129');
});
If the card has no accessible name, filter a stable collection by visible content:
const card = page
.getByTestId('product-card')
.filter({ has: page.getByRole('heading', { name: 'Trail Camera X2' }) });
await card.hover();
await expect(card.getByRole('button', { name: 'Quick view' })).toBeVisible();
Avoid nth() unless position itself is the requirement, such as verifying the first ranked result. DOM order changes when sorting, personalization, or an experiment runs. A meaningful scoped locator preserves intent and lets strictness detect duplicate or malformed content.
Verify the final dialog or navigation as well as button visibility. That extra assertion proves the hover-exposed action remains usable, not merely painted.
5. Coordinate-Based Chart and Map Example
Coordinates are justified when the element is a spatial surface. Locator.hover accepts position relative to the target element, which is more portable than viewport coordinates.
test('shows the controlled chart point tooltip', async ({ page }) => {
await page.goto('/metrics?fixture=constant-series');
const chart = page.getByTestId('response-time-chart');
await expect(chart).toHaveAttribute('data-ready', 'true');
await chart.hover({ position: { x: 240, y: 96 } });
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toContainText('12:00');
await expect(tooltip).toContainText('240 ms');
});
The controlled fixture is essential. If live data changes axes, the same pixel may represent a different time and value. For responsive charts, set a known viewport and ensure the chart has a deterministic size.
For a path across points, use page.mouse with a fresh bounding box:
const box = await chart.boundingBox();
if (!box) throw new Error('Chart is not visible');
await page.mouse.move(box.x + 40, box.y + 90);
await page.mouse.move(box.x + 240, box.y + 96, { steps: 12 });
await expect(page.getByRole('tooltip')).toContainText('240 ms');
The steps option creates intermediate movements. It is useful when the application samples mousemove events or draws a crosshair path. It is unnecessary for a normal menu. Remember that page.mouse uses main-frame CSS pixel coordinates. Recalculate after any scroll, resize, or layout mutation.
6. Hover Styling and Visual Regression Example
If hover changes elevation, color, underline, or controls, choose an assertion at the correct layer. Functional visibility is usually more resilient than checking every CSS token. When appearance is the requirement, use a stable computed property or locator screenshot.
test('renders the selected pricing card hover state', async ({ page }) => {
await page.goto('/pricing');
const card = page.getByRole('article', { name: 'Team plan' });
await card.hover();
await expect(card.getByRole('link', { name: 'Start trial' })).toBeVisible();
await expect(card).toHaveScreenshot('team-plan-hover.png', {
animations: 'disabled'
});
});
Locator screenshot assertions wait for a stable image before comparison and keep the snapshot focused on the component. Control fonts, viewport, data, locale, color scheme, and browser project so the baseline represents a reproducible environment. Review the Playwright visual testing workflow before using screenshots as broad regression coverage.
A targeted CSS assertion can work for semantic style changes:
const link = page.getByRole('link', { name: 'Documentation' });
await link.hover();
await expect(link).toHaveCSS('text-decoration-line', 'underline');
Avoid exact transition matrices or antialiased color edges unless they are the design contract. A screenshot for every hover can create expensive noise. Use it for a small number of visually critical components and keep functional assertions for most behavior.
7. Verify Hover Exit and State Cleanup
Many hover bugs appear when the pointer leaves: a tooltip stays stuck, a menu closes too quickly, or one card remains highlighted after another receives the pointer. Test cleanup by moving to a meaningful safe target.
test('closes the tooltip when the pointer leaves', async ({ page }) => {
await page.goto('/settings');
const info = page.getByRole('button', { name: 'Password requirements' });
const heading = page.getByRole('heading', { name: 'Security settings' });
await info.hover();
await expect(page.getByRole('tooltip')).toBeVisible();
await heading.hover();
await expect(page.getByRole('tooltip')).toBeHidden();
});
Hovering the heading is clearer than moving to coordinate 0,0. It records where the simulated user went and uses Locator actionability.
For adjacent cards, assert state transfer:
const basic = page.getByRole('article', { name: 'Basic plan' });
const team = page.getByRole('article', { name: 'Team plan' });
await basic.hover();
await expect(basic).toHaveAttribute('data-highlighted', 'true');
await team.hover();
await expect(basic).not.toHaveAttribute('data-highlighted', 'true');
await expect(team).toHaveAttribute('data-highlighted', 'true');
If the product intentionally delays closing, toBeHidden retries through the grace period. Keep the assertion timeout aligned with an agreed requirement. A long global timeout can mask a regression in that delay, while a fixed sleep makes the test slower even when cleanup is immediate.
8. Network-Backed Hover Content Example
Some previews fetch data only after hover. Synchronize with the observable response and UI, not a sleep. Set the response wait before triggering the request so the test cannot miss it.
test('loads a customer preview on hover', async ({ page }) => {
await page.goto('/customers');
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/customers/42/preview') &&
response.status() === 200
);
await page.getByRole('row', { name: /Avery Chen/ }).hover();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const preview = page.getByRole('dialog', { name: 'Customer preview' });
await expect(preview).toContainText('Avery Chen');
await expect(preview).toContainText('Active');
});
If the response body itself is not the contract, the UI assertion may be sufficient. Add the response wait when it improves diagnosis or when the UI has an indistinguishable cached state.
For deterministic error coverage, intercept the preview request:
await page.route('**/api/customers/42/preview', async route => {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'Preview unavailable' })
});
});
await page.getByRole('row', { name: /Avery Chen/ }).hover();
await expect(page.getByRole('alert')).toHaveText(
'Preview is temporarily unavailable'
);
Register the route before navigation or before the interaction that can issue the request. Keep response fixtures close to the API schema so mocks do not drift from production behavior.
9. Debug and Stabilize Hover Scenarios
When a test flakes, classify the failure before changing code:
| Failure point | Evidence | Likely direction |
|---|---|---|
| Locator resolution | Strictness or not-found error | Improve identity or scope |
| Actionability | Receives-events, stability, or visibility log | Inspect overlay, motion, or scroll |
| Hover result | Action completes, assertion fails | Check handler, state, delay, or result locator |
| Follow-up click | Menu vanishes or target detaches | Inspect pointer corridor and rerender |
| Screenshot | Pixel diff only | Control environment or review design change |
Run one case headed and enable tracing on the first retry. Inspect the action snapshot at hover, the element chosen, pointer interception messages, console errors, and any lazy request. Repeat the single case to find intermittent timing: npx playwright test tests/hover.spec.ts --repeat-each=20.
Trial mode separates readiness from outcome:
const trigger = page.getByRole('button', { name: 'Resources' });
await trigger.hover({ trial: true });
await trigger.hover();
await expect(page.getByRole('menu', { name: 'Resources' })).toBeVisible();
Do not leave trial calls everywhere after diagnosis. Normal hover already waits for actionability. Do not add waitForTimeout, blanket retries, or force until you understand the failing layer. The Playwright trace viewer debugging guide can help teams use the same evidence-first workflow locally and in CI.
10. Playwright Hover Examples and Best Practices for Frameworks
A helper should expose domain intent, accept stable inputs, and preserve useful assertions. It should not become a generic performHover(selector) wrapper that adds no meaning.
import { expect, type Locator, type Page } from '@playwright/test';
export class CatalogPage {
readonly cards: Locator;
constructor(private readonly page: Page) {
this.cards = page.getByTestId('product-card');
}
productCard(name: string): Locator {
return this.cards.filter({
has: this.page.getByRole('heading', { name, exact: true })
});
}
async openQuickView(name: string): Promise<void> {
const card = this.productCard(name);
await card.hover();
await card.getByRole('button', { name: 'Quick view' }).click();
await expect(
this.page.getByRole('dialog', { name })
).toBeVisible();
}
}
Keep assertions that define the operation's postcondition inside the domain method, while tests assert scenario-specific content. Use fixtures for authentication and data ownership, not for sharing mutable pages across tests.
A practical hover suite should cover one happy desktop path per important component, keyboard or touch alternatives for essential actions, cleanup where state can stick, and one error case for network-backed previews. Avoid multiplying identical cases across every card and browser without a risk reason. Cross-browser coverage is valuable for pointer and CSS behavior, but data permutations can often run in one project.
Treat accessibility names as a contract. A role locator improves readability and can expose missing semantics, but it does not replace an accessibility audit. Keep hover-only content nonessential or make it accessible through focus, tap, or a persistent control.
A practical review rubric
Review each example against five questions. First, does the locator identify the product concept rather than DOM structure? Second, does the action represent a path available to the intended desktop user? Third, does the assertion verify content or behavior meaningful to that user? Fourth, are data, viewport, and network inputs controlled enough to reproduce the state? Fifth, will a CI failure contain evidence that distinguishes actionability, application response, and environment problems?
The rubric helps prevent accidental test inflation. A catalog does not need one identical hover test for every product if the component implementation and behavior are shared. Select representative data boundaries and add separate cases only when configuration, content length, permissions, or browser behavior changes the risk. Keep spatial cases separate from ordinary controls because coordinate assumptions require different maintenance.
Also review accessibility and input parity. If hover reveals a purchase, delete, help, or navigation action, identify its keyboard and touch route. The alternative does not need to use the same event, but it must expose equivalent capability. Test focus behavior with keyboard semantics and tap behavior in a touch-enabled project. A desktop pointer pass should never be used to claim mobile coverage.
Finally, confirm the failure policy. Retrying assertions are normal synchronization. Retrying the whole test is containment and evidence collection, not proof of reliability. A recurring hover failure needs a cause, owner, and product-risk assessment. This keeps examples useful when they grow into a suite rather than becoming isolated demonstrations that only pass on one laptop.
Interview Questions and Answers
Q: What makes a Playwright hover test reliable?
It uses a stable, meaningful locator, awaits locator.hover(), and verifies an observable result with a web-first assertion. Test data and viewport are controlled where they affect the outcome. Fixed sleeps and broad force options are absent.
Q: How do you test a hover menu containing repeated labels?
Scope the trigger and menu to a navigation landmark or component root. Then locate items inside that scope. This prevents a label shared by mobile, desktop, or footer navigation from creating ambiguity.
Q: Should you wait for a network response after hover?
Wait when hover intentionally initiates a request and response evidence improves synchronization or diagnosis. Create the response promise before hover, then assert the rendered UI. Do not wait for unrelated network silence.
Q: How do you test a chart hover?
Use a controlled dataset and viewport, wait for the chart readiness signal, then use locator.hover with an element-relative position. Assert a value associated with that point. Use page.mouse only when a movement path is part of behavior.
Q: How do you test hover-out behavior?
Hover the trigger, assert the open state, then hover a meaningful safe element and assert the content becomes hidden or state transfers. A web-first hidden assertion naturally accommodates an intentional close delay.
Q: Why avoid dispatchEvent for end-to-end hover coverage?
dispatchEvent can invoke an event listener without real pointer movement, hit testing, scrolling, or actionability. It can pass while an overlay prevents a user from hovering. Use it only for a narrow event-level test with a documented reason.
Common Mistakes
- Using a global locator when every card has the same hover action.
- Selecting first() to silence strictness instead of identifying the intended component.
- Waiting a fixed number of milliseconds for a tooltip animation or lazy request.
- Moving the pointer with viewport coordinates when an element-relative position is available.
- Asserting only that a hidden button appeared, without clicking it or verifying its outcome.
- Forcing hover through a banner or overlay that blocks a real user.
- Using live chart data with hard-coded coordinates and expected labels.
- Capturing broad screenshots without controlling animation, fonts, viewport, or data.
- Testing desktop hover in a mobile emulation instead of the supported tap path.
- Ignoring hover exit, focus behavior, and error responses.
Conclusion
The best Playwright hover examples are small, outcome-focused scenarios. Use Locator.hover for menus, cards, and tooltips, add element-relative position for spatial widgets, and choose page.mouse only when pointer travel matters. Every action should lead to a precise assertion.
Adapt the scenario closest to your UI, replace demonstration data with a controlled fixture, and run it in a headed browser plus CI tracing. Once the happy path is stable, add the keyboard, touch, cleanup, or network error case that carries the most user risk.
Interview Questions and Answers
Show the standard pattern for a Playwright hover test.
I arrange a deterministic page state, locate the trigger by user-facing semantics, and await trigger.hover(). I then use a web-first assertion for the visible result. If the hover exposes an action, I exercise it and verify the final outcome.
How do you hover a nested menu reliably?
I scope both levels to the navigation root, hover the top trigger, and assert the first menu before hovering its submenu item. I move directly through meaningful locators so the pointer remains in the intended interaction area. I verify the final navigation or content.
How do you handle repeated hover controls?
I identify the enclosing card or row by stable content and locate the control within it. I avoid first() and nth() unless ordering is part of the requirement. This preserves intent and allows locator strictness to catch ambiguity.
How do you test delayed tooltip behavior?
I hover the trigger and assert the tooltip with a retrying assertion. The timeout should reflect the requirement if a deliberate delay exists. I do not add waitForTimeout because it adds latency without proving readiness.
When is page.mouse.move appropriate?
It is appropriate for canvases, pointer paths, crosshair movement, and applications that react to intermediate mousemove events. I obtain a fresh bounding box, check it for null, and use main-frame CSS coordinates. I use locator.hover for normal elements.
How do you test a lazy-loaded preview?
I register a targeted response wait before hovering, perform the hover, await the response if it is part of the synchronization contract, and assert the rendered preview. For error coverage, I register page.route before the request and fulfill a realistic failure response.
What should a hover page object expose?
It should expose domain intent such as openQuickView for a named product, not a generic selector wrapper. It may assert the operation postcondition while leaving scenario-specific assertions in the test. It should not hide sleeps, force, or catch errors.
How do you diagnose hover flakiness?
I classify whether failure occurs during locator resolution, actionability, result rendering, a follow-up action, or visual comparison. I inspect a trace and repeat the focused test. Trial mode can confirm whether the trigger is actionable without invoking the hover result.
Frequently Asked Questions
What is a basic Playwright hover example?
Locate the target, call await locator.hover(), and assert the result. For example, hover a button by role and expect its associated menu or tooltip to become visible.
How do I test a tooltip on hover in Playwright?
Hover the accessible trigger and locate the tooltip by role or exact content. Use toBeVisible, toHaveText, or toContainText so the assertion retries through the component delay.
How do I hover one card when many cards repeat?
Identify the card by accessible name, heading, or stable test id plus filter. Call hover and locate the action within that card instead of using a global locator or first().
Can Playwright hover over a chart point?
Yes. Use locator.hover({ position: { x, y } }) with controlled data and a known chart size. Assert the tooltip value associated with the selected element-relative coordinate.
How should I wait for content loaded by hover?
Create a targeted waitForResponse promise before hover when the response matters, then await it and assert the UI. Often a web-first UI assertion alone is enough, and neither pattern needs a fixed sleep.
How do I remove hover in Playwright?
Move the pointer to another meaningful visible Locator by calling hover on it. Then assert that the old tooltip or menu is hidden, or that highlighted state transfers to the new target.
Should hover tests use screenshots?
Use locator screenshot assertions when appearance itself is an important contract. Control animation, browser, viewport, data, fonts, and other rendering inputs, and prefer functional assertions for routine behavior.
Related Guides
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices
- Playwright APIRequestContext: Examples and Best Practices
- Playwright aria snapshot: Examples and Best Practices