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 beforeflushPromises
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!