SfSkills Domains Docs GitHub

Home / lwc / lwc-jest-testing-with-accessibility

lwc-jest-testing-with-accessibility

Use when authoring or reviewing Jest unit tests for Lightning Web Components and the test plan must include explicit accessibility assertions — covers `@salesforce/sfdx-lwc-jest` setup, `createElement` / `document.body.appendChild` render harness, wire-service mocks via `@salesforce/wire-service-jest-util`, imperative Apex mocks via `jest.fn()`, simulated user interactions (`click`, `keydown`, `focus`), ARIA attribute and accessible-name assertions, focus-management tests, keyboard-navigation tests, and optional `axe-core` integration via `jest-axe`.

Open the skill folder on GitHub


LWC Jest Testing with Accessibility

Activate this skill when the component already has (or needs) Jest tests and the test plan must prove accessibility behavior — not just rendering and event wiring. The skill is the accessibility-deep-dive sibling of lwc/lwc-testing. The core test harness is the same; the difference is what you assert.


Before Starting

Gather this context before writing the test:


Core Concepts

Concept 1 — The createElement + document.body.appendChild harness

Every LWC jest test follows the same render harness:

import { createElement } from 'lwc';
import MyComponent from 'c/myComponent';

describe('c-my-component', () => {
    afterEach(() => {
        // Clear DOM between tests so state doesn't leak.
        while (document.body.firstChild) {
            document.body.removeChild(document.body.firstChild);
        }
        // Clear any leftover async calls.
        jest.clearAllMocks();
    });

    it('renders with the correct accessible name', async () => {
        const element = createElement('c-my-component', { is: MyComponent });
        element.label = 'Customer details';
        document.body.appendChild(element);
        await Promise.resolve(); // wait for first render
        const region = element.shadowRoot.querySelector('[role="region"]');
        expect(region.getAttribute('aria-label')).toBe('Customer details');
    });
});

Two things are non-negotiable:

  1. shadowRoot queries. LWC uses Shadow DOM (synthetic in older API versions, native in newer). Inside the test, query through element.shadowRoot.querySelector(...) — never through document.querySelector, which won't pierce the shadow boundary.
  2. Wait for render before assertions. LWC re-renders asynchronously. await Promise.resolve() flushes the microtask queue and lets pending re-renders settle. Skip this and your assertion runs before the DOM updates, producing flaky tests. For multiple re-render cycles, chain multiple awaits or use a flushPromises() helper.

The same harness handles a11y assertions — there is no special "a11y mode." You use it to read aria-* attributes, query focusable elements, dispatch keyboard events, and check document.activeElement.

Concept 2 — A11y assertions you CAN make in jest

These structural a11y dimensions are reliably testable in the jest jsdom environment:

DimensionTest approach
Static ARIA attributesel.getAttribute('aria-label'), getAttribute('role'), etc.
Dynamic ARIA stateToggle the component, await Promise.resolve(), re-read aria-expanded / aria-pressed / aria-selected
Accessible namegetAttribute('aria-label') OR getAttribute('aria-labelledby') resolved against the referenced element's text
Focus managementexpect(element.shadowRoot.activeElement).toBe(expectedEl) after an action
Keyboard handlersDispatch new KeyboardEvent('keydown', { key: 'Escape' }) and assert state change
tabindex correctnessRead tabindex; assert -1 for programmatically-focused elements, 0 for tab-stops
Live region usageQuery [role="status"] / [aria-live] and assert text content updates after async
aria-describedby wiringRead the attribute, then look up the referenced ID inside shadowRoot to confirm it resolves

These dimensions are NOT reliably testable in jest jsdom — defer to UTAM, real-browser e2e, or manual QA:

Knowing the jest/jsdom boundary is the difference between a useful test and a misleading "looks accessible" badge.

Concept 3 — Mocking @wire and imperative Apex without breaking a11y assertions

Wire and Apex mocks are essential because jest does not have a Salesforce server. Two official patterns:

Wire adapters — use the test wire adapter helpers bundled with @salesforce/sfdx-lwc-jest:

import { createElement } from 'lwc';
import getCases from '@salesforce/apex/CaseController.getCases';
import CaseList from 'c/caseList';

jest.mock(
    '@salesforce/apex/CaseController.getCases',
    () => {
        const { createApexTestWireAdapter } = require('@salesforce/sfdx-lwc-jest');
        return { default: createApexTestWireAdapter(jest.fn()) };
    },
    { virtual: true }
);

it('shows accessible empty state when no cases', async () => {
    const element = createElement('c-case-list', { is: CaseList });
    document.body.appendChild(element);
    getCases.emit([]); // simulate wire returning empty
    await Promise.resolve();
    const empty = element.shadowRoot.querySelector('[role="status"]');
    expect(empty).not.toBeNull();
    expect(empty.textContent).toContain('No cases');
});

Imperative Apex — use jest.mock with jest.fn() returning a Promise:

jest.mock(
    '@salesforce/apex/CaseController.assignCase',
    () => ({ default: jest.fn() }),
    { virtual: true }
);
import assignCase from '@salesforce/apex/CaseController.assignCase';

it('announces success in the live region after assignment', async () => {
    assignCase.mockResolvedValue({ status: 'OK' });
    // ... interact with component, then:
    await Promise.resolve(); // flush click handler
    await Promise.resolve(); // flush mockResolvedValue microtask
    await Promise.resolve(); // flush re-render
    const liveRegion = element.shadowRoot.querySelector('[aria-live="polite"]');
    expect(liveRegion.textContent).toContain('assigned');
});

The { virtual: true } option is required because @salesforce/apex/... is a virtual module resolved at compile time on the platform — jest needs to be told it's OK that the module doesn't exist on disk.

A11y assertions sit immediately after the mocked async settles. The dance with three await Promise.resolve()s exists because click → mock resolution → re-render is three microtasks, not one.

Concept 4 — axe-core integration: structural pass only, with caveats

axe-core (https://github.com/dequelabs/axe-core) can run inside jsdom and check a defined subset of WCAG rules — those that don't depend on rendered layout or computed style. The integration via jest-axe looks like:

import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);

it('has no axe violations in the default state', async () => {
    const element = createElement('c-my-component', { is: MyComponent });
    document.body.appendChild(element);
    await Promise.resolve();
    // axe needs the DOM subtree — pass shadowRoot or the element host.
    const results = await axe(element.shadowRoot);
    expect(results).toHaveNoViolations();
});

What axe-in-jest catches:

What it does NOT catch (because jsdom can't compute layout):

Treat axe(...) in jest as an extra gate against ARIA mistakes, not as a full a11y certificate. The "passes axe in jest" claim should always be paired with a manual or UTAM-based real-browser axe pass before release.


  1. Inventory the component's a11y surface. Open the component template; list every role, aria-*, tabindex, focus target, and keyboard handler. Each is a candidate assertion. If the list is empty, the component may not need a dedicated a11y test (e.g. a thin display-only wrapper around lightning-card).
  2. Confirm the jest setup. package.json should depend on @salesforce/sfdx-lwc-jest. jest.config.js should extend the Salesforce preset (module.exports = require('@salesforce/sfdx-lwc-jest/config');). If missing, follow the current sfdx-lwc-jest README to install. Without these, none of the assertions in this skill will work.
  3. Write the render + accessible-name baseline. Start with one test that renders the component in a default state and asserts the accessible name (aria-label or aria-labelledby chain). This is the cheapest, highest-signal a11y test — if it fails, the component has no accessible identity at all.
  4. Add interaction-driven ARIA-state tests. For every aria-expanded, aria-pressed, aria-selected, or aria-checked your template emits, add one test that toggles the underlying state and asserts the attribute follows. This is where most real a11y regressions happen.
  5. Add focus-management and keyboard-handler tests. If the component manages focus on open/close (modal, popover, accordion) or implements a keyboard handler (Escape to close, Arrow to navigate), add a test that dispatches the event and asserts both the state change AND shadowRoot.activeElement.
  6. Decide on axe-core. If the team wants a fail-fast gate against ARIA mistakes, add jest-axe and a single per-state axe assertion. Document in the PR description that axe-in-jest does NOT replace browser axe checks. If the team prefers minimal ceremony, skip it — explicit assertions are usually more maintainable.
  7. Run the bundled checker against the project. python3 scripts/check_lwc_jest_testing_with_accessibility.py --root force-app/main/default enumerates LWC components without test files, test files without a11y assertions, and package.json lacking the official preset.

Package files