Practice: test keyboard navigation
Turn "test keyboard navigation" into a concrete interview exercise. Explain the risk, choose the smallest useful test boundary, and describe how the signal prevents regressions.
Answer Strategy
Keyboard navigation testing is the question that exposes whether you test like a sighted-mouse user or like a screen-reader user. The interview win is naming the WAI-ARIA pattern (toolbar, tabs, listbox, menu, grid) the component matches, then driving the keyboard contract the pattern requires. Roving tabindex toolbars need ArrowLeft/ArrowRight, Home/End, and a Tab that LEAVES the toolbar, not cycles inside it.
Tests query by role, not class. screen.getByRole('button', { name: 'Bold' }) proves the accessible name exists; screen.getByText('Bold') only proves the visual label rendered. user-event provides the realistic keyboard sequence; fireEvent.keyDown skips composition events, focus changes, and the actual default browser behavior. Pairing role queries with user-event keyboard runs is the canonical setup.
Volunteer the failures. Tests that focus by data-testid bypass the accessible name check and silently allow a button with no label. Tests that assert tabindex without checking which element actually has DOM focus miss bugs where focus moved but state did not (or vice versa). Tests that drive Tab without rendering a sibling outside the component miss the Tab-leaves-the-region contract. The reference shows the canonical: query by role, use user-event, assert focus AND tabindex, render a sibling for Tab-out tests.
Reference Implementation: Toolbar Keyboard Tests With user-event
A roving-tabindex Toolbar plus the keyboard test suite a senior interviewer expects: ArrowLeft/Right wrap, Home/End jump, Tab leaves the region.
// Component under test: a small toolbar that uses roving tabindex.
type ToolbarProps = {
actions: Array<{ id: string; label: string; onRun: () => void }>;
};
export function Toolbar({ actions }: ToolbarProps) {
const [activeIndex, setActiveIndex] = React.useState(0);
const buttons = React.useRef<Array<HTMLButtonElement | null>>([]);
function moveFocus(index: number) {
const next = ((index % actions.length) + actions.length) % actions.length;
setActiveIndex(next);
buttons.current[next]?.focus();
}
return (
<div role="toolbar" aria-label="Document actions">
{actions.map((action, index) => (
<button
key={action.id}
ref={(node)=> {
buttons.current[index]= node;
}}
type="button"
tabIndex={index= activeIndex ? 0 : -1}
onClick={action.onRun}
onKeyDown={(event)=> {
if (event.key= 'ArrowRight') {
event.preventDefault();
moveFocus(index + 1);
} else if (event.key= 'ArrowLeft') {
event.preventDefault();
moveFocus(index - 1);
} else if (event.key= 'Home') {
event.preventDefault();
moveFocus(0);
} else if (event.key= 'End') {
event.preventDefault();
moveFocus(actions.length - 1);
}
}}
>
{action.label}
</button>
))}
</div>
);
}Executable UI Sandbox
UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.
Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('Toolbar keyboard navigation', () => {
const actions = [
{ id: 'bold', label: 'Bold', onRun: vi.fn() },
{ id: 'italic', label: 'Italic', onRun: vi.fn() },
{ id: 'link', label: 'Link', onRun: vi.fn() },
];
it('only the active button is in the page tab order', () => {
render(<Toolbar actions={actions} />);
const bold = screen.getByRole('button', { name: 'Bold' });
const italic = screen.getByRole('button', { name: 'Italic' });
expect(bold).toHaveAttribute('tabindex', '0');
expect(italic).toHaveAttribute('tabindex', '-1');
});
it('ArrowRight moves focus and tabindex', async () => {
const user = userEvent.setup();
render(<Toolbar actions={actions} />);
screen.getByRole('button', { name: 'Bold' }).focus();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: 'Italic' })).toHaveFocus();
expect(screen.getByRole('button', { name: 'Italic' })).toHaveAttribute('tabindex', '0');
expect(screen.getByRole('button', { name: 'Bold' })).toHaveAttribute('tabindex', '-1');
});
it('ArrowLeft from the first button wraps to the last', async () => {
const user = userEvent.setup();
render(<Toolbar actions={actions} />);
screen.getByRole('button', { name: 'Bold' }).focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: 'Link' })).toHaveFocus();
});
it('Home and End jump to extremes', async () => {
const user = userEvent.setup();
render(<Toolbar actions={actions} />);
screen.getByRole('button', { name: 'Italic' }).focus();
await user.keyboard('{End}');
expect(screen.getByRole('button', { name: 'Link' })).toHaveFocus();
await user.keyboard('{Home}');
expect(screen.getByRole('button', { name: 'Bold' })).toHaveFocus();
});
it('Tab leaves the toolbar entirely instead of cycling between buttons', async () => {
const user = userEvent.setup();
render(
<>
<Toolbar actions={actions} />
<button type="button">Outside</button>
</>
);
screen.getByRole('button', { name: 'Bold' }).focus();
await user.keyboard('{Tab}');
expect(screen.getByRole('button', { name: 'Outside' })).toHaveFocus();
});
});Interviewer Signal
Shows whether you can prove frontend behavior instead of relying on screenshots or manual confidence.
Constraints
- Choose unit, integration, E2E, visual, or performance testing deliberately.
- State the failure that the test catches.
- Avoid brittle assertions that lock implementation details.
Model Answer Shape
- Start with the user-impacting behavior.
- Pick the smallest test that sees that behavior.
- Add one higher-level test only when timing, browser behavior, or integration risk requires it.
Tradeoffs
- Unit tests are fast and precise but cannot prove browser wiring.
- E2E tests are realistic but should be reserved for workflows where integration risk matters.
Edge Cases
- Out-of-order async results.
- Environment-specific browser behavior.
- False confidence from mocks that do not match production contracts.
Testing And Proof
- Regression case for the named risk.
- Negative path or error state.
- Cleanup or retry behavior when relevant.
Follow-Ups
- What would make this test flaky?
- What would you monitor after shipping the fix?