- Published on
How to share global state between tests in Jest with a Fastify Node.js backend
- Authors
- Name
- Giovanni Orciuolo
- @Wolfrost
This is my first blog article written in English! I apologize in advance for any mistake, English is not my native language.
I've always been an huge fan of testing in codebases. True, it is a time consuming process, and writing solid tests is not an easy task (they can become very brittle and difficult to maintain, but this is a topic for another post) but the gains are so huge they are impossible to ignore. While I don't follow the dogma of TDD verbatim, I do always try my best. This leads straight to my next point: mocks.
Testing mocks is completely useless! :)
I've seen many codebases where, in an attempt to artificially increase code coverage to meet arbitrary metrics imposed by a PM who does not even know the big-O complexity of bubble sort, many many unit tests were written where the sole purpose of the test was to instantiate a mock and test if the mocked function returned the mocked value. USELESS CODE FROM TOP TO BOTTOM!!! What a waste of precious bytes.
Don't get me wrong, mocks are surely useful to setup a scenario where we only test what we really want to test, but they should not be used for everything! At some point, the real code must be invoked. Because that's what we are testing.
Testcontainers lets me avoid mocking the database
Testcontainers is a very beautiful library which leverages Docker and is able to automatically create an entire instance of a given database (or a random Docker image!!!), wait for it to become responsive, and communicate with it entirely through code.
I love not having to mock my database (or worse, use another in-memory database entirely while testing like H2) because in this way I can test REAL errors that come from the real database that I'm also using in production. This would be impossible with a mock repository (or rather, it would be very difficult).
The elephant in the room: performance!
I already know what you are thinking: isn't it terrible for performance having to instantiate and destroy an entire database on each test run? Yes, yes it is very heavy. Which is why we must find a way of instantiating the database container only one time and then clear it after each test is run, to ensure isolation (and thus, parallelism). This implies sharing state between each test, which is generally considered VERY BAD PRACTICE, but in this case I don't think it's the end of the world, since we are dealing with something that is supposed to be shared. Also, nobody writes anything to the state (besides the setup script), so it is read-only.
Using Testcontainers with Jest with shared global state
In my Node.js projects I usually reach for Jest as my go-to testing library. It has been battle tested for many years and never failed me (kinda). I know that there are new and shiny testing libraries out there, and I'm trying some of them, but this guide will focus on Jest. Let's first install Testcontainers and the PostgreSQL adapter for it:
npm install testcontainers @testcontainers/postgresql --save-dev
Then, inside the jest.config.js
file (which I placed at the root of my project):
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig");
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: ["index.ts", "Server.ts", "/node_modules/", "/migrations/"],
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
},
// An array of file extensions your modules use
moduleFileExtensions: ["js", "json", "jsx", "ts", "tsx", "node"],
// The test environment that will be used for testing
testEnvironment: "node",
// The glob patterns Jest uses to detect test files
testMatch: ["**/test/**/?(*.)+(spec|test).[tj]s?(x)"],
// A map from regular expressions to paths to transformers
transform: {
"\\.(ts)$": "ts-jest"
},
// Test timeout
testTimeout: 180000,
// Maps import aliases
modulePaths: ["<rootDir>/src"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
// Global setup and teardown scripts
globalSetup: "<rootDir>/test/globalSetup.ts",
globalTeardown: "<rootDir>/test/globalTeardown.ts",
};
The globalSetup
and globalTeardown
fields are very important, since those are what we are going to use to instantiate both the database and the Fastify test server.
globalSetup.ts
looks like this:
import { buildTestServer } from "./server";
module.exports = async function(globalConfig: any, projectConfig: any) {
(globalThis as any).__FASTIFY__ = await buildTestServer();
};
server.ts
looks like this. Keep in mind that everything is under the test
directory at the root of the project.
import "module-alias/register";
import "reflect-metadata";
import { PrismaClient } from "@prisma/client";
import { seed } from "../prisma/seed";
import { APIServer } from "../src/server";
import { FastifyApplication } from "../types";
export async function setupPostgresContainer() {
const container = await new PostgreSqlContainer("postgres:16-alpine").withDatabase("test").start();
const connectionConfig = {
host: container.getHost(),
port: container.getMappedPort(5432),
database: container.getDatabase(),
user: container.getUsername(),
password: container.getPassword(),
};
const databaseUrl = `postgresql://${connectionConfig.user}:${connectionConfig.password}@${connectionConfig.host}:${connectionConfig.port}/${connectionConfig.database}`;
execSync(`npx prisma migrate deploy`, {
env: {
...process.env,
DATABASE_URL: databaseUrl
}
});
return { connectionConfig, databaseUrl };
}
export async function setupPrismaClient(databaseUrl: string): Promise<PrismaClient> {
const client = new PrismaClient({
datasources: {
db: {
url: databaseUrl,
},
}
});
try {
await client.$connect();
LOGGER.info("Start connection on DB!");
} catch (err: unknown) {
LOGGER.error(`Couldn't start server: ${(err as Error).message} ${(err as Error).stack}`);
process.exit(0);
}
return client;
}
export async function buildTestServer(): Promise<FastifyApplication> {
const { databaseUrl } = await setupPostgresContainer();
const prismaClient = await setupPrismaClient(databaseUrl);
await seed(prismaClient);
(globalThis as any).__PRISMA__ = prismaClient;
const apiServer = new APIServer();
const fastify = apiServer.getFastifyInstance();
await fastify.ready();
return fastify;
}
Since I'm using Prisma as the ORM, I need to also deploy the migrations (and I also omitted some chores that I do to initialize extensions and setup the extended client...).
Regardless, the key insight here is the usage of globalThis
. This is a very special object which Node.js itself exposes and that we can leverage to store whatever we want! In older versions of Node.js, this used to be called global
. You can find more about it here and here.
globalTeardown.ts
looks like this:
module.exports = async function(globalConfig: any, projectConfig: any) {
await (globalThis as any).__FASTIFY__?.close();
};
That's it! Now I can make use of the globalThis
object to reference the global Fastify instance. Also, since globalSetup
is called only once, this means that the PostgreSQL container (along with Prisma migrations setup) is only instantiated once, at the beginning of a test suite run.
Let's see a quick example of usage:
describe("AuthController", () => {
let fastify: FastifyApplication;
beforeEach(async () => {
fastify = (globalThis as any).__FASTIFY__;
await resetTestDB();
});
it("Should login with correct password", async () => {
const res = await fastify.inject({
method: "POST",
url: "/api/auth/login",
payload: {
usernameOrEmail: "test",
password: "test",
}
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
token: expect.any(String),
});
});
});
resetTestDB
is a test utility function I wrote that completely clears a PostgreSQL instance, but does not clear the _prisma_migrations
table, which is used to store migration status. After cleaning it, it reseeds it again:
export async function resetTestDB() {
const prisma = (globalThis as any).__PRISMA__ as PrismaClient;
if (!prisma) {
LOGGER.error("Prisma client is not initialized, can't clean up the database.");
process.exit(0);
}
const tablenames = await prisma.$queryRaw<
Array<{ tablename: string }>
>`SELECT tablename FROM pg_tables WHERE schemaname='public'`
const tables = tablenames
.map(({ tablename }) => tablename)
.filter((name) => name !== "_prisma_migrations")
.map((name) => `"public"."${name}"`)
.join(", ");
try {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables} CASCADE;`);
await seed(prisma);
} catch (error) {
LOGGER.error({ error });
process.exit(0);
}
}
Do not abuse this!
Being a global object, globalThis
is prone to everything you know which is bad about global variables. So use it wisely! And have fun!
OR2