mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
25 KiB
25 KiB
Advanced Jest Testing for LWC
Comprehensive guide to testing Lightning Web Components using Jest, based on James Simone's advanced testing patterns.
Table of Contents
- Setup
- Core Testing Patterns
- Render Cycle Management
- Mocking Strategies
- Testing Wire Services
- Testing Events
- Testing Navigation
- Testing LMS
- Testing GraphQL
- Polyfills and Utilities
- Best Practices
Setup
Install Dependencies
# Install Jest and LWC testing tools
npm install --save-dev @salesforce/sfdx-lwc-jest jest
# Install additional utilities
npm install --save-dev @testing-library/jest-dom
Jest Configuration
File: jest.config.js
const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config');
module.exports = {
...jestConfig,
roots: ['<rootDir>/force-app'],
moduleNameMapper: {
'^@salesforce/apex$': '<rootDir>/force-app/test/jest-mocks/apex',
'^@salesforce/schema$': '<rootDir>/force-app/test/jest-mocks/schema',
'^lightning/navigation$': '<rootDir>/force-app/test/jest-mocks/lightning/navigation',
'^lightning/messageService$': '<rootDir>/force-app/test/jest-mocks/lightning/messageService'
},
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/.sfdx/',
'<rootDir>/.agents/',
'<rootDir>/.cursor/',
'<rootDir>/.claude/',
'<rootDir>/.pi/'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
roots: ['<rootDir>/force-app'] keeps Jest focused on real Salesforce source. The ignore list adds defense-in-depth for agent/tool folders that may contain copied examples or generated artifacts.
Example test assets in experience-lwc-generate/assets/ ship with a .example suffix. Copy them into force-app/main/default/lwc/<component>/__tests__/ and rename them to *.test.js or *.test.ts before running Jest.
Setup File
File: jest.setup.js
// ResizeObserver polyfill
if (!window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
constructor(callback) {
this.callback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
}
// IntersectionObserver polyfill
if (!window.IntersectionObserver) {
window.IntersectionObserver = class IntersectionObserver {
constructor(callback) {
this.callback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
}
// Custom matchers
expect.extend({
toHaveClass(element, className) {
const pass = element.classList.contains(className);
return {
pass,
message: () => pass
? `Expected element NOT to have class "${className}"`
: `Expected element to have class "${className}"`
};
}
});
Core Testing Patterns
Basic Test Structure
import { createElement } from 'lwc';
import MyComponent from 'c/myComponent';
describe('c-my-component', () => {
afterEach(() => {
// Clean up DOM
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
// Clear all mocks
jest.clearAllMocks();
});
it('renders correctly', () => {
const element = createElement('c-my-component', {
is: MyComponent
});
document.body.appendChild(element);
expect(element.shadowRoot.querySelector('h1')).not.toBeNull();
});
});
DOM Cleanup Pattern
describe('c-my-component', () => {
let element;
beforeEach(() => {
element = createElement('c-my-component', { is: MyComponent });
document.body.appendChild(element);
});
afterEach(() => {
// CRITICAL: Prevent state bleed between tests
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
});
it('test case 1', () => {
// Element is fresh for each test
});
it('test case 2', () => {
// Element is fresh for each test
});
});
Render Cycle Management
Render Cycle Helper
Based on James Simone's pattern.
// testUtils.js
export const runRenderingLifecycle = async (reasons = ['render']) => {
while (reasons.length > 0) {
await Promise.resolve(reasons.pop());
}
};
// Alias for brevity
export const flushPromises = () => runRenderingLifecycle();
Usage in Tests
import { runRenderingLifecycle, flushPromises } from './testUtils';
it('updates after property change', async () => {
const element = createElement('c-example', { is: Example });
document.body.appendChild(element);
// Change property
element.greeting = 'new value';
// Wait for render cycle
await runRenderingLifecycle(['property change', 'render']);
// Assert
const div = element.shadowRoot.querySelector('div');
expect(div.textContent).toBe('new value');
});
it('handles async operation', async () => {
const element = createElement('c-example', { is: Example });
document.body.appendChild(element);
// Trigger async action
const button = element.shadowRoot.querySelector('button');
button.click();
// Flush all promises
await flushPromises();
// Assert
expect(element.shadowRoot.querySelector('.result')).not.toBeNull();
});
Mocking Strategies
Mock Apex Imports
// __mocks__/apex.js
export default function createApexTestWireAdapter() {
return jest.fn();
}
// Component test
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
jest.mock(
'@salesforce/apex/AccountController.getAccounts',
() => ({ default: jest.fn() }),
{ virtual: true }
);
describe('c-account-list', () => {
it('displays accounts', async () => {
const MOCK_DATA = [
{ Id: '001xxx', Name: 'Acme' }
];
getAccounts.mockResolvedValue(MOCK_DATA);
const element = createElement('c-account-list', {
is: AccountList
});
document.body.appendChild(element);
await flushPromises();
const items = element.shadowRoot.querySelectorAll('.account-item');
expect(items.length).toBe(1);
});
});
Mock Schema Imports
// __mocks__/schema.js
export default {
'Account.Name': 'Name',
'Account.Industry': 'Industry',
'Contact.FirstName': 'FirstName'
};
// Component test
jest.mock('@salesforce/schema/Account.Name', () => 'Name', { virtual: true });
jest.mock('@salesforce/schema/Account.Industry', () => 'Industry', { virtual: true });
Mock Platform Events
// Mock ShowToastEvent
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
jest.mock('lightning/platformShowToastEvent', () => ({
ShowToastEvent: jest.fn()
}), { virtual: true });
it('shows toast on success', () => {
const element = createElement('c-example', { is: Example });
document.body.appendChild(element);
const handler = jest.fn();
element.addEventListener('showtoast', handler);
// Trigger action
const button = element.shadowRoot.querySelector('button');
button.click();
// Assert toast was dispatched
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0].detail.title).toBe('Success');
});
Testing Wire Services
Test Wire Adapter
import { createElement } from 'lwc';
import AccountCard from 'c/accountCard';
import { getRecord } from 'lightning/uiRecordApi';
// Emit data from wire
const mockGetRecord = require('lightning/uiRecordApi');
describe('c-account-card', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
});
it('displays account data', async () => {
const element = createElement('c-account-card', {
is: AccountCard
});
element.recordId = '001xxx000003DGQ';
document.body.appendChild(element);
// Emit mock data to wire
mockGetRecord.emit({
Id: '001xxx000003DGQ',
fields: {
Name: { value: 'Acme Corp' },
Industry: { value: 'Technology' }
}
});
await flushPromises();
const name = element.shadowRoot.querySelector('.account-name');
expect(name.textContent).toBe('Acme Corp');
});
it('displays error', async () => {
const element = createElement('c-account-card', {
is: AccountCard
});
element.recordId = '001xxx000003DGQ';
document.body.appendChild(element);
// Emit error to wire
mockGetRecord.error();
await flushPromises();
const error = element.shadowRoot.querySelector('.error-message');
expect(error).not.toBeNull();
});
});
Test Imperative Apex
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
jest.mock(
'@salesforce/apex/AccountController.getAccounts',
() => ({ default: jest.fn() }),
{ virtual: true }
);
it('loads accounts imperatively', async () => {
const MOCK_ACCOUNTS = [
{ Id: '001xxx', Name: 'Acme' }
];
getAccounts.mockResolvedValue(MOCK_ACCOUNTS);
const element = createElement('c-account-search', {
is: AccountSearch
});
document.body.appendChild(element);
// Trigger search
const input = element.shadowRoot.querySelector('input');
input.value = 'Acme';
input.dispatchEvent(new Event('change'));
await flushPromises();
expect(getAccounts).toHaveBeenCalledWith({ searchTerm: 'Acme' });
const results = element.shadowRoot.querySelectorAll('.account-item');
expect(results.length).toBe(1);
});
it('handles apex error', async () => {
getAccounts.mockRejectedValue(new Error('Network error'));
const element = createElement('c-account-search', {
is: AccountSearch
});
document.body.appendChild(element);
const input = element.shadowRoot.querySelector('input');
input.value = 'Test';
input.dispatchEvent(new Event('change'));
await flushPromises();
const errorMsg = element.shadowRoot.querySelector('.error');
expect(errorMsg.textContent).toContain('Network error');
});
Testing Events
Test Custom Events
it('dispatches custom event', () => {
const element = createElement('c-event-emitter', {
is: EventEmitter
});
document.body.appendChild(element);
const handler = jest.fn();
element.addEventListener('itemselected', handler);
// Trigger event
const button = element.shadowRoot.querySelector('button');
button.click();
// Assert
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0].detail.id).toBe('001xxx');
});
Test Event Bubbling
it('bubbles event to parent', () => {
const parent = createElement('c-parent', { is: Parent });
document.body.appendChild(parent);
const handler = jest.fn();
parent.addEventListener('itemselected', handler);
// Get child component
const child = parent.shadowRoot.querySelector('c-child');
const button = child.shadowRoot.querySelector('button');
button.click();
expect(handler).toHaveBeenCalled();
});
Test Event Composition
it('composes event across shadow DOM', () => {
const element = createElement('c-composer', {
is: Composer
});
document.body.appendChild(element);
const handler = jest.fn();
document.addEventListener('customevent', handler);
// Trigger composed event
const button = element.shadowRoot.querySelector('button');
button.click();
expect(handler).toHaveBeenCalled();
});
Testing Navigation
Mock Navigation Mixin
// __mocks__/lightning/navigation.js
export const NavigationMixin = (Base) => {
return class extends Base {
[NavigationMixin.Navigate](pageReference, replace) {
this._navigate = { pageReference, replace };
}
};
};
NavigationMixin.Navigate = Symbol('Navigate');
// Component test
import { NavigationMixin } from 'lightning/navigation';
it('navigates to record page', async () => {
const element = createElement('c-navigator', {
is: Navigator
});
document.body.appendChild(element);
const button = element.shadowRoot.querySelector('button');
button.click();
await flushPromises();
expect(element._navigate.pageReference.type).toBe('standard__recordPage');
expect(element._navigate.pageReference.attributes.recordId).toBe('001xxx');
});
Testing LMS
Mock Message Service
// __mocks__/lightning/messageService.js
export const publish = jest.fn();
export const subscribe = jest.fn();
export const unsubscribe = jest.fn();
export const MessageContext = Symbol('MessageContext');
export const APPLICATION_SCOPE = Symbol('APPLICATION_SCOPE');
Test Publisher
import { publish, MessageContext } from 'lightning/messageService';
import ACCOUNT_CHANNEL from '@salesforce/messageChannel/AccountSelected__c';
jest.mock('lightning/messageService');
it('publishes message on selection', () => {
const element = createElement('c-publisher', {
is: Publisher
});
document.body.appendChild(element);
const button = element.shadowRoot.querySelector('button');
button.click();
expect(publish).toHaveBeenCalledWith(
expect.anything(),
ACCOUNT_CHANNEL,
expect.objectContaining({ accountId: '001xxx' })
);
});
Test Subscriber
import { subscribe, unsubscribe } from 'lightning/messageService';
jest.mock('lightning/messageService');
it('subscribes on connected', () => {
const element = createElement('c-subscriber', {
is: Subscriber
});
document.body.appendChild(element);
expect(subscribe).toHaveBeenCalled();
});
it('unsubscribes on disconnected', () => {
const element = createElement('c-subscriber', {
is: Subscriber
});
document.body.appendChild(element);
document.body.removeChild(element);
expect(unsubscribe).toHaveBeenCalled();
});
it('handles incoming message', async () => {
let messageHandler;
subscribe.mockImplementation((context, channel, handler) => {
messageHandler = handler;
return { subscription: 'mock' };
});
const element = createElement('c-subscriber', {
is: Subscriber
});
document.body.appendChild(element);
// Simulate message
messageHandler({ accountId: '001xxx', accountName: 'Acme' });
await flushPromises();
const name = element.shadowRoot.querySelector('.account-name');
expect(name.textContent).toBe('Acme');
});
Testing GraphQL
Mock GraphQL Adapter
import { graphql } from 'lightning/graphql';
// Mock graphql wire adapter
jest.mock('lightning/graphql', () => ({
gql: jest.fn(query => query),
graphql: jest.fn()
}), { virtual: true });
it('displays graphql query results', async () => {
const element = createElement('c-graphql-component', {
is: GraphqlComponent
});
document.body.appendChild(element);
// Emit mock data
const mockData = {
uiapi: {
query: {
Contact: {
edges: [
{ node: { Id: '003xxx', Name: { value: 'John Doe' } } }
]
}
}
}
};
graphql.emit({ data: mockData });
await flushPromises();
const contacts = element.shadowRoot.querySelectorAll('.contact-item');
expect(contacts.length).toBe(1);
});
Polyfills and Utilities
ResizeObserver Polyfill
if (!window.ResizeObserver) {
window.ResizeObserver = class ResizeObserver {
constructor(callback) {
this.callback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
};
}
Proxy Unboxing (LWS Compatibility)
// Lightning Web Security proxifies objects
// Unbox them for deep equality assertions
it('compares complex objects', () => {
const element = createElement('c-example', { is: Example });
document.body.appendChild(element);
// Unbox proxied data
const unboxedData = JSON.parse(JSON.stringify(element.data));
expect(unboxedData).toEqual({
accounts: [
{ Id: '001xxx', Name: 'Acme' }
]
});
});
Test Utilities Module
// testUtils.js
export const runRenderingLifecycle = async (reasons = ['render']) => {
while (reasons.length > 0) {
await Promise.resolve(reasons.pop());
}
};
export const flushPromises = () => runRenderingLifecycle();
export const queryAll = (element, selector) => {
return Array.from(element.shadowRoot.querySelectorAll(selector));
};
export const query = (element, selector) => {
return element.shadowRoot.querySelector(selector);
};
export const waitFor = async (condition, timeout = 3000) => {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeout) {
throw new Error('waitFor timeout');
}
await flushPromises();
}
};
// Usage
import { query, queryAll, waitFor } from './testUtils';
it('uses test utils', async () => {
const element = createElement('c-example', { is: Example });
document.body.appendChild(element);
const button = query(element, 'button');
button.click();
await waitFor(() => query(element, '.result') !== null);
const items = queryAll(element, '.item');
expect(items.length).toBeGreaterThan(0);
});
Best Practices
1. Always Clean Up DOM
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
});
2. Use Descriptive Test Names
// BAD
it('works', () => { /* ... */ });
// GOOD
it('displays error message when apex call fails', () => { /* ... */ });
3. Test User Interactions
it('filters list when search input changes', async () => {
const element = createElement('c-searchable-list', {
is: SearchableList
});
document.body.appendChild(element);
const input = element.shadowRoot.querySelector('input');
input.value = 'test';
input.dispatchEvent(new Event('input'));
await flushPromises();
const items = element.shadowRoot.querySelectorAll('.list-item');
expect(items.length).toBeLessThan(10); // Filtered results
});
4. Test Error States
it('displays error when wire service fails', async () => {
mockGetRecord.error({ message: 'Network error' });
const element = createElement('c-example', { is: Example });
document.body.appendChild(element);
await flushPromises();
const error = element.shadowRoot.querySelector('.error-message');
expect(error.textContent).toContain('Network error');
});
5. Test Loading States
it('shows spinner during data load', async () => {
const element = createElement('c-async-component', {
is: AsyncComponent
});
document.body.appendChild(element);
// Before data loads
let spinner = element.shadowRoot.querySelector('lightning-spinner');
expect(spinner).not.toBeNull();
// Emit data
mockGetData.emit([{ Id: '001xxx' }]);
await flushPromises();
// After data loads
spinner = element.shadowRoot.querySelector('lightning-spinner');
expect(spinner).toBeNull();
});
6. Test Accessibility
it('has proper ARIA labels', () => {
const element = createElement('c-accessible', {
is: Accessible
});
document.body.appendChild(element);
const button = element.shadowRoot.querySelector('button');
expect(button.getAttribute('aria-label')).toBe('Delete record');
});
it('manages focus correctly', async () => {
const element = createElement('c-modal', { is: Modal });
document.body.appendChild(element);
element.openModal();
await flushPromises();
const firstFocusable = element.shadowRoot.querySelector('.focusable');
expect(document.activeElement).toBe(firstFocusable);
});
7. Organize Tests by Feature
describe('c-account-list', () => {
describe('data loading', () => {
it('displays accounts when data loads successfully');
it('shows error when data load fails');
it('shows spinner during loading');
});
describe('filtering', () => {
it('filters by search term');
it('filters by industry');
it('clears filters');
});
describe('selection', () => {
it('selects account on click');
it('dispatches selection event');
it('highlights selected account');
});
});
Complete Test Example
import { createElement } from 'lwc';
import AccountList from 'c/accountList';
import getAccounts from '@salesforce/apex/AccountController.getAccounts';
import { publish } from 'lightning/messageService';
import ACCOUNT_SELECTED from '@salesforce/messageChannel/AccountSelected__c';
// Mocks
jest.mock('@salesforce/apex/AccountController.getAccounts', () => ({
default: jest.fn()
}), { virtual: true });
jest.mock('lightning/messageService');
const MOCK_ACCOUNTS = [
{ Id: '001xxx001', Name: 'Acme Corp', Industry: 'Technology' },
{ Id: '001xxx002', Name: 'Global Inc', Industry: 'Finance' }
];
const flushPromises = () => new Promise(resolve => setImmediate(resolve));
describe('c-account-list', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
});
describe('data loading', () => {
it('displays accounts when loaded successfully', async () => {
getAccounts.mockResolvedValue(MOCK_ACCOUNTS);
const element = createElement('c-account-list', {
is: AccountList
});
document.body.appendChild(element);
await flushPromises();
const items = element.shadowRoot.querySelectorAll('.account-item');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('Acme Corp');
});
it('displays error when fetch fails', async () => {
getAccounts.mockRejectedValue(new Error('Network error'));
const element = createElement('c-account-list', {
is: AccountList
});
document.body.appendChild(element);
await flushPromises();
const error = element.shadowRoot.querySelector('.error-message');
expect(error).not.toBeNull();
expect(error.textContent).toContain('Network error');
});
});
describe('selection', () => {
it('publishes message when account selected', async () => {
getAccounts.mockResolvedValue(MOCK_ACCOUNTS);
const element = createElement('c-account-list', {
is: AccountList
});
document.body.appendChild(element);
await flushPromises();
const firstAccount = element.shadowRoot.querySelector('.account-item');
firstAccount.click();
expect(publish).toHaveBeenCalledWith(
expect.anything(),
ACCOUNT_SELECTED,
expect.objectContaining({
accountId: '001xxx001',
accountName: 'Acme Corp'
})
);
});
});
describe('filtering', () => {
it('filters accounts by search term', async () => {
getAccounts.mockResolvedValue(MOCK_ACCOUNTS);
const element = createElement('c-account-list', {
is: AccountList
});
document.body.appendChild(element);
await flushPromises();
const searchInput = element.shadowRoot.querySelector('input');
searchInput.value = 'Acme';
searchInput.dispatchEvent(new Event('input'));
await flushPromises();
const visibleItems = element.shadowRoot.querySelectorAll('.account-item:not(.hidden)');
expect(visibleItems.length).toBe(1);
expect(visibleItems[0].textContent).toContain('Acme Corp');
});
});
});
Related Resources
- component-patterns.md - Component implementation patterns
- lms-guide.md - Lightning Message Service
- James Simone - Advanced LWC Jest Testing
- LWC Recipes - See component-level
__tests__folders for Jest examples