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.
In this repository34 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- sf-agentforce-developmentSkill
- sf-apex-developmentSkill
- sf-apex-testingSkill
- sf-async-apex-patternsSkill
- sf-cli-operationsSkill
- sf-code-analyzer-qualitySkill
- sf-data-cloudSkill
- sf-data-managementSkill
- sf-debugging-logsSkill
- sf-deployment-strategiesSkill
- sf-fflib-domain-service-uowSkill
- sf-fflib-foundationsSkill
- sf-fflib-operationsSkill
- sf-fflib-selector-layerSkill
- sf-fflib-testingSkill
- sf-flow-automationSkill
- sf-governor-limitsSkill
- sf-integration-patternsSkill
- sf-local-developmentSkill
- sf-lwc-developmentSkill
- sf-minimal-changeSkill
- sf-org-security-auditSkill
- sf-packaging-releaseSkill
- sf-post-deploy-verificationSkill
- sf-process-builder-migrationSkill
- sf-project-structureSkill
- sf-scratch-orgs-sandboxesSkill
- sf-security-modelSkill
- sf-setup-automationSkill
- sf-soql-sosl-optimizationSkill
- sf-technical-debt-auditSkill
- sf-ui-test-automationSkill
- sf-workflow-orchestrationSkill
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
- Very specific: line 115
Never share an element between it blocks.
- Very specific: line 139
Never use setTimeout to "wait for" a rerender.
≈ “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.
Files
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 Jestvf-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.
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
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.

