QA Engineer Skills 2026QA-2026Keyboard Navigation Testing

Keyboard Navigation Testing

Why Keyboard Testing Matters

If your checkout flow is not completable via keyboard only, you are excluding users who cannot use a mouse -- people with motor disabilities, power users who prefer keyboard navigation, and anyone with a temporary injury. Keyboard accessibility is also a legal requirement under WCAG 2.1.1 (Keyboard) and 2.4.7 (Focus Visible).


Complete Flow Testing via Keyboard

// tests/accessibility/keyboard-navigation.spec.ts
import { test, expect } from '@playwright/test';

test('entire checkout flow is completable via keyboard only', async ({ page }) => {
    await page.goto('/cart');

    // Tab to "Proceed to Checkout" button
    let attempts = 0;
    while (attempts < 20) {
        await page.keyboard.press('Tab');
        const focusedText = await page.evaluate(() =>
            document.activeElement?.textContent?.trim()
        );
        if (focusedText?.includes('Proceed to Checkout')) break;
        attempts++;
    }
    await page.keyboard.press('Enter');

    // Verify we reached the checkout page
    await expect(page).toHaveURL(/\/checkout/);

    // Fill shipping form via keyboard
    await page.keyboard.press('Tab'); // Focus first field
    await page.keyboard.type('John Doe');
    await page.keyboard.press('Tab');
    await page.keyboard.type('123 Main St');
    await page.keyboard.press('Tab');
    await page.keyboard.type('Springfield');
    await page.keyboard.press('Tab');

    // Select state from dropdown via keyboard
    await page.keyboard.press('Space'); // Open dropdown
    await page.keyboard.type('Il'); // Type to search
    await page.keyboard.press('Enter'); // Select Illinois

    await page.keyboard.press('Tab');
    await page.keyboard.type('62701'); // ZIP code

    // Tab to submit and press Enter
    attempts = 0;
    while (attempts < 10) {
        await page.keyboard.press('Tab');
        const focusedRole = await page.evaluate(() =>
            document.activeElement?.getAttribute('type')
        );
        if (focusedRole === 'submit') break;
        attempts++;
    }
    await page.keyboard.press('Enter');

    // Verify order confirmation
    await expect(page.locator('[data-testid="order-confirmation"]')).toBeVisible();
});

Focus Trap Testing

Modal dialogs must trap focus -- Tab should cycle through elements inside the modal without escaping to the page behind it. This prevents users from accidentally interacting with content they cannot see.

test('focus trap works in modal dialogs', async ({ page }) => {
    await page.goto('/');

    // Open a modal
    await page.click('[data-testid="open-modal"]');
    await expect(page.locator('[role="dialog"]')).toBeVisible();

    // Collect all focusable elements in the modal
    const modalFocusable = await page.evaluate(() => {
        const modal = document.querySelector('[role="dialog"]');
        if (!modal) return [];
        const focusable = modal.querySelectorAll(
            'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
        );
        return Array.from(focusable).map(el => ({
            tag: el.tagName,
            text: el.textContent?.trim().substring(0, 20),
        }));
    });

    expect(modalFocusable.length).toBeGreaterThan(0);

    // Tab through all elements -- focus should cycle within the modal
    for (let i = 0; i < modalFocusable.length + 2; i++) {
        await page.keyboard.press('Tab');
        const isInModal = await page.evaluate(() => {
            const modal = document.querySelector('[role="dialog"]');
            return modal?.contains(document.activeElement);
        });
        expect(isInModal).toBe(true);
    }

    // Escape should close the modal
    await page.keyboard.press('Escape');
    await expect(page.locator('[role="dialog"]')).not.toBeVisible();

    // Focus should return to the trigger element
    const focusedAfterClose = await page.evaluate(() =>
        document.activeElement?.getAttribute('data-testid')
    );
    expect(focusedAfterClose).toBe('open-modal');
});

Tab Order Verification

test('tab order follows logical visual flow', async ({ page }) => {
    await page.goto('/checkout');

    const tabOrder: string[] = [];

    // Press Tab through the page and record the order
    for (let i = 0; i < 30; i++) {
        await page.keyboard.press('Tab');
        const label = await page.evaluate(() => {
            const el = document.activeElement;
            return el?.getAttribute('aria-label')
                || el?.getAttribute('name')
                || el?.textContent?.trim().substring(0, 30)
                || el?.tagName
                || 'unknown';
        });
        tabOrder.push(label);
    }

    // Verify the expected tab order for checkout
    const expectedOrder = [
        'Full Name',
        'Email',
        'Address Line 1',
        'Address Line 2',
        'City',
        'State',
        'ZIP Code',
        'Continue to Payment',
    ];

    // Each expected element should appear in the tab order in sequence
    let lastIndex = -1;
    for (const expected of expectedOrder) {
        const index = tabOrder.findIndex(
            (label, i) => i > lastIndex && label.includes(expected)
        );
        expect(index).toBeGreaterThan(lastIndex);
        lastIndex = index;
    }
});

Skip Links

Skip links allow keyboard users to bypass repetitive navigation and jump directly to main content:

test('skip link is available and functional', async ({ page }) => {
    await page.goto('/');

    // Press Tab once -- skip link should be the first focusable element
    await page.keyboard.press('Tab');
    const skipLink = page.locator(':focus');
    const text = await skipLink.textContent();
    expect(text?.toLowerCase()).toContain('skip to');

    // Skip link should be visible when focused
    await expect(skipLink).toBeVisible();

    // Activate the skip link
    await page.keyboard.press('Enter');

    // Focus should move to the main content area
    const focusedId = await page.evaluate(() => document.activeElement?.id);
    expect(focusedId).toBe('main-content');
});

Keyboard Interaction Patterns

Different component types have expected keyboard behaviors defined by WAI-ARIA:

Component Tab Enter/Space Arrow Keys Escape
Button Focus Activate N/A N/A
Link Focus Navigate N/A N/A
Checkbox Focus Toggle N/A N/A
Radio group Focus group Select Move selection N/A
Dropdown/Select Focus Open Navigate options Close
Tab panel Focus active tab N/A Switch tabs N/A
Modal dialog Focus first element N/A N/A Close modal
Menu Focus first item Activate Navigate items Close menu
Accordion Focus header Toggle section N/A N/A
test('dropdown keyboard interactions follow WAI-ARIA patterns', async ({ page }) => {
    await page.goto('/form');

    // Tab to the dropdown
    const dropdown = page.locator('[role="listbox"]');
    await dropdown.focus();

    // Space opens the dropdown
    await page.keyboard.press('Space');
    await expect(page.locator('[role="option"]').first()).toBeVisible();

    // Arrow down navigates options
    await page.keyboard.press('ArrowDown');
    const focused = await page.evaluate(() =>
        document.activeElement?.getAttribute('aria-selected')
    );
    expect(focused).toBe('true');

    // Enter selects the option
    await page.keyboard.press('Enter');
    await expect(page.locator('[role="option"]').first()).not.toBeVisible();

    // Escape closes without selecting
    await page.keyboard.press('Space'); // Reopen
    await page.keyboard.press('Escape');
    await expect(page.locator('[role="option"]').first()).not.toBeVisible();
});

Keyboard navigation testing catches real user pain. If your most critical flows are not completable via keyboard, you are both excluding users and inviting legal action. Test every critical user journey with keyboard-only interaction.