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
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
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();
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'],
},
});
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;
};
}
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'));
};
}
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();
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);
})
Top comments (0)