Testing and Interview Prep · Lesson 24 of 25
Testing LWC with Jest
Apex has to be tested before it can be deployed; LWC doesn't – which is exactly why many teams' components break silently. Jest tests for LWC run on your machine in seconds, without an org: they render a component in a simulated browser, interact with it, and check the result. Salesforce's @salesforce/sfdx-lwc-jest package provides stubs for every lightning-* component and test versions of the wire adapters. The examples in this course were all checked with it.
In this lesson
- Setup and the shape of a test
- DOM assertions and simulating clicks
- Emitting wire data and mocking Apex
- Testing events and a failing test
Setup
Projects created with sf project generate are already configured. Otherwise:
npm install --save-dev @salesforce/sfdx-lwc-jest
# package.json: "test:unit": "sfdx-lwc-jest", "test:unit:watch": "sfdx-lwc-jest --watch"
npm run test:unit
Tests live next to the component in __tests__/componentName.test.js, which .forceignore keeps out of deployments.
The component under test
<!-- leadScore.html -->
<template>
<lightning-card title="Lead score">
<template lwc:if={lead.data}>
<p class="score">{name}: {score} points</p>
<p class="grade">{grade}</p>
</template>
<template lwc:elseif={lead.error}>
<p class="error">Could not load the lead</p>
</template>
<lightning-button label="Recalculate" onclick={recalculate}></lightning-button>
</lightning-card>
</template>
// leadScore.js
import { LightningElement, api, wire } from 'lwc';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import recalculateScore from '@salesforce/apex/LeadScoring.recalculate';
import NAME from '@salesforce/schema/Lead.Name';
import SCORE from '@salesforce/schema/Lead.Score__c';
export default class LeadScore extends LightningElement {
@api recordId;
override;
@wire(getRecord, { recordId: '$recordId', fields: [NAME, SCORE] }) lead;
get name() {
return getFieldValue(this.lead.data, NAME);
}
get score() {
return this.override ?? getFieldValue(this.lead.data, SCORE);
}
get grade() {
return this.score >= 80 ? 'Hot' : this.score >= 40 ? 'Warm' : 'Cold';
}
async recalculate() {
this.override = await recalculateScore({ leadId: this.recordId });
this.dispatchEvent(new CustomEvent('scorechange', { detail: { score: this.override } }));
}
}
The tests
// __tests__/leadScore.test.js
import { createElement } from 'lwc';
import LeadScore from 'c/leadScore';
import { getRecord } from 'lightning/uiRecordApi';
import recalculateScore from '@salesforce/apex/LeadScoring.recalculate';
// Replace the Apex method with a Jest mock
jest.mock('@salesforce/apex/LeadScoring.recalculate', () => ({ default: jest.fn() }), { virtual: true });
const LEAD = {
apiName: 'Lead',
id: '00QXX0000001Lw',
fields: { Name: { value: 'Ravi Kumar' }, Score__c: { value: 55 } },
};
// let pending promises and re-renders finish
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('c-lead-score', () => {
afterEach(() => {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild); // clean the DOM
jest.clearAllMocks();
});
function render() {
const element = createElement('c-lead-score', { is: LeadScore });
element.recordId = '00QXX0000001Lw';
document.body.appendChild(element);
return element;
}
it('shows the score and grade from the record', async () => {
const element = render();
getRecord.emit(LEAD); // deliver data through the wire
await flushPromises();
expect(element.shadowRoot.querySelector('.score').textContent).toBe('Ravi Kumar: 55 points');
expect(element.shadowRoot.querySelector('.grade').textContent).toBe('Warm');
});
it('shows an error when the record cannot be loaded', async () => {
const element = render();
getRecord.error();
await flushPromises();
expect(element.shadowRoot.querySelector('.error')).not.toBeNull();
});
it('recalculates through Apex and fires scorechange', async () => {
recalculateScore.mockResolvedValue(91);
const element = render();
const handler = jest.fn();
element.addEventListener('scorechange', handler);
getRecord.emit(LEAD);
await flushPromises();
element.shadowRoot.querySelector('lightning-button').click();
await flushPromises();
expect(recalculateScore).toHaveBeenCalledWith({ leadId: '00QXX0000001Lw' });
expect(element.shadowRoot.querySelector('.grade').textContent).toBe('Hot');
expect(handler.mock.calls[0][0].detail).toEqual({ score: 91 });
});
});
PASS force-app/main/default/lwc/leadScore/__tests__/leadScore.test.js ✓ shows the score and grade from the record ✓ shows an error when the record cannot be loaded ✓ recalculates through Apex and fires scorechange Tests: 3 passed, 3 total
The shape of every LWC test
- CreatecreateElement('c-name', { is: Component }), set @api props, append to document.body
- Feedadapter.emit(data) for wires; mockResolvedValue for imperative Apex
- Actclick elements or dispatch events, then await flushPromises()
- Assertquery element.shadowRoot, check mocks and received events
- Wire adapters like
getRecordare test adapters in Jest:emit(data)anderror()push values into the component. jest.mock(…, { virtual: true })replaces an Apex import; check calls withtoHaveBeenCalledWith.- Rendering is asynchronous: always
awaita resolved promise after changing data before asserting. - Clean
document.bodyafter each test so tests don't affect each other.
What a failure looks like
// __tests__/leadScore.test.js
// a test with a wrong expectation
import { createElement } from 'lwc';
import LeadScore from 'c/leadScore';
import { getRecord } from 'lightning/uiRecordApi';
jest.mock('@salesforce/apex/LeadScoring.recalculate', () => ({ default: jest.fn() }), { virtual: true });
it('grades a 40-point lead as Cold', async () => {
const element = createElement('c-lead-score', { is: LeadScore });
document.body.appendChild(element);
getRecord.emit({ apiName: 'Lead', fields: { Name: { value: 'Meera' }, Score__c: { value: 40 } } });
await Promise.resolve();
expect(element.shadowRoot.querySelector('.grade').textContent).toBe('Cold');
});
FAIL force-app/main/default/lwc/leadScore/__tests__/leadScore.test.js ✕ grades a 40-point lead as Cold Expected: "Cold" Received: "Warm" Tests: 1 failed, 1 total
The test expected "Cold", but the component says a score of exactly 40 is "Warm" (>= 40). Either the test or the requirement is wrong – which is exactly the conversation a failing test should start.
Practice
Try it yourself
Write a test for a counter component: it starts at 0 and shows 1 after clicking "Add".
Show solution
<!-- clickCounter.html --> <template> <p class="count">{count}</p> <lightning-button label="Add" onclick={add}></lightning-button> </template>// clickCounter.js import { LightningElement } from 'lwc'; export default class ClickCounter extends LightningElement { count = 0; add() { this.count++; } }// __tests__/clickCounter.test.js import { createElement } from 'lwc'; import ClickCounter from 'c/clickCounter'; describe('c-click-counter', () => { it('increments on click', async () => { const element = createElement('c-click-counter', { is: ClickCounter }); document.body.appendChild(element); expect(element.shadowRoot.querySelector('.count').textContent).toBe('0'); element.shadowRoot.querySelector('lightning-button').click(); await Promise.resolve(); expect(element.shadowRoot.querySelector('.count').textContent).toBe('1'); }); });PASS force-app/main/default/lwc/clickCounter/__tests__/clickCounter.test.js ✓ increments on click Tests: 1 passed, 1 total
Quick check
Do LWC Jest tests need a Salesforce org?
No – they run locally with stubs for base components and wire adapters.
How do you send data to a @wire(getRecord) in a test?
getRecord.emit(data), then await a promise.
Why await after clicking?
LWC re-renders asynchronously; the DOM updates on the next microtask.
Summary
- sfdx-lwc-jest runs fast, org-free unit tests for components.
- Create, feed (emit / mockResolvedValue), act, await, assert – and clean up.
- Test the behaviour users depend on: rendering, errors, Apex calls and events.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.