My LWC application includes a modal window and is written for use in Salesforce and uses Apex. I had a hard time writing tests for some functions, but I couldn't test the application until the end. I can't test the functions that should be triggered AFTER opening the modal window and triggering some buttons. This is switching to the recording editing mode, editing the recording fields and canceling the transition to editing mode. And (to be honest) I was able to test the very fact of closing the modal window only because I set an absolutely unnecessary event.
When I try to write a test, for example, on the method of changing the account name, I get an error due to the fact that the value of the field has not changed (i.e. it is still filled in with the data "TestAccountForWebService" and not "newValue").
The rest of the tests given here pass (I will delete them if they are not needed for the question).
In practice, the application works fine, I will specify the untested lines that I determined using command (in terminal): npm run test:unit:coverage
Some details: AccountSource2 is a custom field, not relevant to my question.
UPDATE: Initially, I received the tested element (lightning-input - in my case) via querySelector and called the dispatchEvent(changeValue) method for it inside the "Promise.resolve().the(() =>" block. Then I got an error due to the fact that the value has not changed and is equal to the originally filled ("TestAccountForWebService" and not "newValue"). After following the solution from Kris Goncalves (see below) I changed the test and took out these lines, and put them before the Promise block, the error changed to " TypeError: Cannot read properties of null (reading 'dispatchEvent') " when trying to call the dispatchEvent method.
js-meta.xml code:
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>52.0</apiVersion>
<isExposed>true</isExposed>
<targets>
<target>lightning__RecordPage</target>
<target>lightning__Tab</target>
</targets>
</LightningComponentBundle>
html code:
<template>
<div>
<lightning-button variant="brand-outline" label="Create new record" title="CreateRecord" onclick={CreateRecord} class="slds-m-left_x-small" data-id="CreateRecordButton"></lightning-button>
<lightning-datatable data-id='dataTable' data={wiredAccounts} columns={columnsForManyAccs} onrowaction={handlerRowAction} key-field="Id" hide-checkbox-column="true">
</lightning-datatable>
</div>
<template if:true={bShowModal} data-id="modalWindowTemplate">
<section role="dialog"
tabindex="-1"
aria-labelledby="modal-heading-01"
aria-modal="true"
aria-describedby="modal-content-id-1"
class="slds-modal slds-fade-in-open"
data-id="modalWindow">
<div class="slds-modal__container" data-id="modalWindowContainer">
<!-- modal header start -->
<header class="slds-modal__header">
<button class="slds-button slds-button_icon slds-modal__close slds-button_icon-inverse" title="Close" onclick={closeModal}>
<lightning-icon icon-name="utility:close" alternative-text="close" variant="inverse" size="small" ></lightning-icon>
</button>
<h2 id="modal-heading-01" class="slds-text-heading_medium slds-hyphenate">Account</h2>
</header>
<!-- modal body start -->
<div class="slds-form-element__control backgroundWhite" data-id="modalWindowBody">
<lightning-input required variant="label-inline" label="Name" readonly={isEditingProhibited} type="text" id="text-input-id-1" value={account.Name} onchange={changeName} data-id="modalInputName"></lightning-input>
<lightning-input variant="label-inline" label="Website" readonly={isEditingProhibited} type="url" id="text-input-id-2" value={account.Website} onchange={changeWebsite} placeholder="SomeTestURL.com"></lightning-input>
<lightning-input variant="label-inline" label="Phone number" readonly={isEditingProhibited} type="tel" id="text-input-id-3" value={account.Phone} onchange={changePhone} placeholder="000-000-0000" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}"></lightning-input>
<lightning-input variant="label-inline" label="AccountSource" readonly={isEditingProhibitedForAccountSource} type="text" id="text-input-id-4" value={account.AccountSource} onchange={changeAccountSource}></lightning-input>
<lightning-input variant="label-inline" label="Account Source 2" readonly={isEditingProhibited} type="text" id="text-input-id-5" value={account.AccountSource2} onchange={changeAccountSource2}></lightning-input>
</div>
<!-- modal footer start-->
<footer class="slds-modal__footer">
<template if:true={isEditingProhibited}>
<lightning-button variant="brand" label="Edit record" title="EditRecord" onclick={EditRecord} class="slds-m-left_x-small" data-id="EditRecordButton"></lightning-button>
<lightning-button variant="destructive" label="Delete" title="DeleteRecord" onclick={DeleteRecord} class="slds-m-left_x-small" data-id="DeleteRecordButton"></lightning-button>
</template>
<template if:false={isEditingProhibited}>
<lightning-button variant="brand" label="Save record" id="SaveAccButton" title="SaveRecord" onclick={SaveRecord} class="slds-m-left_x-small" data-id="SaveRecordButton"></lightning-button>
<lightning-button variant="destructive" label="Cancel" title="CancelEditRecord" onclick={CancelEditRecord} disabled={isCancelNeed} class="slds-m-left_x-small" data-id="CancelEditRecordButton"></lightning-button>
</template>
<lightning-button variant="brand" label="Close" title="Close" onclick={closeModal} class="slds-m-left_x-small" data-id="closeModalWindow"></lightning-button>
</footer>
</div>
</section>
<div class="slds-backdrop slds-backdrop_open"></div>
</template>
</template>
js code:
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { LightningElement, api, wire,track } from 'lwc';
import getAllAccounts from '@salesforce/apex/AccountServiceLWCHandler.GetAllAccounts';
import saveAccount from '@salesforce/apex/AccountServiceLWCHandler.SaveAccount';
import deleteAccount from '@salesforce/apex/AccountServiceLWCHandler.DeleteAccount';
import { refreshApex} from '@salesforce/apex';
const columnsForManyAccs = [
{
label: 'View',
type: 'button-icon',
initialWidth: 75,
typeAttributes: {
iconName: 'action:preview',
title: 'Detail',
variant: 'border-filled',
alternativeText: 'View'
}
},
{ label: 'Name', fieldName: 'Name' ,wrapText: true, hideDefaultActions: true },
{ label: 'Website', fieldName: 'Website', type: 'url',wrapText: true, hideDefaultActions: true },
{ label: 'Phone', fieldName: 'Phone', type: 'phone',wrapText: true, hideDefaultActions: true },
{ label: 'AccountSource', fieldName: 'AccountSource',wrapText: true, hideDefaultActions: true },
{ label: 'AccountSourc2oo', fieldName: 'AccountSource2',wrapText: true, hideDefaultActions: true }
];
const columnsForOneAcc = [
{ label: 'Name', fieldName: 'Name' ,wrapText: true, hideDefaultActions: true },
{ label: 'Website', fieldName: 'Website', type: 'url',wrapText: true, hideDefaultActions: true },
{ label: 'Phone', fieldName: 'Phone', type: 'phone',wrapText: true, hideDefaultActions: true },
{ label: 'AccountSource', fieldName: 'AccountSource',wrapText: true, hideDefaultActions: true },
{ label: 'AccountSourc2', fieldName: 'AccountSource2',wrapText: true, hideDefaultActions: true }
];
export default class AccountWebService extends LightningElement {
columnsForManyAccs = columnsForManyAccs;
columnsForOneAcc=columnsForOneAcc;
wiredAccounts=[];
@track wiredData;
@api realFormData='some';
bShowModal=false;
@track account;
isEditingProhibited=true;
isEditingProhibitedForAccountSource=true;
isCancelNeed=true;
@track editedAccount=null;
@wire(getAllAccounts)wireBlastData(response)
{
this.wiredData=response;
this.wiredAccounts=this.wiredData.data;
console.log(this.wiredAccounts);
}
handlerRowAction(event){
var tmp=event.detail.row;
console.log('handlerRowAction1',event.detail.row);
console.log('handlerRowAction2',tmp.Name);
console.log('handlerRowAction2',tmp.Id);
console.log('handlerRowAction3',tmp);
this.account={Id:'',Name:'',Phone:'',Website:'',AccountSource:'',AccountSource2:''};
if(tmp.AccountSource){
this.account.AccountSource=tmp.AccountSource;
}
if(tmp.AccountSource2){
this.account.AccountSource2=tmp.AccountSource2;
}
if(tmp.Phone){
this.account.Phone=tmp.Phone;
}
if(tmp.Website){
this.account.Website=tmp.Website;
}
this.account.Id=tmp.Id;
this.account.Name=tmp.Name;
this.editedAccount={...this.account};
console.log('editedAcc:'+this.editedAccount.Name);
this.bShowModal = true;
}
closeModal() {
this.bShowModal = false;
//absolutely unnecessary event, I inserted it here only for test
const event = new CustomEvent('close_modal_event');
this.dispatchEvent(event);
}
CreateRecord(){
this.account={Id:'',Name:'TestAccountForWebService0',Phone:'000-000-0000',Website:'SomeTestURL.com',AccountSource:'Other',AccountSource2:'NotOther'};
this.bShowModal=true;
this.isEditingProhibited=false;
this.isEditingProhibitedForAccountSource=false;
}
showToastMessage(messageToSet,variantToSet){
const evt = new ShowToastEvent({
title: 'Info',
message:messageToSet,
variant: variantToSet,
mode: 'sticky'
});
this.dispatchEvent(evt);
}
DeleteRecord(){
deleteAccount({strId:this.account.Id}).then(response=>{
if(response)
{
this.bShowModal=false;
refreshApex(this.wiredData);
this.showToastMessage('Ok: id='+response,'success');
}
else
{
//!!!!Untested lines begin below and to the end of the file
this.showToastMessage('Not ok','error');
}
});
}
SaveRecord(){
this.isEditingProhibited=true;
this.isEditingProhibitedForAccountSource=true;
saveAccount({acc:this.account}).then(response=>{
if(response)
{
refreshApex(this.wiredData);
this.showToastMessage('Ok: id='+response,'success');
this.editedAccount=null;
this.bShowModal=false;
}
else
{
this.showToastMessage('Not ok','error');
}
});
}
changeName(event){
console.log(event.detail.value);
this.account.Name=event.detail.value;
}
changeWebsite(event){
this.account.Website=event.detail.value;
}
changePhone(event){
this.account.Phone=event.detail.value;
}
changeAccountSource(event){
this.account.AccountSource=event.detail.value;
}
changeAccountSource2(event){
this.account.AccountSource2=event.detail.value;
}
EditRecord(){
this.isEditingProhibited=false;
this.isCancelNeed=false;
}
CancelEditRecord(){
this.isEditingProhibited=true;
console.log('editedAcc2:'+this.editedAccount.Name);
this.account= {...this.editedAccount};
console.log('editedAcc3:'+this.editedAccount.Name);
console.log('account:'+this.account.Name);
this.isCancelNeed=true;
}
}
tests:
import { createElement } from 'lwc';
import AccountWebService from 'c/accountWebService';
import getAllAccounts from '@salesforce/apex/AccountServiceLWCHandler.GetAllAccounts';
import { registerApexTestWireAdapter } from '@salesforce/sfdx-lwc-jest';
import deleteAccount from '@salesforce/apex/AccountServiceLWCHandler.DeleteAccount';
const mockRecords = require('./data/getAllAccounts.json');
const getDataAdapter = registerApexTestWireAdapter(getAllAccounts);
jest.mock('@salesforce/apex/AccountServiceLWCHandler.SaveAccount',()=>
{
return{default: jest.fn()};
},
{
virtual:true
});
describe('c-unit-test', () => {
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
});
//THIS TEST FAIL
it('ChangeName method testing', () => {
const rowActionEvent = new CustomEvent("rowaction", {
detail:
{
row:
{
Id:mockRecords[0].Id,
Name:mockRecords[0].Name,
Phone:mockRecords[0].Phone,
Website:mockRecords[0].Website,
AccountSource:mockRecords[0].AccountSource,
AccountSource2:mockRecords[0].AccountSource2
}
}
});
const changeValue = new CustomEvent("change", { detail: { value:"newValue" } });
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
getDataAdapter.emit(mockRecords);
const table = element.shadowRoot.querySelector('lightning-datatable');
table.dispatchEvent(rowActionEvent);
const testedElement = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
//dispatch input name change event on modalInputName
//a new error occurs on the line below : TypeError: Cannot read properties of null (reading 'dispatchEvent')
testedElement.dispatchEvent(changeValue);
//let the re-render happen based on value change
return Promise.resolve().then(() => {
expect(testedElement.value).toBe('newValue');
});
});
it('CreateRecord button testing', () => {
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
getDataAdapter.emit(mockRecords);
const button = element.shadowRoot.querySelector('lightning-button[data-id=CreateRecordButton]');
button.dispatchEvent(new CustomEvent("click"));
return Promise.resolve().then(() => {
const lInput = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
const section = element.shadowRoot.querySelector('section[data-id=modalWindow]');
expect(section).not.toBeNull();
expect(lInput).not.toBeNull();
expect(lInput.label).toBe('Name');
expect(lInput.value).toBe('TestAccountForWebService0');
});
});
it('DeleteRecord button testing', () => {
const MOCK_APEX_RESPONSE = mockRecords[0].Id;
const rowActionEvent = new CustomEvent("rowaction", {
detail:
{
row:
{
Id:mockRecords[0].Id,
Name:mockRecords[0].Name,
Phone:mockRecords[0].Phone,
Website:mockRecords[0].Website,
AccountSource:mockRecords[0].AccountSource,
AccountSource2:mockRecords[0].AccountSource2
}
}
});
const EXPECTED_APEX_PARAMETERS =
{
"strId" : mockRecords[0].Id
};
deleteAccount.mockResolvedValue(MOCK_APEX_RESPONSE);
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
getDataAdapter.emit(mockRecords);
const table = element.shadowRoot.querySelector('lightning-datatable');
table.dispatchEvent(rowActionEvent);
return Promise.resolve().then(() => {
const button = element.shadowRoot.querySelector('lightning-button[data-id=DeleteRecordButton]');
button.dispatchEvent(new CustomEvent("click"));
expect(deleteAccount.mock.calls[0][0]).toEqual(EXPECTED_APEX_PARAMETERS);
});
});
it('The modal window is NOT rendered and NOT filled', () => {
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
getDataAdapter.emit(mockRecords);
return Promise.resolve().then(() => {
const lInput = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
const section = element.shadowRoot.querySelector('section[data-id=modalWindow]');
expect(lInput).toBeNull();
expect(section).toBeNull();
const button = element.shadowRoot.querySelector('lightning-button[data-id=closeModalWindow]');
expect(button).toBeNull();
});
});
it('The modal window is rendered and filled AND CLOSED', () => {
const rowActionEvent = new CustomEvent("rowaction", {
detail:
{
row:
{
Id:mockRecords[0].Id,
Name:mockRecords[0].Name,
Phone:mockRecords[0].Phone,
Website:mockRecords[0].Website,
AccountSource:mockRecords[0].AccountSource,
AccountSource2:mockRecords[0].AccountSource2
}
}
});
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
getDataAdapter.emit(mockRecords);
const table = element.shadowRoot.querySelector('lightning-datatable');
table.dispatchEvent(rowActionEvent);
const handler = jest.fn();
element.addEventListener('close_modal_event', handler);
return Promise.resolve().then(() => {
//const section = element.shadowRoot.querySelector('section[data-id=modalWindow]');
//expect(section).not.toBeNull();
const button = element.shadowRoot.querySelector('lightning-button[data-id=closeModalWindow]');
button.click();
expect(handler).toHaveBeenCalled();
});
});
it('The modal window is rendered and filled', () => {
const rowActionEvent = new CustomEvent("rowaction", {
detail:
{
row:
{
Id:mockRecords[0].Id,
Name:mockRecords[0].Name,
Phone:mockRecords[0].Phone,
Website:mockRecords[0].Website,
AccountSource:mockRecords[0].AccountSource,
AccountSource2:mockRecords[0].AccountSource2
}
}
});
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
getDataAdapter.emit(mockRecords);
const table = element.shadowRoot.querySelector('lightning-datatable');
table.dispatchEvent(rowActionEvent);
return Promise.resolve().then(() => {
const testedElement = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
//expect(testedElement.label).toBe('Name');
expect(testedElement.value).toBe('TestAccountForWebService');
});
});
it('Renders my component', () => {
// Create element
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
return Promise.resolve().then(() => {
const content = document.body.querySelector('c-AccountWebService');
expect(content).not.toBeNull();
});
});
it('Renders my data', () => {
// Create element
const element = createElement('c-AccountWebService', {
is: AccountWebService
});
document.body.appendChild(element);
getDataAdapter.emit(mockRecords);
return Promise.resolve().then(() => {
const table = element.shadowRoot.querySelector('lightning-datatable[data-id=dataTable]');
expect(table).not.toBeNull();
expect(table.data.length).toBe(mockRecords.length);
expect(table.data[0].Id).toBe(mockRecords[0].Id);
});
});
});
file for tests getAllAccounts.json:
[
{
"Id":"testId1",
"Name":"TestAccountForWebService",
"Phone":"000-000-0000",
"Website":"SomeTestURL.com",
"AccountSource":"Other",
"AccountSource2":"NotOther"
},
{
"Id":"testId2",
"Name":"TestAccountForWebService2",
"Phone":"000-000-0000",
"Website":"SomeTestURL.com",
"AccountSource":"Other",
"AccountSource2":"NotOther"
}
]
changeName
handler, then you'd need to throw a change event from that specific input element so that handler can run. Is there a reason you're throwing a "success" event on that input element instead?