E2E test Remix with Vitest and Puppeteer

Vitest is a testing framework, similar to Jest, but way faster, built on top of Vite, which uses esbuild.

Puppeteer is a tool to let us use Chrome as a headless browser inside a Node script to interact with a website/web app.

You could have Vitest as a test runner combined with Puppeteer to E2E test a website, for example, one built with Remix.

Let's see how we could write a few testing helpers to let us write tests that:

  1. Build a Remix app
  2. Create a new DB, migrate and seed it
  3. Run the Remix app
  4. Open a headless browser and visit the Remix app

All of that was on each test file to have a clean slate for each test.

The Database

In this example, we will use Prisma for our database with SQLite as the engine.

Because SQLite uses a file in disk, we could create a new file on each test and run the migrations and seed against it.

Let's start by creating a function to get a new database URL (the path to the file).

import { randomUUID } from "node:crypto";

const DATABASE_URL_FORMAT = "file:./test/{{uuid}}.db";

export function generateDatabaseUrl() {
  let uuid = randomUUID();
  return DATABASE_URL_FORMAT.replace("{{uuid}}", uuid);
}

Now, lets' create a function to migrate the database.

import { execa } from "execa";

export function migrateDatabase(url: string) {
  // this will run Prisma CLI to ask it to migrate our DB
  return execa("npx", ["prisma", "migrate", "deploy"], {
    env: {
      NODE_ENV: "test", // we set the NODE_ENV to test
      DATABASE_URL: url, // we set the DATABASE_URL to the one we just created
    },
  });
}

Similarly, we could seed our database.

import { execa } from "execa";

export function seedDatabase(url: string) {
  // this will run Prisma CLI to ask it to seed our DB
  return execa("npx", ["prisma", "db", "seed"], {
    env: { NODE_ENV: "test", DATABASE_URL: url },
  });
}

Finally, we could combine all of this into a single function.

export async function prepareDatabase() {
  let url = generateDatabaseUrl();
  await migrateDatabase(url);
  await seedDatabase(url);
  return url;
}

The Remix App

Here, we'll create all the functions to build and run our Remix app.

Let's start by clearing the build folders.

import { execa } from "execa";

function clearBuild() {
  return Promise.all([
    execa("rm", ["-rf", "server/build"]),
    execa("rm", ["-rf", "public/build"]),
  ]);
}

Now, we can build the app.

import { execa } from "execa";

function buildApp() {
  return execa("npm", ["run", "build"]);
}

And we could combine them into a single function.

async function prepareBuild() {
  await clearBuild();
  await buildApp();
}

Then, we could start a new process with the server.

import { execa } from "execa";
import getPort from "get-port";

export type Process = {
  stop(): Promise<void>;
  port: number;
};

async function startProcess({ databaseUrl }: { databaseUrl: string }) {
  let port = await getPort(); // get a random post

  // start the process with the database URL and generated port
  let server = execa("npm", ["start"], {
    env: {
      CI: "true",
      NODE_ENV: "test",
      PORT: port.toString(),
      BASE_URL: `http://localhost:${port}`,
      DATABASE_URL: databaseUrl,
    },
  });

  // here, we create a new promise, we'll expect for the stdout to receive
  // the message with the PORT our server generates once it starts listening
  return await new Promise<Process>(async (resolve, reject) => {
    server.catch((error) => reject(error));
    if (server.stdout === null) return reject("Failed to start server.");
    server.stdout.on("data", (stream: Buffer) => {
      if (stream.toString().includes(port.toString())) {
        return resolve({
          async stop() {
            if (server.killed) return;
            server.cancel();
          },
          port,
        });
      }
    });
  });
}

Finally, we need to start the application to prepare the database, build, run the process, and open Puppeteer.

import "pptr-testing-library/extend";
import puppeteer from "puppeteer";
import { prepareDatabase } from "test/helpers/db";

export type App = {
  navigate(path: string): Promise<puppeteer.ElementHandle<Element>>;
  stop(): Promise<void>;
  browser: puppeteer.Browser;
  page: puppeteer.Page;
};

async function openBrowser() {
  let browser = await puppeteer.launch();
  let page = await browser.newPage();
  return { browser, page };
}

export async function start(): Promise<App> {
  // prepare the DB and build, get the database URL back
  let [databaseUrl] = await Promise.all([prepareDatabase(), prepareBuild]);

  // then start the process and open the browser
  let [{ port, stop }, { browser, page }] = await Promise.all([
    startProcess({ databaseUrl }),
    openBrowser(),
  ]);

  return {
    browser,
    page,
    // this function, will navigate to the given path using the correct port
    // and it will return the Puppeteer Testing Library's document object
    async navigate(path: string) {
      let url = new URL(path, `http://localhost:${port}/`);
      await page.goto(url.toString());
      return await page.getDocument();
    },
    async stop() {
      await stop();
      await browser.close();
      await clearBuild();
    },
  };
}

Writing the test

With those functions ready, we can now write our E2E test using Vitest.

import { test, expect, describe, beforeAll, afterAll } from "vitest";
import "pptr-testing-library/extend"; // we need this to get TS auto-complete
import { type App, start } from "test/helpers/app";

describe("E2E", () => {
  let app: App;

  // Before all E2E tests in this file, we start the app
  beforeAll(async () => {
    app = await start();
  });

  // And after all E2E tests in this file, we stop the app
  afterAll(async () => {
    await app.stop();
  });

  // In our test, we can use `app.navigate` to navigate to our path
  test("Articles page should render list of articles", async () => {
    let document = await app.navigate("/articles");

    let $h1 = await document.findByRole("heading", {
      name: "Articles",
      level: 1,
    });

    expect(await $h1.getNodeText()).toBe("Articles");
  });
});

Writing more tests than only E2E

With our DB helpers in place, we could do even more. We could also test our route loader and action functions in the same file.

import { test, expect, describe, beforeAll, afterAll } from "vitest";
import { loader, action } from "./articles";
import { PrismaClient } from "@prisma/client";
import { prepareDatabase } from "test/helpers/db";

describe("Integration", () => {
  let db: PrismaClient;

  // Before all integration tests, we'll prepare a new DB copy and create a client
  beforeAll(async () => {
    let url = await prepareDatabase();
    db = new PrismaClient({ datasources: { db: { url } } });
    await db.$connect();
  });

  // After all integration tests, we'll close the DB connection
  afterAll(async () => {
    await db.$disconnect();
  });

  describe("Loader", () => {
    // And here we could call the route loader passing the DB from the context
    test("The loader should have an articles key", async () => {
      let request = new Request("/articles");
      let response = await loader({ request, params: {}, context: { db } });

      let data = await response.json();

      expect(data).toHaveProperty("articles");
      expect(data.articles).toBeInstanceOf(Array);
    });
  });

  describe("Action", () => {
    // And here we could call the route action passing the DB from the context
    test("The action should create a new article", async () => {
      let body = new FormData();
      body.set("title", "article title");
      body.set("content", "article content");

      let request = new Request("/articles", { method: "POST", body });

      let response = await action({ request, params: {}, context: { db } });

      expect(response.headers.get("Location")).toBe("/articles");
    });
  });
});