Log Entry
Testing LWC Behaviour, Not Implementation Details
An LWC Jest test can be green and still tell me almost nothing useful.
This is technically a test:
expect(element.shadowRoot.querySelectorAll('div')).toHaveLength(7);
It is also a maintenance alarm.
Seven div elements are probably not the behaviour anybody asked for. A harmless markup refactor can break the test while the component remains perfectly correct.
I want tests to describe what the component promises.
Quick snapshot
Good LWC unit tests focus on:
- public
@apiproperties and methods - visible states that matter to the user
- interaction through buttons, inputs, and events
- custom events emitted by the component
- wire data, empty data, and wire errors
- loading, success, and failure transitions
- accessibility-relevant state such as labels and disabled controls
Salesforce's LWC testing guide recommends Jest for testing components in isolation, including public APIs, user interaction, DOM output, and events.
The DOM is still part of the test. The trick is to assert meaningful DOM outcomes rather than private structure.
Test from the component boundary
Imagine a c-case-summary component.
Its contract might be:
- accept a
record-id - show a loading state while data is unavailable
- render the case subject on success
- show an accessible empty state when no case is returned
- dispatch
caseopenwhen the user selects the case
Those are behaviours.
The names of its private getters and the number of wrapper elements are not.
Arrange the component like a consumer
import { createElement } from 'lwc';
import CaseSummary from 'c/caseSummary';
function createComponent(properties = {}) {
const element = createElement('c-case-summary', {
is: CaseSummary
});
Object.assign(element, properties);
document.body.appendChild(element);
return element;
}
This helper sets public properties and attaches the component as a real owner would.
It does not reach into the component instance to set private state.
If a test must mutate a private property to create a scenario, that is often a sign that the component's input boundary or separation of logic needs work.
Flush asynchronous rendering deliberately
LWC rerendering and promise callbacks are asynchronous.
const flushPromises = () => Promise.resolve();
After emitting wire data, changing a reactive public input, or resolving an imperative promise, wait for the queued work before asserting the DOM.
getCase.emit(mockCase);
await flushPromises();
expect(
element.shadowRoot.querySelector('[data-testid="subject"]').textContent
).toBe('Printer is on fire');
The await is not ceremony. It makes the point in time under test explicit.
Test wire states, not the wire implementation
Salesforce provides test adapters for generic wires, LDS wires, and Apex wires. The wire-service Jest guide shows the pattern: emit controlled data or errors, then assert how the component responds.
At minimum, I cover:
- data
- empty data
- error
If the component has a meaningful loading state, cover the state before anything is emitted too.
it('renders an error state when the wire fails', async () => {
const element = createComponent({ recordId: CASE_ID });
getCase.error({
body: { message: 'Case could not be loaded' },
status: 500
});
await flushPromises();
const alert = element.shadowRoot.querySelector('[role="alert"]');
expect(alert.textContent).toContain('Case could not be loaded');
});
The useful assertion is that the user receives an error state. Whether the implementation calls a getter named normalisedError is irrelevant.
Test events as public output
A custom event is part of the component's API.
it('requests the selected case to be opened', async () => {
const element = createComponent({ recordId: CASE_ID });
const handler = jest.fn();
element.addEventListener('caseopen', handler);
getCase.emit(mockCase);
await flushPromises();
element.shadowRoot
.querySelector('lightning-button')
.dispatchEvent(new CustomEvent('click'));
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][0].detail).toEqual({
recordId: CASE_ID
});
});
This test does not call handleOpen() directly. Users do not call controller methods. They interact with the component.
Dispatch the input event and observe the public output.
Avoid giant snapshots
Snapshots are attractive because they produce a lot of apparent coverage quickly.
They also encourage a weak review loop:
- component changes
- snapshot fails with a wall of markup
- developer updates snapshot
- nobody checks the behavioural difference
A small snapshot can be reasonable for a stable, meaningful fragment. I do not use a full shadow DOM snapshot as the main proof that an interactive component works.
Focused assertions explain failures much better.
Do not over-specify base components
Jest uses stubs for Lightning base components. Test the contract between your component and the base component:
- the correct label
- the correct value
- disabled when the operation is running
- the handler responds to an event
Do not try to retest Salesforce's internal implementation of lightning-input or lightning-datatable.
That code is not yours, and its internal DOM is not your stable API.
Extract logic when the DOM is incidental
Some component code is mostly calculation: column building, normalisation, filtering, date grouping, or result mapping.
Put substantial pure logic in a plain JavaScript module and test it directly.
export function groupCasesByPriority(cases) {
return cases.reduce((groups, caseRecord) => {
const priority = caseRecord.priority ?? 'Unspecified';
return {
...groups,
[priority]: [...(groups[priority] ?? []), caseRecord]
};
}, {});
}
The component test then proves that inputs and interactions connect to the logic. The utility tests can cover edge cases without rendering a component for every array permutation.
Know what Jest does not prove
LWC Jest tests do not run in an org or a real browser page.
They do not prove:
- Apex permissions are configured
- metadata exists in the target org
- Lightning page composition works
- a real base component renders exactly as expected
- the full user journey succeeds
Keep a smaller number of integration and end-to-end checks for those boundaries. Salesforce explicitly warns that end-to-end tests which reach into Lightning's internal DOM are fragile; use them for user journeys, not as a substitute for focused component tests.
The test should read like a requirement
My favourite LWC test names look like this:
disables save while the request is runningshows field errors returned by the serveremits the selected account idrenders an empty state when no records matchdoes not submit an invalid form
If the name instead says calls handleSave or sets isLoading to true, the test is probably staring inward.
Test what the component promises.
Then let the component change how it keeps that promise.