I've been working on a Svelte project that integrates with an auth server. If the user's session cookie expires, the app shows a modal to the user prompting them to login. The modal persists until the user logs in. Creating this modal was easy. Unit testing the modal was not.

This article is all about writing unit tests for the modal using real timers.

Write some Tests

Start with the describe block:

view plain print about
1describe('MyModal.svelte', () => {
2}

Before jumping into the test, let's add an afterEach() block:

view plain print about
1afterEach(() => {
2 vi.resetAllMocks();
3 cleanup();
4})

The afterEach() block will reset all Mocks, and run a clean up script which scrubs the virtual DOM, so every test starts fresh.

Then, start your test:

view plain print about
1it('should close modal after clicking "Do Something" with Real Timers', async () => {
2})

First step in the test is to render the modal. We're going to render it as an open modal:

view plain print about
1const { getByText, queryByRole } = render(MyModal, { props: { isModalOpen: true } });

I am pulling two functions out of the render object, getByText(), and queryByRole().

First, we'll use getByText() to grab the modal's button:

view plain print about
1const button = getByText('Do Something');

And then we'll click the button:

view plain print about
1await fireEvent.click(button);

This should trigger the button handler inside the modal, which starts the interval countdown to close the modal. Next, we have to increment the timer:

view plain print about
1await tick();

This should close the Modal. So, we can use the queryByRole() method to get the Modal and validate whether it is in the document--or not:

In practice this should work! In reality, it doesn't yet. You'll see an error like this:

If this is a long-running test, pass a timeout value as the last argument or configure it globally with "testTimeout".

Or a screenshot:

If you step through the code, you'll see that the final assertion that checks whether or not the dialog is closed executes before the method inside the interval that toggles the close. Even with a longer timeout, the test will still fail.

After much experimentation, and stepping through code I discovered that the issue lies with the animation to remove the dialog. We can solve that by spying on the animations. I'm going to create a method, which will mock the animate method, along with some of the modal methods:

view plain print about
1const mockMissingProps = () => {
2 Element.prototype.animate = vi.fn().mockImplementation(()=> ({
3 cancel: () => Promise.resolve(undefined)
4 }))
5 HTMLDialogElement.prototype.showModal = vi.fn().mockImplementation(() => {
6 Promise.resolve();
7 })
8 HTMLDialogElement.prototype.close = vi.fn().mockImplementation(() => {
9 Promise.resolve();
10 })
11}

Add a call to this method as the first line in the unit test:

view plain print about
1mockMissingProps();

We need to do one more thing. I dug out the actual Modal Code to discover it uses the Svelte fade transition. We want to mock this so that the transition is immediate, with no delays or duration:

This mock is added at the top of the test file, outside of the describe() statement. Now, re run the tests:

Success!

Final Thoughts

It took me four days and a lot of errors, even with the help of AI tools, to determine what mocks needed to be set up to get this test working.

Along the way I also created a version of this test using fake timers. That will be the topic of my next post.

Be sure to check out my Blog's repo for samples and follow along.