Test methods that change object fields (and some others methods) not decorated with @api using JEST in LWC

htmllightning-web-componentslwc-domlwc-jestsalesforcedx

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"
    }
]

Best Answer

When you emit the change event - you need to do it on the specific input element you're looking to change. You're currently doing it on the component itself

const element = createElement('c-AccountWebService', {
    is: AccountWebService
});
...
element.dispatchEvent(changeValue);

Whereas, you need to do it on the input you selected

return Promise.resolve().then(() => {
    const testedElement = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
    //dispatch input name change event on modalInputName
    testedElement.dispatchEvent(changeValue);
    ...
    

Likewise, you need to wait for the DOM update/re-rendering when the value changes.

const testedElement = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
//dispatch input name change event on modalInputName
testedElement.dispatchEvent(changeValue);
//let the re-render happen based on value change
return Promise.resolve().then(() => {
    expect(testedElement.value).toBe('newValue');
})

Edit:

You want to wait for DOM changes any time you dispatch an event or do an interaction that would cause one. In your test, you'd need to wait after the row action as well as after you change the input. You can accomplish this more cleanly with chaining your promises.

return Promise.resolve()
    .then(() => {
        //handlerRowAction changes (bShowModal=true) has happened so input is visible
        const testedElement = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
        testedElement.dispatchEvent(changeValue);
    })
    .then(() => {
        //re-render after onchange event on input has occurred
        const inputNameAfterChange = element.shadowRoot.querySelector('lightning-input[data-id=modalInputName]');
        expect(inputNameAfterChange.value).toBe('newValue');
    });
Related Topic