LWC unit testing gets skipped more than it should. The setup feels unfamiliar, the mocking patterns aren’t obvious, and it’s easy to write tests that give false confidence. This post cuts through the noise and covers what actually matters: wire adapter mocking, Apex call mocking, event testing, and how to think about snapshots.
Setting Up @salesforce/lwc-jest
Your project needs @salesforce/lwc-jest as a dev dependency. If you’re using the Salesforce CLI project scaffolding, this is often already present. If not:
npm install --save-dev @salesforce/lwc-jest jest jest-environment-jsdom
Your jest.config.js should extend the LWC preset:
const { jestConfig } = require('@salesforce/lwc-jest/config');
module.exports = {
...jestConfig,
modulePathIgnorePatterns: ['<rootDir>/.localdevserver'],
setupFilesAfterFramework: ['./jest.setup.js'],
};
The LWC Jest preset handles module name mapping so that @salesforce/apex, @salesforce/schema, and lightning/* imports resolve correctly in the test environment.
Three Test Types and Their Boundaries
Keep these layers distinct:
- Unit tests - a single component in isolation, all dependencies mocked. This is where 90% of your test effort should go.
- Integration tests - a parent component with real child components composed together, external dependencies still mocked.
- End-to-end - Playwright or similar against a real org. Expensive to maintain; reserve for critical user journeys.
The boundary that matters most in LWC testing is: never let a test reach the network. Everything external - Apex, wire adapters, platform imports - must be mocked.
Mocking Wire Adapters
Wire adapters are the most LWC-specific mocking challenge. The pattern uses @wire decorator + $adapter.emit():
// contactCard.js
import { LightningElement, wire, api } from 'lwc';
import { getRecord } from 'lightning/uiRecordApi';
import NAME_FIELD from '@salesforce/schema/Contact.Name';
export default class ContactCard extends LightningElement {
@api recordId;
@wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD] })
contact;
get name() {
return this.contact?.data?.fields?.Name?.value ?? 'Loading...';
}
}
// __tests__/contactCard.test.js
import { createElement } from 'lwc';
import ContactCard from 'c/contactCard';
import { getRecord } from 'lightning/uiRecordApi';
import { registerLdsTestWireAdapter } from '@salesforce/sfdx-lwc-jest';
const mockGetRecord = registerLdsTestWireAdapter(getRecord);
describe('c-contact-card', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
});
it('displays contact name from wire data', async () => {
const element = createElement('c-contact-card', { is: ContactCard });
element.recordId = '003000000000001';
document.body.appendChild(element);
mockGetRecord.emit({
fields: {
Name: { value: 'Jane Doe' }
}
});
await Promise.resolve(); // flush async rendering
const nameEl = element.shadowRoot.querySelector('.contact-name');
expect(nameEl.textContent).toBe('Jane Doe');
});
it('handles wire error gracefully', async () => {
const element = createElement('c-contact-card', { is: ContactCard });
element.recordId = '003000000000001';
document.body.appendChild(element);
mockGetRecord.emitError({ status: 404, statusText: 'Not Found' });
await Promise.resolve();
const errorEl = element.shadowRoot.querySelector('.error-message');
expect(errorEl).not.toBeNull();
});
});
The key: registerLdsTestWireAdapter returns a handle you call .emit() or .emitError() on. You control exactly what data the component sees.
Mocking Apex Calls
Apex imports get mocked with jest.mock and controlled with mockResolvedValue:
// accountSearch.js
import { LightningElement, track } from 'lwc';
import searchAccounts from '@salesforce/apex/AccountController.searchAccounts';
export default class AccountSearch extends LightningElement {
@track results = [];
@track error;
handleSearch(event) {
searchAccounts({ searchTerm: event.detail.value })
.then(data => { this.results = data; })
.catch(err => { this.error = err.body.message; });
}
}
// __tests__/accountSearch.test.js
import { createElement } from 'lwc';
import AccountSearch from 'c/accountSearch';
import searchAccounts from '@salesforce/apex/AccountController.searchAccounts';
jest.mock('@salesforce/apex/AccountController.searchAccounts', () => ({
default: jest.fn()
}), { virtual: true });
describe('c-account-search', () => {
it('displays results from Apex', async () => {
searchAccounts.mockResolvedValue([
{ Id: '001000000000001', Name: 'Acme Corp' }
]);
const element = createElement('c-account-search', { is: AccountSearch });
document.body.appendChild(element);
// trigger search
const input = element.shadowRoot.querySelector('lightning-input');
input.dispatchEvent(new CustomEvent('change', { detail: { value: 'Acme' } }));
await Promise.resolve();
await Promise.resolve(); // second flush for the promise chain
const items = element.shadowRoot.querySelectorAll('.result-item');
expect(items.length).toBe(1);
expect(items[0].textContent).toContain('Acme Corp');
});
it('shows error message when Apex throws', async () => {
searchAccounts.mockRejectedValue({ body: { message: 'SOQL error' } });
const element = createElement('c-account-search', { is: AccountSearch });
document.body.appendChild(element);
const input = element.shadowRoot.querySelector('lightning-input');
input.dispatchEvent(new CustomEvent('change', { detail: { value: 'bad' } }));
await Promise.resolve();
await Promise.resolve();
const errorEl = element.shadowRoot.querySelector('.error');
expect(errorEl.textContent).toBe('SOQL error');
});
});
Use mockResolvedValue for happy-path and mockRejectedValue for error states. Reset mocks between tests with jest.clearAllMocks() in afterEach.
User Events: userEvent vs fireEvent
fireEvent dispatches a DOM event directly - it’s synchronous and doesn’t simulate browser behavior. Use it for simple event triggers.
@testing-library/user-event simulates real user interactions including focus, keyboard events, and event sequences. Use it when the component’s behavior depends on event order (e.g., input + blur triggers validation).
import userEvent from '@testing-library/user-event';
it('validates on blur', async () => {
const input = element.shadowRoot.querySelector('input');
await userEvent.type(input, 'bad-email');
await userEvent.tab(); // triggers blur
await Promise.resolve();
const validationMsg = element.shadowRoot.querySelector('.validation-error');
expect(validationMsg).not.toBeNull();
});
Snapshot Testing Trade-offs
Snapshot tests are tempting because they’re trivially easy to write. The reality: they become a maintenance burden without providing much safety net.
Snapshots break on any markup change - including cosmetic ones that don’t affect behavior. Developers learn to run jest --updateSnapshot without reading what changed, which defeats the purpose entirely.
Use snapshots sparingly:
- Good candidate: a component that renders complex conditional markup based on input props, where you want to catch unintended rendering regressions
- Bad candidate: any component that uses dynamic data, dates, or IDs in its output
When you do use them, be explicit about what you’re snapshotting:
// Snapshot just the relevant section, not the entire shadow DOM
const statusBadge = element.shadowRoot.querySelector('.status-badge');
expect(statusBadge.outerHTML).toMatchSnapshot();
The Missing Piece: Testing Error States
Error state tests are the most valuable tests you’re probably not writing. The happy path usually works; it’s the error paths that fail in production.
For every component that calls Apex or uses a wire adapter, write at least one test that exercises the error state. Verify the error message renders, that the component doesn’t crash, and that retry mechanisms (if any) are accessible.
Testing LWC with Jest rewards consistency over cleverness. Get the mocking patterns right, keep tests focused on behavior rather than implementation, and make error-state coverage non-negotiable.