salesforce/sfdx-lwc-jest

Jest "jumping" to assertions when async service component/module/dependency is called

joeythomaschaske opened this issue · 4 comments

Description

I have a component that uses async functions imported from a service component. While testing an async event handler in the component, I noticed by step debugging that jest will break out of the async handler and jump straight to the jest assertions when one of these utility functions is called. After the assertions run it will go back and finish running the code in the async handler. This causes the test to fail because the handler code the test is asserting isn't run until after the assertions run.

 ● c-create-user-keys › creates a user key when the button is clicked

    expect(received).toBe(expected) // Object.is equality

    Expected: 1
    Received: 0

      345 |
      346 |         // then
    > 347 |         expect(createUserKey.mock.calls.length).toBe(1);
          |         ^
      348 |         expect(createUserKey.mock.calls[0][0]).toMatchObject({
      349 |             privateKey: expect.any(String),
      350 |             publicKey: expect.any(String),

      at Object.<anonymous> (force-app/main/default/lwc/createUserKeys/__tests__/createUserKeys.test.js:347:9)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 14 skipped, 15 total
Snapshots:   0 total
Time:        9.729 s
Ran all test suites within paths "/Users/user/dev/Enigma/force-app/main/default/lwc/createUserKeys/__tests__/createUserKeys.test.js".
Test results written to: .sfdx/tools/testresults/lwc/test-result-847be843-873f-4544-a058-5e262159e62a.json
Jest did not exit one second after the test run has completed.

This usually means that there are asynchronous operations that weren't stopped in your tests. Consider running Jest with `--detectOpenHandles` to troubleshoot this issue.

If I mock these utility functions then jest waits until the async handler is done before running assertions.

Is it possible for jest to use the dependency without jumping and not having to mock the dependency?

Steps to Reproduce

LWC async handler being tested

import { LightningElement } from 'lwc';
import { createAesGcmKey, createRsaKeyPair, encryptWithAesGcm, exportKey } from 'c/encryptionUtils';
import createUserKey from '@salesforce/apex/EnigmaController.createUserKey';

async handleSubmit() {
    try {
        this.isSaving = true;
        // when this is called from the service component jest jumps back to the test and executes assertions immedietly
        const { privateKey, publicKey } = await createRsaKeyPair();
        const [exportedPrivateKey, exportedPublicKey] = await Promise.all([
            exportKey(privateKey),
            exportKey(publicKey)
        ]);
        const aesKey = await createAesGcmKey(this.masterPassword);
        const { iv, cipherText: encryptedPrivateKey } = await encryptWithAesGcm(
            aesKey,
            exportedPrivateKey,
            this.masterPassword
        );

        await createUserKey({
            privateKey: encryptedPrivateKey,
            publicKey: exportedPublicKey,
            initilizationVector: iv
        });
        this.isSaving = false;
        this.dispatchEvent(new CustomEvent('keycreated'));
    } catch (e) {
        this.isSaving = false;
    }
}

jest test testing async handler functionality

jest.mock(
    '@salesforce/apex/EnigmaController.createUserKey',
    () => {
        return {
            default: jest.fn()
        };
    },
    { virtual: true }
);

it('creates a user key when the button is clicked', async () => {
    // given
    const element = createElement('c-create-user-keys', {
        is: CreateUserKeys
    });
    document.body.appendChild(element);
    const masterPasswordInput = element.shadowRoot.querySelector('lightning-input[data-id="masterPassword"]');
    const confirmPasswordInput = element.shadowRoot.querySelector('lightning-input[data-id="confirmPassword"]');

    // when
    masterPasswordInput.value = '1hH$ddddqwertyuiopasdfgh';
    masterPasswordInput.dispatchEvent(new CustomEvent('change'));
    confirmPasswordInput.value = '1hH$ddddqwertyuiopasdfgh';
    confirmPasswordInput.dispatchEvent(new CustomEvent('change'));
    await flushPromises();
    const button = element.shadowRoot.querySelector('button');
    button.dispatchEvent(new CustomEvent('click'));
    await flushPromises();

    // then
    expect(createUserKey.mock.calls.length).toBe(1);
    expect(createUserKey.mock.calls[0][0]).toMatchObject({
        privateKey: expect.any(String),
        publicKey: expect.any(String),
        initilizationVector: expect.any(String)
    });
});

Recording of step debugging:

Screen.Recording.2022-01-25.at.9.21.42.PM.mov

Based on the record, it looks like the Promise returned by createRsaKeyPair isn't resolved before flushPromises resolves. To confirm this I would recommend replacing the flushPromises occurrences in your test with a setTimeout with a large enough value (eg. 500).

Ideally, your test should listen for the keycreated event dispatched at the end of the handleSubmit before asserting against the mocked apex method.

Since the issue doesn't contain standalone reproduction steps for us to investigate further and appears to be unrelated to LWC or Jest, I would close this issue. Feel free to keep commenting on this issue, if you need extra debugging guidance.

Based on the record, it looks like the Promise returned by createRsaKeyPair isn't resolved before flushPromises resolves.

I think that's where I'm confused. Isn't the purpose of flushPromises to wait until all promises in the event loop are resolved? My hope is I don't have to use setTimeout for the async action, otherwise why does flushPromises exist?

It is used extensively in the lwc-recipes repo

Ok, I see the confusion now.

The flushPromises waits for the microtask queue to be emptied. The flushPromises don't wait for the actual promises to resolve.

LWC reflects internal component changes asynchronously to the DOM. Every time the engine detects that a component is should re-render, the engine enqueues a microtask. The DOM operations are then done synchronously in the microtask. This is why when you update a public prop in a test, you should wait for another microtask to check the DOM state.

If you need a refresher about event loop and microtasks I would recommend you give a look at this talk: What the heck is the event loop anyway? | Philip Roberts

@pmdartus Ok this makes a ton of sense now! thank you!