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

If you're following along, I already showed you how to test the modal in real time. This post will focus on testing the modal with fake timers.

Write the Real Time Tests

Let's write the 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 Fake Timers', async () => {
2})

First step in the test is to enable the fake timers:

view plain print about
1vi.useFakeTimers();

This tells the vitest framework to enable spy's on things like setTimeout() and interval() methods, so we can manually control them.

Then, render the modal. Pass in the isModalOpen property as true, so the modal is open by default:

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
1vi.advanceTimersByTime(1000);

In practice, this should increment the timer, cause the interval function to execute, and close the modal. Then we can use the queryByRole() method to get the Modal and validate whether it is in the document--or not:

view plain print about
1const dialog = queryByRole('dialog');
2await expect.element(dialog).not.toBeInTheDocument();

Finally, reset the test framework to use real timers:

view plain print about
1vi.useRealTimers();

In practice this should work! In reality, it doesn't yet. You'll see two errors along the lines of this:

Error: Matcher did not succeed in time. Caused by: Error: expect(element).not.toBeInTheDocument()

Along with a dump of the current dom, which shows the modal dialog still init:

If you step through the code, you'll see that the final assertion fails, and despite efforts with ticking forward the timer; the close fade animation never executes, and the modal never disappears. Even with a longer timeout, the test will still fail.

Spoiler alert: We solved this in the previous article about using real timers. The same solutions apply here. But I'm going to repeat them just in case you didn't read the previous post.

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 two more things. 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:

view plain print about
1vi.mock('svelte/transition', () => ({
2 fade: () => ({delay: 0, duration: 0})
3}))

We also want to extend the timeout for this test. This is done by adding a property to the it() statement after the test function, sort of like this:

view plain print about
1it('should close modal after clicking "Do Something" with fake Timers', async () => {
2 // all the test code here
3}), 5000)

I added 5000 milliseconds, which gives us plenty of room for the modal to close, although I hate to forcefully add that much time to the test.

Now, re run the tests:

Success!

Final Thoughts

Like the real time tests, it took me quite a few days to discover everything that had to be mocked by this. Even with the help of AI tools, such as Amazon Q and Github Copilot, I had to debug these errors on my own.

Writing these posts were harder than I expected them to be. A few versions of some node library had ticked up since I worked on this at the day job. Out of the box, Svelte tests did not run. I filed a bug against Svelte because tests did not run with a new project setup. I also had issues debugging unit tests through IntelliJ, and filed a bug against IntelliJ. Both have been verified, but as of yet unfixed.

I also, surprisingly, discovered that tests were also sporadic, for example if I ran them in headless mode, things worked better than if I ran them in a headful mode. And the watch mode seemed to have less issues than running once. Weird things I have not experienced in other UI frameworks. That said, I hope these articles help someone. Let me know!