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:
- Build a Remix app
- Create a new DB, migrate and seed it
- Run the Remix app
- 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");
});
});
});