Imported from grzmol/vibe-force (
skills/sf-lwc-jest-testing/SKILL.md). Install upstream withnpx skills add grzmol/vibe-force --skill sf-lwc-jest-testing. Copyright stays with the author.
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
createElementmust be the kebab-case name with thec-namespace. - Nothing renders until
document.body.appendChild(element). - Never share an element between
itblocks.
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
references/jest-setup.md- fulljest.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.