Integration testing Remix apps with Vitest, Typescript & Docker

Photo by Jexo on Unsplash

Integration testing Remix apps with Vitest, Typescript & Docker

This article goes over integration testing with a real database in your Remix apps, this can be ported over to any JS app like Next.js or any server running JS as it contains universal approaches to integration testing.

The tech I will be using is the following:

  • Faker.js - For faking data in our tests

  • Vitest - for running integration tests

  • Docker - for creating our database image

  • Prisma - for type safety, managing our migrations and migrating everything to the integration db. You don't have to use an ORM, the approach will require some changes if you don't but the principle is the same (or you can use drizzle!)

It is important to highlight for which part I'm using what tech so you can replace it with your own if you wish. Now that we have that out of the way let's go over my approach and an awesome trick I use to make my integration testing very fast and easy!

I also use PostgreSQL but you can use whatever else you want, the configuration might change slightly but I've provided links that can help you out.

Project setup

Before we do anything we first need to set up our project of course. I will be using Remix obviously and if you already have a Remix project you can just skip to the next section.

First things first, let's initialize our project, run the following command and go through the questions:

npx create-remix@latest

This will set up a completely new Remix v2 project for you. After this is done you can go into it and install the dependencies if you haven't already by running:

npm install

This will have you set up with everything we need to follow through the rest of this.

Installing everything we need

So after you've up and running you install the following dependencies:

npm i -D vitest vite vite-tsconfig-paths @faker-js/faker

Let's briefly go over what each one does and if you need to use it:

  • vite - required to run vitest

  • vitest - test runner built on top of Vite

  • vite-tsconfig-paths - if you use an alias "~" in your app to do the following: import * from "~/models/user.server" you will need this to resolve these paths in your tests.

  • @faker-js/faker - allows you to create fake random data, eg user emails, passwords, names and so on.

Setting up the database

If you have a database set up already you can skip this part, it only covers setting up Prisma and a PostgreSQL DB in your project

Before we add Vitest environments let's first add Prisma and create our DB. For this setup, you can use Drizzle or whatever you want, but I will be using Prisma with PostgreSQL. I will first initialize prisma via:

$ npx prisma init

After this, I added the following models to the schema file:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int    @id @default(autoincrement())
  email     String @unique
  firstName String @default("")
  lastName  String @default("")
  role      Role   @default(USER)
  posts     Post[]
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  content   String?
  published Boolean @default(false)
  author    User    @relation(fields: [authorId], references: [id])
  authorId  Int
}

enum Role {
  USER
  ADMIN
}

Then I add the Prisma client and Prisma to the dependencies:

$ npm i @prisma/client
$ npm i -D prisma

I changed the .env to point to my development database, this might differ for you, if you want to just test this out locally you can jump to the Docker setup section, set it up, and then run npm run db:up and come back here, otherwise just run:

$ npx prisma migrate dev --name init

This will create a migration with the above models and migrate them into your DB and also the types for the models.

Setting up the vitest config

Let's set up our Vitest config and then go step by step on what we are doing, create a file called vitest.integration.ts and paste the following config into it:

/// <reference types="vitest" />
/// <reference types="vite/client" />

import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
  plugins: [tsconfigPaths()],
  test: {
    // allows you to use stuff like describe, it, vi without importing
    globals: true,
    // disables multi-threading and runs test serially, you can change this
    threads: false, 
    // Path to your setup script that we will go into detail below
    setupFiles: ["./tests/setup.integration.ts"],
    // Up to you, I usually put my integration tests inside of integration
    // folders
    include: ["./app/**/integration/*.test.ts"]
  }
});

So what does this config do for us, well let's go over it.

globals

Allows for global use of test keywords such as "describe", "it", "vi" and so on, this will work BUT typescript will complain at us, so let's fix that first, in your tsconfig.json add the following:

{ 
  "compilerOptions": {
    ...
    "types": ["vitest/globals"], 
  }
}

This will tell typescript to register the global types from Vitest so it won't complain at us when we use globals, if you don't want to do this it is fine, but you will have to import all of these from "Vitest" in your test files!

threads

This either runs tests in serial or parallel fashion, because these tests interact with the database it can cause weird race conditions and incorrect tests so we opt out of parallel execution and execute tests one by one to make sure they are correct.

setupFiles

This allows us to define a series of files to be executed BEFORE the test runs. We can use these to do many useful things like mock globals, mock third-party libraries and set up Vitest context which we will do right now.

Notice how our config points to "./tests/setup.integration.ts"˙ well let's add this file now. First, create the tests directory and add setup.integration.ts into it. After you've done that paste in the following code:

import * as integration from "./factory";

beforeEach(ctx => {
  ctx.request = new Request("http://localhost");
  ctx.integration = integration;
});

Alright, so what does this do exactly? Well, it imports everything from a file called factory.ts and sets it into the context before each of our tests are executed. This means that every test using Vitest will have access to this context.

We also add the request into context so we don't have to create new requests, you can add anything you want here, it is all up to you. What is important is that you understand that this context will be available in all your tests!

Okay, that is out of the way, but what is this factory then? Well, this is where it gets good!

First, create a factory.ts inside your tests directory and paste the following into it:

import { User } from "@prisma/client";
import { createUser } from "~/models/user.server";
import { faker } from "@faker-js/faker";

// creates normal users for us
export const createNormalUser = (user?: Omit<User, "id">) => {
  return createUser({
    email: faker.internet.email().toLowerCase(),
    role: "USER",
    ...user,
  });
};
// creates admin users for us
export const createAdminUser = (user?: Omit<User, "id">) => {
  return createUser({
    email: faker.internet.email().toLowerCase(),
    role: "ADMIN",
    ...user,
  });
};

If you haven't realized it yet, everything that gets exported from this file gets automatically injected into our Vitest context for us to consume in our integration tests, as your project grows you can just keep adding helper utilities into this file that help you create entities that other entities require for you to test them, we will see a concrete example very soon but you can add a whole factory of utilities and re-use them in tests.

You can learn more about this approach here:

https://vitest.dev/guide/test-context.html#extend-test-context

One more thing we need to do for this to work is to actually define the typing for the context, you can either create a custom type and import it everywhere or use global types, I will go with the global approach because I feel it is way simpler. First, on the root, you need to add an index.d.ts file and then inside it paste the following code:

import type * as vitest from "vitest";
import * as integration from "./tests/factory";

declare module "vitest" {
  export interface TestContext {
    request: Request;
    integration: typeof integration;
  }
}

This will extend your Vitest tests to have these two inside of the context. But be careful with this because if you use Vitest for both unit tests and integration tests this will also be defined inside of the unit tests, I like to hide it behind an integration field so that you don't accidentally try to use it inside of your unit tests for this reason.

include

Finally, we have the include, which just tells which files you want Vitest to test, in the example above we test everything that is located in an "integrations" folder and has a ".test" extension, you can configure this to your needs and preferences.

Creating our docker image & scripts

After we are done with the database setup let's set up a simple docker image so we can run our tests. First, we create a docker-compose.yml at the root with the db service like the following:

# Set the version of docker compose to use
version: "3.9"

# The containers that compose the project
services:
  db:
    image: postgres:13
    env_file: .env.test
    restart: always
    container_name: integration-tests
    ports:
      - "5433:5432"
    environment:
      POSTGRES_USER: prisma
      POSTGRES_PASSWORD: prisma
      POSTGRES_DB: tests

This will tell docker to build a Postgres 13 database from the image, use the .env.test for the .env variables and we call the container "integration-tests", we expose the port for our local env to listen to and we set the user and password to Prisma so we can connect to it.

If you are using a different database here is a link to docker-compose docs and files for different databases from Prisma themselves:

https://github.com/prisma/prisma/blob/main/docker/README.md

This allows us to set the following in our .env.test (create this on the root and paste the below line):

# prisma:prisma is our user and password defined in docker compose
# localhost:5433 allows us to connect to our local docker image on port 5433
# tests is the name of our db
# if you change the config in docker compose make sure you change it here too
DATABASE_URL="postgresql://prisma:prisma@localhost:5433/tests"

After this is done let's paste the following scripts inside our package.json:

"db:up": "docker compose up -d db",
"db:down": "docker compose down db"
"pretest:i": "npm run db:up && npx prisma migrate deploy",
"test:i": "vitest run --config ./vitest.integration.ts",
"posttest:i": "npm run db:down",

So let's explain what each script does:

  • db:up -> starts the database in a docker container with the docker image we provided and exposes it locally

  • db:down -> shuts down the docker container we created after the tests are run

  • pretest:i -> first boots up the docker container and then migrates our migrations to the db we booted up

  • test:i -> executes the tests with our custom integration configuration from above

  • posttest:i -> shuts down the database container and removes the db

So the above configuration basically starts our docker container locally, migrates everything into the fresh database, runs the tests and closes the container. If you run

$ npm run db:up

You can open up your Docker desktop app and find the following:

And running the db:down command will close it for you and remove it from here.

Alright, now one last thing to do, test our code!

Testing our code

If you're following along with the tutorial I go over some basic functions and how to test them, if you're just reading to integrate the approach into your codebase you're good to go to test your code!

Now that we have everything set up for us let's finally test our code! I will create an app/models directory and inside it add two files:

  • post.server.ts -> will contain our post logic

  • user.server.ts -> will contain our user logic

Inside user.server.ts paste the following:

import { User } from "@prisma/client";
import { prisma } from "~/db.server";

export const createUser = (user: Pick<User, "email" | "role">) =>
  prisma.user.create({
    data: user,
  });

Inside post.server.ts paste the following:

import { Post } from "@prisma/client";
import { prisma } from "~/db.server";

export const createPost = (
  post: Pick<Post, "title" | "content" | "authorId">
) => {
  return prisma.post.create({
    data: post,
  });
};

Now inside of the app/models we create an integration directory and add post.server.test.ts file inside of it. We paste the following into it:

import { createPost } from "../post.server";

describe("creating posts", () => {
  // integration object is fully typed
  it("creates post with normal user set as the author", async ({ integration }) => {
    // we use it to create a random user
    const user = await integration.createNormalUser();
    // we use the user to test something else
    const result = await createPost({
      title: "hello world",
      content: "this is a test",
      authorId: user.id,
    });
    expect(result.authorId).toBe(user.id);
  });

  // integration object is fully typed
  it("creates the post with admin user set as the author", async ({ integration }) => {
    // we use it to create a random admin user
    const user = await integration.createAdminUser();
    // we use the admin user to test something else
    const result = await createPost({
      title: "hello world",
      content: "this is a test",
      authorId: user.id,
    });
    expect(result.authorId).toBe(user.id);
  });
});

And now we have our first tests using our factory! This is awesome but do they work? Well let's run our integration tests script and find out, so I run npm run test:i and get the following:

Awesome! We ran our tests, they passed and we now have fully tested server-side functions that interact with our database!

Repository

If you want to view the repository you can find it here:

https://github.com/AlemTuzlak/remix-integration-tests

Thank you

If you've reached the end you're a champ! I hope you liked my article.

If you wish to support me follow me on Twitter here:

twitter.com/AlemTuzlak59192

or if you want to follow my work you can do so on GitHub:

github.com/AlemTuzlak

And you can also sign up for the newsletter to get notified whenever I publish something new!