Mastodon
7 min read

Testing file download in Vitest Browser Mode

This post shows how to use Vitest Browser mode for testing file downloads, with key advantages over Playwright’s approach. You’ll learn to write custom commands to test downloads confidently, with minimal mocking, and hit 100% coverage. Check the demo repo for working code.
Vitest logo left, download icon right, handshake emoji center, post title below, NeverOff Dev logo bottom-right.
An easy & detailed guide on testing file download functionality with Vitest Browser

Until relatively recently, the best way I knew of to test file upload/download actions in web apps was to use Playwright and its suite of tools (snapshots, file comparison, etc). Whilst established and well-supported, it lacks sufficient coverage metrics (especially outside of Chromium browsers) and overall has to rely a lot on what users can see and interact with, which is different from tools like Vitest and Jest, which have access to the internal state & components of your app.

Vitest Browser Mode quickly became my go-to choice for both unit and integration testing. The fact that it combines both in-codebase insight with robust user-like assertions (via Playwright/WebdriverIO) means it gives the ultimate flexibility to assert the actual user behaviour, avoiding the test user and testing implementation details - all the while generating the coverage metrics!

Running directly in the browser (rather than jsdom) is the icing on the cake, giving even more confidence, which is why we're testing to begin with. By the end of this, you’ll know how to get 100% coverage on your file download functions with confidence and little to no mocking.

0:00
/0:14

A video demonstration of the file download tests in action

🎯
If you'd rather jump to working code straight away feel free to navigate to the demo repository linked below.
GitHub - mneveroff/vitest-demo-download-file: Demonstrating approaches to handle file download testing in Vitest Browser
Demonstrating approaches to handle file download testing in Vitest Browser - mneveroff/vitest-demo-download-file

Extending Vitest

Vitest Browser provides a set of wrappers over the test runner's functions, but they often don't come close enough to cover all of the needs nor expose the full functionality of the underlying test runner - and it's reasonable, given that the team behind Vitest needs to both focus on overall tool stability and provide a unified, runner-agnostic experience.

Vitest Browser does have Commands though, which allow you to tap into the test runner's unique implementations, which we'll be using today.

Playwright’s waitForEvent(‘download’)

Playwright has a great, if slightly light on detail, section on file downloads. From there we know that we need to use page.waitForEvent('download') that would return a promise that we can await to actually get the file. Incredible.

Creating a custom Vitest Command

Getting to the actual implementation, you'll need to set up Commands in your environment. We'll store ours under tests/command-download.ts for now.

From the playwright's example we can see that everything happens in a single action:

const downloadPromise = page.waitForEvent('download');
await page.getByText('Download file').click();
const download = await downloadPromise;

This might not be ideal for us when trying to stay within the Vitest Browser for all user actions (i.e. avoiding page.getByText('...').click()).

Let's start with two commands for now:

// command-download.ts
export const listenForFileDownload: BrowserCommand<[]> = async (
  ctx
): Promise<Download> => {
  const page = ctx.page;

  const downloadPromise = page.waitForEvent("download");

  return downloadPromise;
};

export const finishFileDownload: BrowserCommand<[Promise<Download>]> = async (
  _,
  downloadPromise
): Promise<string> => {
  const download = await downloadPromise;
  const filename = download.suggestedFilename();

  // Target directory needs to be within the project root due to server:fs security restrictions, see https://vitest.dev/guide/browser/commands.html
  const targetDirectory = path.resolve(process.cwd(), "./tests/__downloads__/");
  fs.mkdirSync(targetDirectory, { recursive: true });

  const targetPath = path.join(targetDirectory, filename);

  await download.saveAs(targetPath);

  return targetPath;
};

The two functions above would allow us to both set the listener (listenForFileDownload) and then resolve it in a separate command (finishFileDownload).

Configuring Vitest Browser Commands

In order to use these commands, as per Vitest's documentation, we need to:

  • Register them in the test configuration
  • (If you're using Typescript) extend the @vitest/browser/context module

We can achieve that by modifying vite.config.ts and creating vitest.d.ts:

// vitest.d.ts
import { Download } from "playwright-core"; // Needed for Download interface

declare module '@vitest/browser/context' {
    interface BrowserCommands {
      listenForFileDownload: () => Promise<Download>;
      finishFileDownload: (downloadPromise: Promise<Download>) => Promise<string>;
      downloadFile: () => Promise<string>;
      triggerAndDownloadFile: (elementText: string) => Promise<string>;
    }
  }
// vite.config.ts
import { listenForFileDownload, finishFileDownload } from './tests/command-download';

export default defineConfig({
  plugins: [react()],
  test: {
    browser: {
      enabled: true,
      provider: 'playwright',
      ui: false,
      instances: [
        { browser: 'chromium' },
        { browser: 'firefox' },
        { browser: 'webkit' },
      ],
      commands: {
        listenForFileDownload,
        finishFileDownload
      }
    },
  }
})

Creating a Vitest Browser test

Finally, let’s create a simple Vitest test file:

// download-file.test.tsx
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { userEvent, commands, server } from "@vitest/browser/context";
import { screen, render, cleanup } from "@testing-library/react";
import App from "../src/App";
import React from "react";
import "../src/index.css";
import "../src/App.css";

const TIMEOUT = 5_000;
const { readFile, removeFile } = server.commands;

describe("Download file", () => {
  beforeEach(async () => {
    render(
      <div id="root">
        <App />
      </div>,
      { container: document.body }
    );
  });

  afterEach(async () => {
    cleanup();
  });

  it(
    "🟢 should download a file",
    { timeout: TIMEOUT },
    async () => {
      const downloadPromise = commands.listenForFileDownload();

      const downloadButton = screen.getByText("Download immediately");
      expect(downloadButton).toBeVisible();
      await userEvent.click(downloadButton);

      const targetPath = await commands.finishFileDownload(downloadPromise);
      expect(targetPath).toBeDefined();
      expect(targetPath.length).toBeGreaterThan(0);

      const fileContent = await readFile(targetPath);
      expect(fileContent).toBeDefined();
      expect(fileContent.length).toBeGreaterThan(0);
      await removeFile(targetPath);
    }
  );
});

The ideal implementation, allowing us to separate obtaining the listener promise from user interaction and promise resolution.

Putting it all together

I’ll amend the implementation of the download component and the accompanying download-file hook shared in this write-up, you can check them out in the repository accompanying this post. Let's try to run our test now, npx vitest run --browser.name chromium:

Alas, the ideal approach does not work. Apparently, download doesn't have a suggestedFilename function, even though it's clearly within playwright-core!

💡
The below is my best understanding explanation at the time, as I'm not exactly an expert on how Vitest interacts with the test runner and passes the promises from and to it.

This is most likely due to the intricacies of passing through a Promise from the test-runner to Vitest. It's entirely possible that it gets lost during serialisation or another part of the IPC, meaning we're getting back an empty promise.

This means there are two ways we can proceed:

  • Unify the two playwright commands and add a configurable timeout to our download hook so we could be sure that even the quickest downloads don't fire before the download event listener is bound
  • Pass the desired selector over to playwright and let it perform the user action

Adding a timeout to the download function

A naive approach, whereby we extend hook to accept a timeout parameter. This might not be the best practice, but gets the job done:

// download-file.ts
...
  const downloadFile = async ({
    filename = uuidv4(),
    timeout = 1_000,
  } = {}) => {
  ...
// command-download.ts
export const downloadFile: BrowserCommand<[]> = async (
  ctx
): Promise<string> => {
  const downloadPromise = listenForFileDownload(ctx);
  return finishFileDownload(ctx, downloadPromise);
};
// download-file.test.tsx
it(
  "🟢 should download a file with timeout"
  async () => {
    const downloadButton = screen.getByText("Download with timeout");
    expect(downloadButton).toBeVisible();
    await userEvent.click(downloadButton);

    const targetPath = await commands.downloadFile();
    expect(targetPath).toBeDefined();
    expect(targetPath.length).toBeGreaterThan(0);

    const fileContent = await readFile(targetPath);
    expect(fileContent).toBeDefined();
    expect(fileContent.length).toBeGreaterThan(0);
    await removeFile(targetPath);
  }
);

You can see that here we just invoke the new downlodFile command right after the user interaction and have a default timeout of 1,000ms which is is plenty for the test runner listener to bind and execute within itself.

Moving the userEvent into Playwright

This is undoubtedly the more "correct" option of the two. We'll create a new, fourth command and write an updated test for it:

// command-download.ts
export const triggerAndDownloadFile: BrowserCommand<[string]> = async (
  ctx,
  elementText
): Promise<string> => {
  const downloadPromise = listenForFileDownload(ctx);

  const element = ctx.iframe.getByText(elementText);
  await element.click();

  return finishFileDownload(ctx, downloadPromise);
};
// download-file.test.tsx
it(
  "🟢 should download a file without a timeout",
  async () => {
    const targetPath = await commands.triggerAndDownloadFile(
      "Download immediately"
    );
    expect(targetPath).toBeDefined();
    expect(targetPath.length).toBeGreaterThan(0);

    const fileContent = await readFile(targetPath);
    expect(fileContent).toBeDefined();
    expect(fileContent.length).toBeGreaterThan(0);
    await removeFile(targetPath);
  }
);

In this case we're getting through the entire test incredibly quickly and don't introduce potential flakiness to our suite, best of both worlds.

💡
You might notice the command invoking ctx.iframe instead of ctx.page - this is due to how Vitest Browser runs, which is in a page with an iframe. See this section of the docs.

Final test suite

Now that we have attempted three different ways to handle the download testing, let's flesh out the test case by expecting the first implementation to fail via it.fails and adding the assertions for our other two methods to hit 100% coverage. You can find the full test file code here, but the top-level view is:

// download-file.test.tsx

describe("Download file", () => {
  beforeEach(...);
  afterEach(...);

  it.fails("🔴 will not be able to pass Download promise to finishFileDownload", {});

  it("🟢 should download a file with timeout", {});

  it("🟢 should download a file without a timeout", {});

  it("🟢 should display an error message when download fails", {});

  it("🟢 should display a generic error message when download fails with non-Error", {});
});

Hopefully, with everything set up correctly, you'll see the coveted 100% coverage even on your file download hooks!

Image displaying 100% coverage of the download process

You can get the source code and integrate it into your app by taking a look at the repository linked below.

GitHub - mneveroff/vitest-demo-download-file: Demonstrating approaches to handle file download testing in Vitest Browser
Demonstrating approaches to handle file download testing in Vitest Browser - mneveroff/vitest-demo-download-file

It's worth noting that Playwright has the File Upload API which should absolutely be available as a command in a similar fashion. Happy testing!