sf-lwc-jest-testing

Covers unit testing Lightning Web Components with @salesforce/sfdx-lwc-jest - jest.config.js setup and the sfdx-lwc-jest preset, createElement/appendChild test anatomy, DOM cleanup, flushing async rer

SKILL.md

GitHub

LWC Jest Testing

When to use

Situation Use this skill
Adding or fixing a __tests__/*.test.js file for an LWC Yes
vf-check jest fails or coverage is below the gate Yes
A component needs wire data, Apex results, or navigation asserted in a test Yes
Writing the component itself Skill sf-lwc-development
Apex unit tests (@IsTest) Skill sf-apex-testing
Verifying behaviour in a real org after deploy Skill sf-post-deploy-verification
Sharing one set of states between the spec and a sf lightning dev component preview Skill sf-local-development, pattern 9
Specs generated by DX MCP tools or the experience-lwc-test skill Yes - review them against this skill; see sf-lwc-development/references/mcp-and-skills.md

Jest runs entirely offline: no org, no authentication, no network. It is the cheapest gate in the pipeline and runs in wave 2 as part of vf-check local.

Quick reference

Task API
Instantiate const el = createElement('c-foo', { is: Foo }); document.body.appendChild(el);
Set a public property Assign before appendChild for initial render, after for a rerender
Clean up while (document.body.firstChild) document.body.removeChild(document.body.firstChild); in afterEach
Wait for rerender await Promise.resolve(); or a flushPromises() helper
Query shadow DOM el.shadowRoot.querySelector('.name'), querySelectorAll
Query light DOM component el.querySelector(...) - there is no shadowRoot
Assert a dispatched event el.addEventListener('rowselect', handler) then assert handler.mock.calls[0][0].detail
Mock an Apex method jest.mock('@salesforce/apex/Cls.method', () => ({ default: jest.fn() }), { virtual: true })
Emit wire data adapter.emit(data) from a __mocks__ module built with createLdsTestWireAdapter
Emit a wire error adapter.error(body, status, statusText) or adapter.emitError({...})
Inspect the resolved wire config adapter.getLastConfig()
Control time jest.useFakeTimers() + jest.advanceTimersByTime(ms)
Run one file npm run test:unit -- src/path/foo.test.js

Core patterns

1. Project setup

{
  "devDependencies": {
    "@salesforce/sfdx-lwc-jest": "^7.0.0"
  },
  "scripts": {
    "test": "npm run test:unit",
    "test:unit": "sfdx-lwc-jest",
    "test:unit:watch": "sfdx-lwc-jest --watch",
    "test:unit:debug": "sfdx-lwc-jest --debug",
    "test:unit:coverage": "sfdx-lwc-jest --coverage"
  }
}
// jest.config.js at the root of the Salesforce DX project
const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config');

module.exports = {
    ...jestConfig,
    moduleNameMapper: {
        '^@salesforce/apex$': '<rootDir>/force-app/test/jest-mocks/apex',
        '^lightning/navigation$': '<rootDir>/force-app/test/jest-mocks/lightning/navigation',
        '^lightning/platformShowToastEvent$':
            '<rootDir>/force-app/test/jest-mocks/lightning/platformShowToastEvent',
        '^lightning/messageService$': '<rootDir>/force-app/test/jest-mocks/lightning/messageService',
        '^lightning/uiRecordApi$': '<rootDir>/force-app/test/jest-mocks/lightning/uiRecordApi'
    },
    collectCoverageFrom: ['force-app/main/default/lwc/**/*.js', '!**/__tests__/**'],
    coverageThreshold: { global: { lines: 80, statements: 80 } },
    testTimeout: 10000
};

sfdx-lwc-jest configures Jest for the DX workspace out of the box; jest.config.js is only needed to override. Stubs for every lightning namespace base component ship with the package and are used automatically. The coverageThreshold mirrors gates.jestCoverageMin (80) from .vibeforce/config.json; vf-check jest enforces the same number independently.

2. Test anatomy

// force-app/main/default/lwc/hello/__tests__/hello.test.js
import { createElement } from 'lwc';
import Hello from 'c/hello';

describe('c-hello', () => {
    afterEach(() => {
        // The jsdom instance is shared across test cases in a single file, so reset the DOM
        while (document.body.firstChild) {
            document.body.removeChild(document.body.firstChild);
        }
        jest.clearAllMocks();
    });

    it('displays the greeting', () => {
        const element = createElement('c-hello', { is: Hello });
        document.body.appendChild(element);

        const div = element.shadowRoot.querySelector('div');
        expect(div.textContent).toBe('Hello, World!');
    });
});

Rules:

  • The tag name passed to createElement must be the kebab-case name with the c- namespace.
  • Nothing renders until document.body.appendChild(element).
  • Never share an element between it blocks.

3. Asynchronous rerenders

// helper used across the suite
async function flushPromises() {
    return Promise.resolve();
}

it('updates the label when the property changes', async () => {
    const element = createElement('c-counter', { is: Counter });
    document.body.appendChild(element);

    element.count = 5;                     // property set after insertion

    await flushPromises();                 // the rerender is enqueued as a microtask

    expect(element.shadowRoot.querySelector('.label').textContent).toBe('5');
});

One await Promise.resolve() flushes one microtask turn. Chained promises (an imperative Apex call that then triggers a rerender) need one flush per turn, or await Promise.resolve().then(() => Promise.resolve()). Never use setTimeout to "wait for" a rerender.

4. Mocking Apex

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

// The LWC Jest transformer resolves @salesforce/apex/* modules; declare the mock as virtual.
jest.mock(
    '@salesforce/apex/CaseController.getCases',
    () => ({ default: jest.fn() }),
    { virtual: true }
);

const MOCK_CASES = [{ Id: '500xx0000000001', Subject: 'Broken widget' }];

describe('c-case-list imperative Apex', () => {
    afterEach(() => {
        while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
        jest.clearAllMocks();
    });

    it('renders rows on success', async () => {
        getCases.mockResolvedValue(MOCK_CASES);

        const element = createElement('c-case-list', { is: CaseList });
        document.body.appendChild(element);
        element.shadowRoot.querySelector('lightning-button').click();

        await Promise.resolve();
        await Promise.resolve();

        expect(getCases).toHaveBeenCalledWith({ ownerId: undefined, maxRows: 25 });
        expect(element.shadowRoot.querySelectorAll('tr')).toHaveLength(1);
    });

    it('renders an error panel on failure', async () => {
        getCases.mockRejectedValue({ body: { message: 'Insufficient access' }, ok: false, status: 400 });

        const element = createElement('c-case-list', { is: CaseList });
        document.body.appendChild(element);
        element.shadowRoot.querySelector('lightning-button').click();

        await Promise.resolve();
        await Promise.resolve();

        expect(element.shadowRoot.querySelector('c-error-panel')).not.toBeNull();
    });
});

Assert both the success and the failure path for every server call - the error branch is where production bugs live.

5. Driving wire adapters

@salesforce/sfdx-lwc-jest re-exports the @salesforce/wire-service-jest-util APIs, so no extra dependency is required.

Factory Use for Emitted shape
createLdsTestWireAdapter(fn) getRecord, getObjectInfo, getPicklistValues, graphql { data, error }, default error 404 NOT_FOUND
createApexTestWireAdapter(fn) @wire(apexMethod) { data, error }, default error 400 Bad Request
createTestWireAdapter(fn) CurrentPageReference, MessageContext, custom adapters Raw value, as emitted
import { createElement } from 'lwc';
import AccountCard from 'c/accountCard';
import { getRecord } from 'lightning/uiRecordApi';
import getOpenCases from '@salesforce/apex/AccountCaseController.getOpenCases';

const mockGetRecord = require('./data/getRecord.json');

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

it('renders the account name from the wire', async () => {
    const element = createElement('c-account-card', { is: AccountCard });
    element.recordId = '001xx000003DGg0AAG';
    document.body.appendChild(element);

    getRecord.emit(mockGetRecord);         // lightning/uiRecordApi is stubbed as a test adapter
    getOpenCases.emit([{ Id: '500xx1', Subject: 'Late shipment' }]);

    await Promise.resolve();

    expect(element.shadowRoot.querySelector('.name').textContent).toBe('Acme');
    expect(getRecord.getLastConfig().recordId).toBe('001xx000003DGg0AAG');
});

Error paths: getRecord.error() emits the default not-found response; getRecord.emitError({ body: { message: 'Boom' }, status: 500, statusText: 'Server Error' }) emits a specific one.

6. Asserting dispatched events

it('dispatches rowselect with the record id', async () => {
    const element = createElement('c-selector', { is: Selector });
    document.body.appendChild(element);

    const handler = jest.fn();
    element.addEventListener('rowselect', handler);

    element.shadowRoot.querySelector('[data-id="003xx1"]').click();
    await Promise.resolve();

    expect(handler).toHaveBeenCalledTimes(1);
    expect(handler.mock.calls[0][0].detail).toEqual({ recordId: '003xx1' });
    expect(handler.mock.calls[0][0].bubbles).toBe(false);
});

For toasts, listen for ShowToastEventName imported from lightning/platformShowToastEvent.

7. What to assert, and what not to

Assert Do not assert
Rendered text, element counts, and attributes a user perceives Internal field names or private method calls
Which branch renders for a given input (loading / data / empty / error) That a getter returns what the field holds
Event name, detail, and propagation flags That dispatchEvent was called
Apex/LDS call arguments (they are the cross-slice contract) Base component internals - they are stubs
Accessibility attributes the template promises (alternative-text, aria-*) CSS class strings with no behavioural meaning

A test that would still pass after the component is broken is worse than no test. Snapshot tests are allowed only for stable presentational markup; a snapshot that is regenerated with -u every sprint is noise and must be deleted.

8. Timers and intervals

it('ticks every second', () => {
    jest.useFakeTimers();
    const element = createElement('c-clock', { is: Clock });
    document.body.appendChild(element);

    jest.advanceTimersByTime(3000);

    expect(element.shadowRoot.querySelector('.tick').textContent).toBe('3');
    document.body.removeChild(element);
    jest.advanceTimersByTime(3000);          // proves disconnectedCallback cleared the interval
    expect(element.shadowRoot.querySelector('.tick').textContent).toBe('3');
    jest.useRealTimers();
});

Anti-patterns

Anti-pattern Consequence Fix
No afterEach DOM cleanup jsdom is shared per file; the second test sees the first component Remove every child of document.body
Asserting immediately after setting a property Rerender is a microtask; assertion runs too early await Promise.resolve() first
setTimeout(..., 0) to wait for a rerender Flaky and slow Flush promises, or fake timers for real timers
jest.mock('@salesforce/apex/...') without { virtual: true } Module not found - the path is synthetic Add the virtual option
Mocking lightning-button with a hand-written stub Base component stubs ship with the package Use the built-in stubs; override only via moduleNameMapper
Testing a wire by calling the Apex mock directly The adapter never provisions; the component stays empty Emit through the test wire adapter
Leaving console.error noise from unhandled promises Hides real failures Await every promise the test triggers
expect(component.privateField) Couples the test to the implementation Assert rendered output or dispatched events
Snapshot-only test files Zero diagnostic value on failure Assert the specific nodes that matter
Tests that require org data Jest has no org Mock the adapter; move org assertions to vf-check apex / smoke

Verification

# All LWC tests
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" jest

# Only tests affected by the current branch
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" jest --changed

# Full local gate (format + lint + analyzer + jest)
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local --changed

# Direct invocations while iterating
npm run test:unit -- force-app/main/default/lwc/accountCard/__tests__/accountCard.test.js
npm run test:unit:watch
npm run test:unit:coverage
npm run test:unit:debug            # then open chrome://inspect
sfdx-lwc-jest -- --runInBand       # everything after -- goes straight to Jest

vf-check jest fails the wave-2 gate when any test fails, when coverage is below gates.jestCoverageMin (80), or when gates.requireJestForLwc is true and a bundle has no __tests__ directory. No org is contacted; exit code 1 means a real gate failure, 2 means the toolchain is missing (run npm install in the consumer project).

References