# 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 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.

- Skill: `grzmol/sf-lwc-jest-testing` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add grzmol/sf-lwc-jest-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/grzmol/sf-lwc-jest-testing/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: grzmol (https://skillmd.com/u/grzmol)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/grzmol/sf-lwc-jest-testing

---


# 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

```json
{
  "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"
  }
}
```

```javascript
// 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

```javascript
// 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

```javascript
// 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

```javascript
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 |

```javascript
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

```javascript
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

```javascript
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

```bash
# 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`](references/jest-setup.md) - full `jest.config.js`, npm scripts, `__mocks__` layout, CI invocation, coverage configuration.
- [`references/mocking-cookbook.md`](references/mocking-cookbook.md) - Apex, LDS, GraphQL, navigation, LMS, toast, labels, base components, fetch.
- [`references/test-recipes.md`](references/test-recipes.md) - complete test files for the component shapes this harness builds.
- [`references/troubleshooting-jest.md`](references/troubleshooting-jest.md) - failure-to-cause table and debugging workflow.
- Official: [sfdx-lwc-jest README](https://github.com/salesforce/sfdx-lwc-jest), [wire-service-jest-util README](https://github.com/salesforce/wire-service-jest-util), [Test Lightning Web Components](https://developer.salesforce.com/docs/platform/lwc/guide/unit-testing-using-jest-create-tests.html), [Jest 30 documentation](https://jestjs.io/docs/30.0/getting-started).

