Pubblicato il

Come condividere stato globale tra i test in Jest con un backend Fastify su Node.js

Autori

Questo è stato il mio primo articolo scritto in inglese! Quella che state leggendo è la versione italiana, quindi stavolta niente scuse in anticipo per gli errori di inglese.

Sono sempre stato un grandissimo fan dei test nelle codebase. È vero, è un processo che porta via tempo, e scrivere test solidi non è affatto semplice (possono diventare molto fragili e difficili da manutenere, ma questo è un argomento per un altro post), però i vantaggi sono così enormi che è impossibile ignorarli. Anche se non seguo il dogma del TDD alla lettera, ci provo sempre al massimo. Il che mi porta dritto al punto successivo: i mock.

Testare i mock è completamente inutile! :)

Ho visto tantissime codebase in cui, nel tentativo di gonfiare artificialmente la code coverage per rispettare metriche arbitrarie imposte da un PM che non conosce neanche la complessità O-grande del bubble sort, venivano scritti tanti tanti unit test il cui unico scopo era istanziare un mock e verificare che la funzione mockata ritornasse il valore mockato. CODICE INUTILE DALLA PRIMA ALL'ULTIMA RIGA!!! Che spreco di byte preziosi.

Non fraintendetemi, i mock sono sicuramente utili per preparare uno scenario in cui testiamo solo quello che vogliamo davvero testare, ma non vanno usati per tutto! A un certo punto il codice vero deve essere invocato. Perché è quello che stiamo testando.

Testcontainers mi permette di non mockare il database

Testcontainers è una libreria bellissima che sfrutta Docker ed è in grado di creare in automatico un'intera istanza di un database a scelta (o di un'immagine Docker qualsiasi!!!), aspettare che diventi responsiva, e comunicarci interamente da codice.

Adoro non dover mockare il database (o peggio, usare un altro database in-memory solo per i test, tipo H2) perché in questo modo posso testare gli errori VERI che arrivano dal database vero, lo stesso che uso anche in produzione. Con un repository mockato questo sarebbe impossibile (o meglio, sarebbe molto difficile).

L'elefante nella stanza: le performance!

So già cosa state pensando: non è terribile per le performance dover istanziare e distruggere un intero database a ogni esecuzione dei test? Sì, sì che è pesantissimo. Per questo dobbiamo trovare un modo per istanziare il container del database una volta sola e poi svuotarlo dopo ogni test, così da garantire l'isolamento (e quindi il parallelismo). Questo implica condividere stato tra i vari test, cosa che in genere è considerata una PESSIMA PRATICA, ma in questo caso non credo sia la fine del mondo, dato che abbiamo a che fare con qualcosa che è fatto apposta per essere condiviso. Inoltre nessuno scrive niente nello stato (a parte lo script di setup), quindi è in sola lettura.

Usare Testcontainers con Jest e stato globale condiviso

Nei miei progetti Node.js di solito uso Jest come libreria di testing di riferimento. È rodato da tanti anni e non mi ha mai tradito (più o meno). So che in giro ci sono librerie di testing nuove e luccicanti, e ne sto provando qualcuna, ma questa guida si concentra su Jest. Per prima cosa installiamo Testcontainers e il suo adapter per PostgreSQL:

npm install testcontainers @testcontainers/postgresql --save-dev

Poi, dentro il file jest.config.js (che ho messo nella root del progetto):

// 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",
};

I campi globalSetup e globalTeardown sono importantissimi, perché sono proprio quelli che useremo per istanziare sia il database che il server Fastify di test.

globalSetup.ts è fatto così:

import { buildTestServer } from "./server";

module.exports = async function(globalConfig: any, projectConfig: any) {
    (globalThis as any).__FASTIFY__ = await buildTestServer();
};

server.ts invece è fatto così. Tenete presente che è tutto sotto la cartella test nella root del progetto.

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;
}

Dato che uso Prisma come ORM, devo anche fare il deploy delle migration (e ho anche omesso alcune faccende che faccio per inizializzare le estensioni e preparare il client esteso...). In ogni caso, il punto chiave qui è l'uso di globalThis. Si tratta di un oggetto molto speciale esposto da Node.js stesso, in cui possiamo infilare tutto quello che vogliamo! Nelle versioni più vecchie di Node.js si chiamava global. Potete approfondire qui e qui.

globalTeardown.ts è fatto così:

module.exports = async function(globalConfig: any, projectConfig: any) {
    await (globalThis as any).__FASTIFY__?.close();
};

Fatto! Adesso posso usare l'oggetto globalThis per referenziare l'istanza globale di Fastify. Inoltre, dato che globalSetup viene chiamato una volta sola, il container PostgreSQL (assieme al setup delle migration di Prisma) viene istanziato una volta sola, all'inizio dell'esecuzione della test suite.

Vediamo un rapido esempio di utilizzo:

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 è una funzione di utility per i test che ho scritto io: svuota completamente un'istanza PostgreSQL, ma non tocca la tabella _prisma_migrations, che serve a memorizzare lo stato delle migration. Dopo averla svuotata, rifà il seed:

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);
    }
}

Non abusatene!

Essendo un oggetto globale, globalThis si porta dietro tutto quello che sapete già di brutto sulle variabili globali. Quindi usatelo con giudizio! E divertitevi!

OR2