DEV Community

John Teague
John Teague

Posted on

Using TestContainers with Vitest

Testing frameworks and utilities have progressed greatly over the past few years. TestContainers make creating isolated, lightweight dependencies to test an application's complete functionality easy and quick and reducing the need to mock large parts of your system.

Vitest is a super-fast testing framework that is a drop-in replacement for Jest. It achieves speed improvements mostly by parallelizing test suites. However, if you are testing APIs with TestContainers, that parallelism can make things tricky.

Test Setup the old way

When using older frameworks that run your test serially, like Mocha or Jest, you usually have a setup something like:

beforeAll(async () => {
  // setup database

})

beforeEach(async () => {
  // drop all tables
});
 // run your tests
Enter fullscreen mode Exit fullscreen mode

This doesn't work out very well with Vitest. Since test suites are parallelized, one test suite might finish while another test is still running and drop all your tables when you don't expect it.

TestContainers with Vitest

You need to do some things to set TestContainers up to run successfully with Vitest. In this example, we're creating a Fastly server using Postgres as a database.

Let's use a simple User type to start with:

export interface User {
  firstName: string,
  lastName: string,
  age: number
Enter fullscreen mode Exit fullscreen mode

I use efate to generate test fixtures.

import {defineFixture} from 'efate';
export UserFixure = defineFixture<User>(t => {
  t.firstName.asString();
  t.lastName.asString();
  t.age.asNumber();
Enter fullscreen mode Exit fullscreen mode

Database initialization

You need to initialize the database test container before your tests start. This is the most expensive part of the initialization process, so you should only do it once when you start your test run. We will define a global-setup.ts file and register it with the vite.confit.ts file.

You must also provide a test setup and teardown for each test suite, which we will define in a test-setup.ts file.


//vite.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    globals: true,

    globalSetup: './src/test-config/global-setup.ts',
    setupFiles: ['./src/test-config/test-setup.ts'],
  },
});

Enter fullscreen mode Exit fullscreen mode

We're also going to extend the Vitest ProvidedContext type with our database information.

//global-setup.ts
declare module 'vitest' {
  export interface ProvidedContext {
    pgConfig: {
      host: string;
      port: string;
      username: string;
      password: string;
      database: string | undefined;
    };
}
Enter fullscreen mode Exit fullscreen mode

Then, we will spin up the test container and clean the docker containers after the tests.

We will set up environment variables for the app and also pass information from the setup to the test context.

// global-setup.ts
// runs at the start of the test run only once

export default async function setup({ provide }) {
  console.log('setup: setting up postgres container');
  const pgContainer = await new PostgreSqlContainer().start();
  process.env.DB_URL = pgContainer.getConnectionUri());
  process.env.DB_HOST = pgContainer.getHost();
  process.env.DB_PORT = pgContainer.getPort().toString();
  process.env.DB_USER = pgContainer.getUsername();
  process.env.DB_PASS = pgContainer.getPassword();

  // use the provide function to make this data available to our test suites
  provide('pgConfig', {
    host: 'localhost',
    port: pgContainer.getPort(),
    username: pgContainer.getUsername(),
    password: pgContainer.getPassword(),
  });

  // return the teardown function to clean up
  return function teardown() {
    console.log('*** teardown -- stopping postgres container');
    pgContainer.stop().then(() => console.log('*** teardown -- container stopped'));
  };
}
Enter fullscreen mode Exit fullscreen mode

Passing Data from Global setup to Tests

The global-setup file runs in a different thread context than the tests, so passing data to the test context is not straightforward. Vitest provides two functions to make this possible:

  • provide is used in global setup to make data available to your tests
  • inject is used to retrieve information from the global thread.

It's passing through thread contexts, so the data must be simple data structures that can be serialized.

Test Setup

Now that we have our Postgres server running in the container, we need to set up our tests so that they can run independently and in isolation. We will create a new database for every test suite to do this.

extend the Vitest context objects
interface ExtendedSuite extends Suite {
  dbName?: string;
}

interface ExtendedFile extends File {
  dbName?: string;
}

// test-setup.ts
beforeAll(async (context: ExtendedSuite | ExtendedFile) => {
  // creating a random database name for each test suite
  const dbName = `test_${new Xid().toString()}`;
  process.env.DB_DATABASE_NAME = dbName;

  context.dbName = dbName;
  // get the database config information from global setup
  const dbConfig = inject('pgConfig');
  const sql = postgres({
    host: dbConfig.host,
    port: dbConfig.port,
    username: dbConfig.username,
    password: dbConfig.password,
    database: dbName,
  })
  await sql`CREATE DATABASE ${sql(dbName)}`.simple();
  // run all database migrations for your new database
  await runMigrations(dbName, dbConfig);
});

afterAll(async (context: ExtendedSuite | ExtendedFile) => {
  const dbConfig = inject('pgConfig');
  const sql = postgres({
    host: dbConfig.host,
    port: dbConfig.port,
    username: dbConfig.username,
    password: dbConfig.password,
    database: dbName,
  })

await sql`DROP DATABASE ${sql(context.dbName as string)}`.simple();

Enter fullscreen mode Exit fullscreen mode

Each test suite now has a separate database so that they can run independently. You no longer have to worry about one test suite clobbering the state of another.

Your Fastify app will use the environment variables set, so when API calls are made, it will use the configuration provided. You can also directly access the database configuration information directly from your tests.

describe('Get User', () => {
  let app: FastifyInstance;
  let sql: Sql;
  let userId: string;
  beforeAll(async context => {
    const dbConfig = inject('pgConfig');
    dbConfig.database = process.env.DB_DATABASE_NAME;
    sql = postgres({ ...dbConfig, port: Number(dbConfig.port), transform: postgres.camel });
  const [userInsert] = await sql`INSERT INTO users ${sql(UserFixture.create(), 'name', 'urlPrefix')} RETURNING *`;
    user = serviceInsert.id;
    app = await buildApp(Fastify());
});
it('should return the user', async () => {
  const response = await app.inject({
  method: 'GET',
  URL: `user/${userId}`,
});

expect(response.statusCode).toBe(200);

})
Enter fullscreen mode Exit fullscreen mode

Top comments (0)