agentleFS
grzmolgrzmol/vibe-force/skills/sf-lwc-jest-testing/SKILL.md

sf-lwc-jest-testing

Skill2 starsChanged 2 months agoMITView on GitHub

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 rerenders, querying shadow and light DOM, asserting dispatched CustomEvents, mocking Apex methods and @salesforce scoped imports with jest.mock, driving wire adapters with createApexTestWireAdapter/createLdsTestWireAdapter/createTestWireAdapter, stubbing lightning/navigation, lightning/messageService, toasts and base components, coverage configuration against the jestCoverageMin gate, and debugging failing suites. Use this skill whenever a __tests__ directory under force-app/**/lwc/** is created or changed, when vf-check jest fails, when a component test needs wire or Apex data, or when deciding what an LWC test should assert.

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 0 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Never share an element between it blocks.

    line 115
  2. Very specific:

    Never use setTimeout to "wait for" a rerender.

    line 139

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

LWC Jest Testing

When to use

SituationUse this skill
Adding or fixing a __tests__/*.test.js file for an LWCYes
vf-check jest fails or coverage is below the gateYes
A component needs wire data, Apex results, or navigation asserted in a testYes
Writing the component itselfSkill sf-lwc-development
Apex unit tests (@IsTest)Skill sf-apex-testing
Verifying behaviour in a real org after deploySkill sf-post-deploy-verification
Sharing one set of states between the spec and a sf lightning dev component previewSkill sf-local-development, pattern 9
Specs generated by DX MCP tools or the experience-lwc-test skillYes - 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

TaskAPI
Instantiateconst el = createElement('c-foo', { is: Foo }); document.body.appendChild(el);
Set a public propertyAssign before appendChild for initial render, after for a rerender
Clean upwhile (document.body.firstChild) document.body.removeChild(document.body.firstChild); in afterEach
Wait for rerenderawait Promise.resolve(); or a flushPromises() helper
Query shadow DOMel.shadowRoot.querySelector('.name'), querySelectorAll
Query light DOM componentel.querySelector(...) - there is no shadowRoot
Assert a dispatched eventel.addEventListener('rowselect', handler) then assert handler.mock.calls[0][0].detail
Mock an Apex methodjest.mock('@salesforce/apex/Cls.method', () => ({ default: jest.fn() }), { virtual: true })
Emit wire dataadapter.emit(data) from a __mocks__ module built with createLdsTestWireAdapter
Emit a wire erroradapter.error(body, status, statusText) or adapter.emitError({...})
Inspect the resolved wire configadapter.getLastConfig()
Control timejest.useFakeTimers() + jest.advanceTimersByTime(ms)
Run one filenpm 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.

FactoryUse forEmitted 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 adaptersRaw 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

AssertDo not assert
Rendered text, element counts, and attributes a user perceivesInternal 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 flagsThat 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-patternConsequenceFix
No afterEach DOM cleanupjsdom is shared per file; the second test sees the first componentRemove every child of document.body
Asserting immediately after setting a propertyRerender is a microtask; assertion runs too earlyawait Promise.resolve() first
setTimeout(..., 0) to wait for a rerenderFlaky and slowFlush promises, or fake timers for real timers
jest.mock('@salesforce/apex/...') without { virtual: true }Module not found - the path is syntheticAdd the virtual option
Mocking lightning-button with a hand-written stubBase component stubs ship with the packageUse the built-in stubs; override only via moduleNameMapper
Testing a wire by calling the Apex mock directlyThe adapter never provisions; the component stays emptyEmit through the test wire adapter
Leaving console.error noise from unhandled promisesHides real failuresAwait every promise the test triggers
expect(component.privateField)Couples the test to the implementationAssert rendered output or dispatched events
Snapshot-only test filesZero diagnostic value on failureAssert the specific nodes that matter
Tests that require org dataJest has no orgMock 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

  • references/jest-setup.md - full jest.config.js, npm scripts, __mocks__ layout, CI invocation, coverage configuration.
  • references/mocking-cookbook.md - Apex, LDS, GraphQL, navigation, LMS, toast, labels, base components, fetch.
  • references/test-recipes.md - complete test files for the component shapes this harness builds.
  • references/troubleshooting-jest.md - failure-to-cause table and debugging workflow.
  • Official: sfdx-lwc-jest README, wire-service-jest-util README, Test Lightning Web Components, Jest 30 documentation.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.