I have a form that shows a status of 'submitting' while processing and the 'submitted' when done.
This is the submit handler I'm using...
const handleSubmit = (e, _setTitle) => {
e.preventDefault()
_setTitle('Submitting...')
try {
doformStuff(emailRef.current.value)
} catch (err) {
throw new Error(err)
} finally {
_setTitle('Submitted perfectly.')
}
}
I'd like to test that the submitting state appears, no matter how brief.
it('shows "submitting" when submitting', async () => {
// arrange
render(<MobileEmailCapture/>)
// act
const emailInput = screen.getByPlaceholderText('[email protected]')
userEvent.type(emailInput, fakeEmail)
fireEvent.submit(screen.getByTestId('form'))
// assert
expect(screen.getByTestId('title')).toHaveTextContent('Submitting...')
})
The problem is the test jumps straight to the the submitted state
Error: FAILED Expected 'Submitted perfectly.' to match 'Submitting...'.
I understand that's where it ends up, but I'd like to test the temporary transitional state. How do I do that?
I figured out a way to solve this, although it does require some explanation. I will start with some raw but abstract code, and I'll explain it while relating it to your specific problem.
import {waitFor} from "@testing-library/react"
describe('Button should', () => {
let sideEffect = NO_SIDE_EFFECT
let lock = {locked: true}
beforeEach(() => {
sideEffect = NO_SIDE_EFFECT
})
afterEach(() => {
lock.locked = false
})
it("Side effect not applied", async () => {
changeStateLater()
await waitFor(() => expect(sideEffect).toBe(NO_SIDE_EFFECT))
})
it("Side effect applied", async () => {
changeStateLater()
applyChangeOfState()
await waitFor(() => expect(sideEffect).toBe(SIDE_EFFECTED))
})
async function changeStateLater() {
await delayWhile(() => lock.locked)
sideEffect = SIDE_EFFECTED
}
function applyChangeOfState() {
lock.locked = false
}
})
async function delayWhile(condition: () => boolean, timeout = 2000) {
const started = performance.now();
while (condition()) {
const elapsed = performance.now() - started;
if (elapsed > timeout) throw Error("delayWhile lock was never released");
await syncronousDelay();
}
}
function syncronousDelay (n: number = 0) {
return new Promise((done: any) => {
setTimeout(() => { done() }, n)
})
}
const NO_SIDE_EFFECT = "no side effect"
const SIDE_EFFECTED = "side effected"
Okay, this may be more code than I needed to show. I wanted to give you a complete working example so that, if you are like me and need to see something working and mess around with it in order to understand it, you can test it yourself.
The idea here is that we generate an asyncronous lock, and always generate a new locked lock inside of the beforeEach. "changeStateLater" represents any async call that you want to call and test something before it finished (In your case "doformStuff"). This function will call "delayWhile", and you send it a function with the locks value. delayWhile loops over a syncronous delay until the lock has been resolved. Since this validation will be called every time the call stack is emptied, you can deterministically unlock it whenever you want (like I do on "applyChangeOfState"). Since this will be called once the call stack is empty, if you were to evaluate the result directly, this may fail due to not having applied the change yet, so you should wrap the expectation in a waitFor or use a findBy react query. Finally, the afterEach bit makes sure that we unlock it so that it can always exit the loop neatly and not by timeout.
First of all, doformStuff needs to be async, and you need to await it inside of handleSubmit. Otherwise, you will never show the intermediate state.
You may want to mock doformStuff in the test and use my asyncronous lock "delayWhile" inside it. An easy way to do this is to have that function be a dependency of the rendered component, but if that is not posible, you may also move the function into a separate file and call it from there. On the test you can the do:
import * as foo from "@/whatever_your_file_is";
describe("Bar", () => {
const doformStuffSpy = jest.spyOn(foo, "doformStuff")
let api_lock = { locked: true }
beforeEach(() => {
// refresh the lock object
api_lock = { locked: true }
doformStuffSpy.mockReset()
doformStuffSpy.mockReturnValue(Promise.resolve())
});
afterEach(() => {
// clear the lock so that it does not keep polling until timeout
api_lock.locked = false
})
it('shows "submitting" when submitting', async () => {
// arrange
render(<MobileEmailCapture/>)
// act
const emailInput = screen.getByPlaceholderText('[email protected]')
userEvent.type(emailInput, fakeEmail)
// this will lock doformStuff and not let it return until we say so
doformStuffSpy.mockImplementation(() => delayWhile(() => api_lock.locked))
fireEvent.submit(screen.getByTestId('form'))
// assert
expect(screen.getByTestId('title')).toHaveTextContent('Submitting...')
})
function call_this_if_you_want_to_unlock_it() {
api_lock.locked = false
}
}
async function delayWhile(condition: () => boolean, timeout = 2000) {
const started = performance.now();
while (condition()) {
const elapsed = performance.now() - started;
if (elapsed > timeout) throw Error("delayWhile lock was never released");
await syncronousDelay();
}
}
function syncronousDelay (n: number = 0) {
return new Promise((done: any) => {
setTimeout(() => { done() }, n)
})
}
I hope this was somewhat clear, just tell me if it wasn't and I will happily solve any question you may have.
If you want a simpler but flakier implementation, you could just add some syncronous delay inside of the mocked function. That would free you from using delayWhile. Just replace the following line in the previous example and remove the api_lock:
//doformStuffSpy.mockImplementation(() => delayWhile(() => api_lock.locked))
doformStuffSpy.mockReturnValue(new Promise(resolve => setTimeout(resolve, 2000)))
This, of course, has the problem of being non deterministic. Although 2 seconds (or any such value for that matter) should be enough for the test logic that you have to run, it feels kinda weird. You may still use it tho if the "delayWhile" solution seems a bit too overwhelming for your use case.
The end result would look something like:
import * as foo from "@/whatever_your_file_is";
describe("Bar", () => {
const doformStuffSpy = jest.spyOn(foo, "doformStuff")
beforeEach(() => {
doformStuffSpy.mockReset()
doformStuffSpy.mockReturnValue(Promise.resolve())
});
it('shows "submitting" when submitting', async () => {
// arrange
render(<MobileEmailCapture/>)
// act
const emailInput = screen.getByPlaceholderText('[email protected]')
userEvent.type(emailInput, fakeEmail)
// this will lock doformStuff for 2 seconds, should be enough to make our assertions
doformStuffSpy.mockReturnValue(new Promise(resolve => setTimeout(resolve, 2000)))
fireEvent.submit(screen.getByTestId('form'))
// assert
expect(screen.getByTestId('title')).toHaveTextContent('Submitting...')
})
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With