UI Interaction Testing Guide

How to Test Right Click Menu in Playwright: Complete Context Menu Testing Guide

A scenario-by-scenario walkthrough of testing custom context menus with Playwright. Dispatching the contextmenu event, asserting cursor-relative positioning, navigating nested sub-menus with the keyboard, verifying dismiss on outside click, and handling platform differences that break real test suites.

93%

According to the 2025 WebAIM Million report, 93% of the top one million home pages had detectable accessibility errors, with missing keyboard operability being one of the most common failures in custom interactive widgets like context menus.

WebAIM Million 2025

0Context menu scenarios covered
0+Dismiss triggers tested
0Keyboard nav patterns
0%Fewer lines with Assrt

Context Menu Lifecycle

UserBrowserApp JSDOMMenu ComponentRight-click on elementDispatch contextmenu eventpreventDefault() to suppress native menuCalculate position from clientX/clientYRender menu at computed coordinatesClick item or navigate with keyboardFire action callbackRemove menu from DOM

1. Why Testing Context Menus Is Harder Than It Looks

Custom context menus (right-click menus) are one of the most deceptively tricky UI patterns to test with end-to-end tools. Unlike a regular click handler, a context menu depends on the contextmenu browser event, which must be dispatched with precise clientX and clientY coordinates. The menu component then reads those coordinates to position itself relative to the cursor. If your test triggers the event without coordinates, the menu may render at (0, 0) or not appear at all.

The complexity grows quickly. Most production context menus support nested sub-menus that open on hover or arrow key navigation. Each sub-menu must be positioned so it does not overflow the viewport, which means edge cases at window borders. The menu must close when the user clicks outside of it, presses Escape, scrolls the page, or switches tabs. It must also support full keyboard navigation: Arrow Down and Arrow Up to move between items, Arrow Right to open a sub-menu, Arrow Left to close it, Enter to activate, and Home/End to jump to the first or last item. According to the WAI-ARIA Menu Pattern, all of these keyboard interactions are expected for an accessible menu widget.

There are five structural reasons context menu testing breaks in practice. First, Playwright's click({ button: 'right' }) dispatches a real contextmenu event, but many tutorials incorrectly use dispatchEvent without coordinates, producing a synthetic event that skips the positioning logic. Second, the menu is often rendered via a portal (attached to document.body rather than the triggering element), so locators scoped to the parent container will miss it. Third, sub-menus open on hover with a debounce delay, and tests that move the mouse too quickly will never see the sub-menu appear. Fourth, the menu can be conditionally rendered based on what was right-clicked (different items for files vs. folders, for example), so your test must right-click the exact target. Fifth, browser-native context menus can interfere: if the app fails to call preventDefault() on the contextmenu event, the native browser menu appears on top of the custom one, and Playwright cannot interact with native browser menus at all.

Context Menu Event Lifecycle

🌐

Right-Click

User action on target element

⚙️

contextmenu Event

Dispatched with clientX/clientY

🔒

preventDefault()

Suppresses native menu

⚙️

Position Calculation

Menu placed at cursor coords

🌐

Render Menu

Portal to document.body

User Interaction

Click, keyboard, or dismiss

2. Setting Up a Reliable Test Environment

Context menu tests are sensitive to viewport size. A menu that fits comfortably at a 1280x720 viewport may overflow and reposition at 800x600. Pin your viewport in the Playwright config to get deterministic positioning results.

playwright.config.ts

You also need a page with a custom context menu to test against. For this guide, we will use a file manager UI where right-clicking a file row opens a context menu with actions like Rename, Delete, Copy Path, and a “Move to” sub-menu with folder options. This is a common real-world pattern seen in applications like VS Code, Figma, Notion, and Google Drive.

Project Setup

Test Helper: Triggering the Context Menu

Playwright's built-in click({ button: 'right' }) is the correct way to trigger a context menu. It dispatches a real contextmenu event with valid coordinates based on where the target element is in the viewport. Avoid using page.dispatchEvent() manually unless you need to right-click at a specific pixel offset within an element.

tests/context-menu/helpers.ts
1

Basic Right-Click Opens the Menu

Straightforward

Goal:Verify that right-clicking a target element opens the custom context menu instead of the browser's native context menu. The custom menu should render in the DOM, be visible, and contain the expected menu items.

Preconditions: The page contains a list of file items. Each file row has a data-testid attribute for targeting. The context menu component uses role="menu" and each item uses role="menuitem" per WAI-ARIA.

tests/context-menu/basic.spec.ts

What to Assert Beyond the UI

Verify that the contextmenu event was intercepted (the native menu never appears). Check that the menu element has the correct ARIA attributes: role="menu", aria-label or aria-labelledby, and that focus management is correct (the first menu item should receive focus when the menu opens).

Basic Right-Click: Playwright vs Assrt

import { test, expect } from '@playwright/test';

test('right-click opens context menu', async ({ page }) => {
  await page.goto('/files');

  const fileRow = page.locator('[data-testid="file-row-report.pdf"]');
  await fileRow.click({ button: 'right' });

  const menu = page.getByRole('menu');
  await expect(menu).toBeVisible();

  await expect(menu.getByRole('menuitem', { name: 'Rename' }))
    .toBeVisible();
  await expect(menu.getByRole('menuitem', { name: 'Delete' }))
    .toBeVisible();
  await expect(menu.getByRole('menuitem', { name: 'Copy Path' }))
    .toBeVisible();
  await expect(menu.getByRole('menuitem', { name: 'Move to' }))
    .toBeVisible();

  const menus = await page.getByRole('menu').count();
  expect(menus).toBe(1);
});
45% fewer lines
2

Menu Positioning Relative to Cursor

Moderate

Goal: Verify that the context menu renders at the cursor position, not at a fixed location or at (0, 0). Also verify that the menu repositions when right-clicking near a viewport edge to avoid overflow.

Preconditions: The viewport is pinned at 1280x720. The context menu has a known width (approximately 200px) and known item height. The menu uses position: fixed with top and leftset from the event's clientX and clientY.

tests/context-menu/positioning.spec.ts

What to Assert Beyond the UI

Check the computed CSS top and left values on the menu element, not just visual positioning. Verify that the menu does not cause a horizontal scrollbar when opened near viewport edges. Use page.viewportSize() to calculate expected bounds programmatically rather than hardcoding pixel values.

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started
5

Full Keyboard Navigation

Complex

Goal: Verify that the context menu supports full keyboard navigation per the WAI-ARIA Menu Pattern: Arrow Down/Up to move between items, Arrow Right to open sub-menus, Arrow Left to close them, Enter to activate, Escape to close the menu, and Home/End to jump to the first/last item.

Preconditions: The context menu is open. Focus is on the first menu item. The menu component manages a roving tabindex so only one item is tabbable at a time.

tests/context-menu/keyboard.spec.ts

What to Assert Beyond the UI

Verify that only one menu item has tabindex="0" at a time (roving tabindex pattern). All other items should have tabindex="-1". Check that aria-haspopup="true" is set on items that have sub-menus, and that aria-expanded toggles correctly when the sub-menu opens and closes.

Keyboard Navigation: Playwright vs Assrt

import { test, expect } from '@playwright/test';

test('arrow keys navigate menu items', async ({ page }) => {
  await page.goto('/files');
  const fileRow = page.locator('[data-testid="file-row-report.pdf"]');
  await fileRow.click({ button: 'right' });

  const menu = page.getByRole('menu');
  const items = menu.getByRole('menuitem');

  await expect(items.first()).toBeFocused();

  await page.keyboard.press('ArrowDown');
  await expect(items.nth(1)).toBeFocused();

  await page.keyboard.press('ArrowDown');
  await expect(items.nth(2)).toBeFocused();

  await page.keyboard.press('End');
  await expect(items.last()).toBeFocused();

  await page.keyboard.press('Home');
  await expect(items.first()).toBeFocused();

  await page.keyboard.press('ArrowDown');
  await page.keyboard.press('ArrowDown');
  await page.keyboard.press('ArrowDown');
  await page.keyboard.press('ArrowRight');

  const subMenu = page.getByRole('menu').nth(1);
  await expect(subMenu).toBeVisible();
});
46% fewer lines
6

Dismiss on Outside Click, Escape, and Scroll

Moderate

Goal: Verify that the context menu closes in all expected dismissal scenarios: clicking outside the menu, pressing Escape, scrolling the page, right-clicking a different element, and resizing the window.

Preconditions: The context menu is open. The page has enough content to be scrollable. The dismiss behavior is implemented via a combination of click-outside detection, keydown listeners, and scroll/resize event handlers.

tests/context-menu/dismiss.spec.ts
Running the Dismiss Tests

9. Common Pitfalls That Break Context Menu Tests

These pitfalls are sourced from real Playwright GitHub issues and Stack Overflow threads. Each one has caused production test suites to fail intermittently or completely.

Context Menu Testing Anti-Patterns

  • Using dispatchEvent('contextmenu') without clientX/clientY coordinates. The menu renders at (0, 0) or does not appear. Always use Playwright's built-in click({ button: 'right' }) which dispatches the event with correct coordinates from the element's bounding box.
  • Asserting menu visibility immediately after right-click without waiting. Many menu components use a requestAnimationFrame or short setTimeout before rendering. Use expect(menu).toBeVisible() which retries automatically rather than a raw DOM check.
  • Moving the mouse too quickly over sub-menu triggers. Sub-menus typically have a 100-300ms debounce. Playwright's hover() is instantaneous, so the sub-menu may never open. Add an explicit waitFor after hover or use expect(subMenu).toBeVisible({ timeout: 2000 }).
  • Forgetting that context menus render in portals. If your menu component portals to document.body, locators scoped to a parent container (like page.locator('.file-list').getByRole('menu')) will never find it. Use page.getByRole('menu') at the page level.
  • Not handling the native browser context menu fallback. If the app's event handler throws an error before calling preventDefault(), the native context menu appears instead. Playwright cannot interact with native menus, so your test hangs waiting for a [role='menu'] that never renders.
  • Testing in headless mode without a fixed viewport. Context menu positioning depends on viewport dimensions. Without a pinned viewport in playwright.config.ts, different CI environments produce different positioning results, causing flaky bounding box assertions.
  • Not verifying focus management after menu close. The WAI-ARIA Menu Pattern requires focus to return to the triggering element when the menu closes. A broken focus restore can cause the next keyboard action in your test to target the wrong element entirely.
  • Relying on CSS animation completion for visibility checks. If the menu uses a fade-in or slide-in animation, toBeVisible() may pass before the animation completes. Subsequent bounding box measurements return intermediate values. Wait for the animation to finish using page.waitForFunction or check the computed animationPlayState.

What Good Context Menu Tests Look Like

Context Menu Testing Best Practices

  • Always use click({ button: 'right' }) instead of manually dispatching contextmenu events
  • Pin viewport dimensions in playwright.config.ts for deterministic positioning
  • Use page-level locators (page.getByRole('menu')) for portaled menus
  • Add explicit waits after hover() for sub-menu debounce timers
  • Test all five dismiss triggers: Escape, outside click, scroll, resize, and right-click elsewhere
  • Verify ARIA attributes: role='menu', role='menuitem', aria-haspopup, aria-expanded, roving tabindex
  • Assert focus returns to the triggering element after menu close
Full Test Suite Run

10. Writing Context Menu Tests in Plain English with Assrt

The Playwright scenarios above total around 300 lines of TypeScript across six test files. Each test contains implementation details like getByRole('menu'), boundingBox(), and keyboard.press('ArrowDown') that couple the test to the menu component's DOM structure. When your team refactors the context menu (swapping Radix UI for a custom implementation, changing from portal rendering to inline, updating ARIA roles), every test file breaks.

Assrt lets you describe context menu behavior in plain English. Each .assrtfile declares what to test, not how to find it. Assrt compiles these into real Playwright TypeScript, committed to your repo as runnable tests. When the menu's DOM structure changes, Assrt detects the failure, analyzes the updated markup, and opens a pull request with fixed selectors. Your scenario files remain stable.

scenarios/context-menu-full-suite.assrt

Seven scenarios, 65 lines of plain English. The equivalent Playwright TypeScript is approximately 300 lines across multiple files. When the menu component changes its rendering strategy, portal target, or ARIA structure, the Assrt scenarios stay the same. Assrt handles the locator updates automatically.

Full Context Menu Suite: Playwright vs Assrt

// tests/context-menu/basic.spec.ts (45 lines)
// tests/context-menu/positioning.spec.ts (75 lines)
// tests/context-menu/actions.spec.ts (55 lines)
// tests/context-menu/submenus.spec.ts (65 lines)
// tests/context-menu/keyboard.spec.ts (85 lines)
// tests/context-menu/dismiss.spec.ts (70 lines)

// 6 files, ~300 lines of Playwright TypeScript
// Every file coupled to DOM structure, ARIA roles,
// bounding box calculations, and keyboard event handling.

import { test, expect } from '@playwright/test';

test('right-click opens context menu', async ({ page }) => {
  await page.goto('/files');
  const fileRow = page.locator('[data-testid="file-row-report.pdf"]');
  await fileRow.click({ button: 'right' });
  const menu = page.getByRole('menu');
  await expect(menu).toBeVisible();
  // ... 280+ more lines across 6 files
});
78% fewer lines

Start with the basic right-click scenario. Once it passes in CI, add the positioning test, then the sub-menu hover, then keyboard navigation, and finally the dismiss scenarios. In one session you can achieve comprehensive context menu coverage that most applications never manage to build and maintain by hand.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk