Testing Lightning Web Components with JEST
Like we do for Apex, now we can also add some unit tests to our UI components. Making sure our component does not break and also preventing errors on real time coding.
Few Highlights to get the context here :
- Jest is a powerful tool with rich features for writing JavaScript tests.
- Use Jest to write unit tests for all of your Lightning web components, they don’t work with Aura components.
- Jest tests don’t run in a browser or connect to an org, so they run fast.
- When run in “watch mode” they give you immediate feedback while you’re coding.
Write Jest tests to:
- Test a component in isolation
- Test a component’s public API (@api properties and methods, events)
- Test basic user interaction (clicks)
- Verify the DOM output of a component
- Verify that events fire when expected
Lets get started
- Install Jest – Install Jest and Its Dependencies into Your Project with the Salesforce CLI
sfdx force:lightning:lwc:test:setup
- Run the command from the top-level directory of each Salesforce DX project.
- This command creates the necessary configuration files and installs the sfdx-lwc-jest package
The following scripts will be added to your package.json
file and can be run with npm run ...
{ ... "scripts": { ... "test": "npm run test:unit", "test:unit": "sfdx-lwc-jest", "test:unit:watch": "sfdx-lwc-jest --watch", "test:unit:debug": "sfdx-lwc-jest --debug", "test:unit:coverage": "sfdx-lwc-jest --coverage", ... }, ... }
Create Test Folder and files
The following sfdx command will add a sub folder to each component folder named __tests__
:
sfdx force:lightning:lwc:test:create
-f | FILEPATH – Path to Lightning web component .js file to create a test for.
- You will also get a boiler plate file with a convention you will want to maintain ending of your tests files with
yourcomponent.test.js
- You cannot deploy those tests into your Scratch or Dev org so make sure you also add those tests folders into the
.forceignore
file.
# LWC Jest **/__tests__/**
Run your Tests on the Command Line
- To run all tests for your project (use sfdx or npm to run the commands) :
sfdx force:lightning:lwc:test:run
npm run test:unit
Run Tests Continuously During Development
npm run test:unit:watch
Lets Start Testing
The ‘Hello World’ component test
Let’s look on this quick example for writing our first unit test.
- Sample component from lwc-recipes / hello.
<template> <lightning-card title="Hello" icon-name="custom:custom14"> <div class="slds-var-m-around_medium"> Hello, {greeting}! </div> </lightning-card> </template>
Simple lightning card to display a greeting
.
import { LightningElement } from 'lwc'; export default class Hello extends LightningElement { greeting = 'World'; }
What are we Testing ?
On our unit tests we can validate and test the following:
- Does
greeting
display as expected ?
Creating the Test folder and files
For this example let’s start by creating a test directory with a boilerplate test file for your component JS:
sfdx force:lightning:lwc:test:create -f force-app/main/default/lwc/hello/hello.js
Then change the test.js file to perform the test:
// hello.test.js import { createElement } from 'lwc'; import Hello from 'c/hello'; describe('c-hello', () => { afterEach(() => { // The jsdom instance is shared across test cases in a single file so reset the DOM while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } }); it('displays greeting', () => { // Create element const element = createElement('c-hello', { is: Hello }); document.body.appendChild(element); // Verify displayed greeting const div = element.shadowRoot.querySelector('div'); expect(div.textContent).toBe('Hello, World!'); }); });
Test Steps breakdown
- Imports:
- Imports the
createElement
method. This method is available only in tests. - Import the component to test, which in this case is
c/hello
.
- Imports the
describe
block- Defines A test suite that can contains one or more tests that belong together from a functional point of view.
- Cleanup
- The Jest
afterEach()
method resets the DOM at the end of the test.
- The Jest
it
(ortest
) block- An
it
block describes a single test. A test represents a single functional unit that you want to test. Write the it to describe the expected behavior of that function.
- An
- Create the component under test
- Use the imported createElement method to create an instance of the component to test,
- Add the component to the DOM
- The test then calls
appendChild
to add the component to the test’s version ofdocument
.
- The test then calls
- Use a standard DOM query method to search the DOM for the element you wish to test.
- Asserts
- Finally, the expect statement is an assertion of the success condition: that the text of the element is “Hello, World!”
Test Asynchronous DOM Updates
- When the state of a Lightning web component changes, the DOM updates asynchronously. To ensure that your test waits for updates to complete before evaluating the result, return a resolved Promise.
- Useful when dealing with
@api
public properties that needs to be populated in order to render the component.
test('element does not have slds-icon class when bare', () => { const element = createElement('one-primitive-icon', { is: PrimitiveIcon }); document.body.appendChild(element); // Property value is assigned after the component is inserted into the DOM element.variant = "bare"; // Use a promise to wait for asynchronous changes to the DOM return Promise.resolve().then(() => { expect(element.classList).not.toContain('slds-icon'); }); });
The Detail Product Card component Using Lightning Data Service
- Let’s look on this sample component that is using the Lightning Data Service (LDS) with
@wire
decorator withgetRecord
wire adapter to provision record data.
<!-- productCard.html --> <template> <div class="content"> <template if:true={product}> <div class="name"> <div>Name:</div> <div>{name}</div> </div> </template> </div> </template>
// productCard.js import { LightningElement, wire } from 'lwc'; // Wire adapter to load records. import { getRecord } from 'lightning/uiRecordApi'; export default class ProductCard extends LightningElement { // Id of Product__c to display. recordId; // Product__c to display // product; // Product__c field values to display. // name = ''; @wire(getRecord, { recordId: '$recordId', fields }) wiredRecord({ data }) { if (data) { this.product = data; this.name = data.fields.Name.value; } } }
What are we Testing ?
- The unit test will register a test on the wire adapter to emit the mock data defined in the JSON file. Then, it checks that the product name field displays that data.
Write Jest Tests for Lightning Web Components That Use the Wire Service
To test how these components handle data and errors from the wire service, use the @salesforce/sfdx-lwc-jest
test utility.
In the __tests__
folder, create a data folder, and a JSON file called wireAdapter.json.
- Save a payload snapshot on the JSON file.
For example, this data is a response from /ui-api/records/{recordId}
, which is the resource that backs the getRecord
wire adapter.
{ "fields": { "Name": { "value": "DYNAMO X1" } } }
- In a real test, mock all the data that your component expects.
In the componentName.test.js file:
// productCard.test.js import { createElement } from 'lwc'; import { registerLdsTestWireAdapter } from '@salesforce/sfdx-lwc-jest'; import ProductCard from 'c/productCard'; import { getRecord } from 'lightning/uiRecordApi'; // Import mock data to send through the wire adapter. const mockGetRecord = require('./data/getRecord.json'); // Register a test wire adapter to control @wire(getRecord) const getRecordWireAdapter = registerLdsTestWireAdapter(getRecord);
First, the test creates the component and appends it to the DOM. The component receives updates about data only when the component is connected to the DOM. After the component connects, pass the mocked data to the emit function on the wire adapter.
describe('@wire demonstration test', () => { // Disconnect the component to reset the adapter. It is also // a best practice to clean up after each test. afterEach(() => { while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } }); it('displays product name field', () => { const element = createElement('c-product_filter', { is: ProductCard }); document.body.appendChild(element); getRecordWireAdapter.emit(mockGetRecord); // Resolve a promise to wait for a rerender of the new content. return Promise.resolve().then(() => { const content = element.querySelector('.content'); const nameField = mockGetRecord.fields.Name.value; expect(content.textContent).toBe('Name:${nameField}') }); }); });
Code Breakdown:
- Import the component under test and its wire adapter.
- Import registerLdsTestWireAdapter(wireAdapter) from
@salesforce/sfdx-lwc-jest
. - Import the mock data from the JSON file you created.
- Register the test wire adapter.
The describe
block will show the following steps:
- Emit the mock data.
- Verify that the component received the mock data as expected.
Write Jest Tests for Lightning Web Components That Use Apex Imperative Methods
Lets look now on the apexImperativeMethod example
<lightning-button label="Load Contacts" onclick={handleLoad} > </lightning-button>
The lightning-button will call onclick to fetch data from apex.
import { LightningElement } from 'lwc'; import getContactList from '@salesforce/apex/ContactController.getContactList'; export default class ApexImperativeMethod extends LightningElement { contacts; error; handleLoad() { getContactList() .then((result) => { this.contacts = result; this.error = undefined; }) .catch((error) => { this.error = error; this.contacts = undefined; }); } }
Writing our tests Mock Data
- we will use the
jest.mock()
to mimic the response from server
import { createElement } from 'lwc'; import ApexImperativeMethod from 'c/apexImperativeMethod'; import getContactList from '@salesforce/apex/ContactController.getContactList'; // Mocking imperative Apex method call jest.mock( '@salesforce/apex/ContactController.getContactList', () => { return { default: jest.fn() }; }, { virtual: true } ); // Sample data for imperative Apex call const APEX_CONTACTS_SUCCESS = [ { Id: '0031700000pJRRSAA4', Name: 'Amy Taylor', Title: 'VP of Engineering', Phone: '4152568563', Email: '[email protected]', Picture__c: 'https://s3-us-west-1.amazonaws.com/sfdc-demo/people/amy_taylor.jpg' }, { Id: '0031700000pJRRTAA4', Name: 'Michael Jones', Title: 'VP of Sales', Phone: '4158526633', Email: '[email protected]', Picture__c: 'https://s3-us-west-1.amazonaws.com/sfdc-demo/people/michael_jones.jpg' } ]; // Sample error for imperative Apex call const APEX_CONTACTS_ERROR = { body: { message: 'An internal server error has occurred' }, ok: false, status: 400, statusText: 'Bad Request' };
Describe the test block
describe('c-apex-imperative-method', () => { afterEach(() => { // The jsdom instance is shared across test cases in a single file so reset the DOM while (document.body.firstChild) { document.body.removeChild(document.body.firstChild); } // Prevent data saved on mocks from leaking between tests jest.clearAllMocks(); }); // Helper function to wait until the microtask queue is empty. This is needed for promise // timing when calling imperative Apex. function flushPromises() { // eslint-disable-next-line no-undef return new Promise((resolve) => setImmediate(resolve)); } it(...)
Positive test for Success results
it('renders two contacts returned from imperative Apex call', () => { // Assign mock value for resolved Apex promise getContactList.mockResolvedValue(APEX_CONTACTS_SUCCESS); // Create initial element const element = createElement('c-apex-imperative-method', { is: ApexImperativeMethod }); document.body.appendChild(element); // Select button for executing Apex call const buttonEl = element.shadowRoot.querySelector('lightning-button'); buttonEl.click(); // Return an immediate flushed promise (after the Apex call) to then // wait for any asynchronous DOM updates. Jest will automatically wait // for the Promise chain to complete before ending the test and fail // the test if the promise ends in the rejected state. return flushPromises().then(() => { // Select div for validating conditionally changed text content const detailEls = element.shadowRoot.querySelectorAll( 'p:not([class])' ); expect(detailEls.length).toBe(APEX_CONTACTS_SUCCESS.length); expect(detailEls[0].textContent).toBe( APEX_CONTACTS_SUCCESS[0].Name ); expect(detailEls[1].textContent).toBe( APEX_CONTACTS_SUCCESS[1].Name ); }); });
Negative test to handle errors
it('renders the error panel when the Apex method returns an error', () => { // Assign mock value for rejected Apex promise getContactList.mockRejectedValue(APEX_CONTACTS_ERROR); // Create initial element const element = createElement('c-apex-imperative-method', { is: ApexImperativeMethod }); document.body.appendChild(element); // Select button for executing Apex call const buttonEl = element.shadowRoot.querySelector('lightning-button'); buttonEl.click(); // Return an immediate flushed promise (after the Apex call) to then // wait for any asynchronous DOM updates. Jest will automatically wait // for the Promise chain to complete before ending the test and fail // the test if the promise ends in the rejected state. return flushPromises().then(() => { const errorPanelEl = element.shadowRoot.querySelector( 'c-error-panel' ); expect(errorPanelEl).not.toBeNull(); }); });
Test Event Handlers
Looking at the same miscNotification.js example – we can also mock an event handler such as Toast notifications.
- the
ShowToastEventName
event triggersjest.fn()
which inserts the constants into the lightning-input element.
import { ShowToastEventName } from 'lightning/platformShowToastEvent';
- Describe and cleanup and lets focus on the test itself.
it('shows custom toast events based on user input', () => { const TOAST_TITLE = 'The Title'; // Create initial element const element = createElement('c-misc-notification', { is: MiscNotification }); document.body.appendChild(element); // Mock handler for toast event const handler = jest.fn(); // Add event listener to catch toast event element.addEventListener(ShowToastEventName, handler); // Select input field for simulating user input const inputTitleEl = element.shadowRoot.querySelector( 'lightning-input[data-id="titleInput"]' ); inputTitleEl.value = TOAST_TITLE; inputTitleEl.dispatchEvent(new CustomEvent('change')); // Select button for simulating user interaction const buttonEl = element.shadowRoot.querySelector('lightning-button'); buttonEl.click(); // validate ... }
What are we Testing ?
- We can verify that the event handler was called as expected, and with the correct parameters.
return Promise.resolve().then(() => { // Check if toast event has been fired expect(handler).toHaveBeenCalled(); expect(handler.mock.calls[0][0].detail.title).toBe(TOAST_TITLE); });