afv-library/skills/experience-lwc-generate/references/jest-testing.md

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

  1. Setup
  2. Core Testing Patterns
  3. Render Cycle Management
  4. Mocking Strategies
  5. Testing Wire Services
  6. Testing Events
  7. Testing Navigation
  8. Testing LMS
  9. Testing GraphQL
  10. Polyfills and Utilities
  11. 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');
        });
    });
});