DEV Community

jacksonPrimo
jacksonPrimo

Posted on

Como fazer testes de integração com Node.js, Fastify, Vitest e Prisma 2026

Após passar um bom periodo usando apenas Jest, recentemente resolvi dar uma chance para o Vitest e achei a ferramenta simplesmente sensacional, super simples e intuitiva para configurar.

Comecei a estudar utilizando ferramentas de IA para construção de alguns casos de teste mas não gostei muito do resultado, devido a configuração ser muito simples e haver muitos elementos mockados para testes que deveriam ser de integração e não unitários.

Resolvi então fazer minha estrutura seguindo algumas convenções e práticas que aprendi no mundo do Ruby On Rails como uso de Factories para criação de registros reais no banco, subir todas dependências via container antes de executar os casos de teste como PostgreSql e Redis. Com isto eu pude testar não somente os fluxos diferentes que o código seguia baseado nos parâmetros enviados como também se eles estavam sendo persistidos corretamente no banco.

Agora que estão contextualizados vamos começar.

Base do projeto

Primeiro vamos construir a base do projeto, começando pela configuração do typescript e fastify.

$ npm init -y
$ npm install fastify zod fastify-type-provider-zod
$ npm install -D typescript tsx @types/node
$ npx tsc init
Enter fullscreen mode Exit fullscreen mode

crie o arquivo de servidor:

// /src/server.ts
import fastify from "fastify";
import {
    hasZodFastifySchemaValidationErrors,
    serializerCompiler,
    validatorCompiler,
    type ZodTypeProvider,
} from "fastify-type-provider-zod";
import { env } from "./env";

const app = fastify().withTypeProvider<ZodTypeProvider>();

app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);

app.get("/", async (request) => {
    return "server is working"
});

export { app };

if (process.env.NODE_ENV !== 'test') {
    app
        .listen({ port: env.PORT, host: '0.0.0.0' })
        .then(() => {
            console.log(`server running on port ${env.PORT}`);
        });
}

Enter fullscreen mode Exit fullscreen mode

Adicione um arquivo para reúnir e tipar corretamente suas envs com o zod:

// /src/env.ts

import z from "zod";
const envSchema = z.object({
    DATABASE_URL: z.url(),
    JWT_SECRET: z.string(),
    PORT: z.coerce.number().default(3001),
});

export const env = envSchema.parse(process.env);
Enter fullscreen mode Exit fullscreen mode

Configurando prisma

Agora vamos fazer a configuração do prisma com PostgreSQL, apenas para armazenarmos nossa tabela de usuários.

npm install prisma @types/pg --save-dev
npm install @prisma/client @prisma/adapter-pg pg dotenv

npx prisma
Enter fullscreen mode Exit fullscreen mode

Será gerado um arquivo de configuração do prisma e a pasta de configuração do schema na raiz do projeto:

// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});
Enter fullscreen mode Exit fullscreen mode
// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}
datasource db {
  provider = "postgresql"
}
Enter fullscreen mode Exit fullscreen mode

Adicione um arquivo de comunicação para o prisma

// src/lib/prisma.ts
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";
import { env } from "../env";

const connectionString = `${env.DATABASE_URL}`;

const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });

export { prisma };
Enter fullscreen mode Exit fullscreen mode

Essa parte do setup do prisma é sempre bom seguir a documentação oficial pois de tempos em tempos algumas configurações mudam.

Agora vamos modificar nosso schema e em seguida rodar a migration para criarmos nossa tabela de usuário:

// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

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

model User { 
  id                String      @id @default(uuid()) 
  email             String      @unique
  name              String
  passwordHash      String
  createdAt         DateTime    @default(now())
  updatedAt         DateTime    @updatedAt
}
Enter fullscreen mode Exit fullscreen mode

Agora vamos gerar uma migration:

$ npx prisma migrate dev --name createUser
Enter fullscreen mode Exit fullscreen mode

Aqui é importante que seu banco esteja disponível, então você pode subir um container com o PostgreSql para testes:

$ docker run --name pg-test-db -e POSTGRES_PASSWORD=mypassword -e POSTGRES_USER=myuser -p 5432:5432 -d postgres
Enter fullscreen mode Exit fullscreen mode

Criando serviço de autenticação

Agora iremos criar um serviço simples de registro de usuário e login com jwt:

$ npm install jsonwebtoken bcryptjs
Enter fullscreen mode Exit fullscreen mode
// src/helpers/authHelper.ts
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { env } from "../env";

export async function generatePasswordHash(password: string) {
  return await bcrypt.hash(password, 10);
}

export function generateAuthToken(userId: string) {
  return jwt.sign({ userId }, env.JWT_SECRET, { expiresIn: "7d" });
}

export function verifyAuthToken(token: string) {
  try {
    return jwt.verify(token, env.JWT_SECRET);
  } catch(_) {}
}

export async function comparePasswordHash(password: string, hash: string) {
  return await bcrypt.compare(password, hash);
}
Enter fullscreen mode Exit fullscreen mode
// src/services/auth/registerUserService.ts
import { RegisterUserDto } from "../../dtos/RegisterUserDto";
import { CustomError } from "../../errors/customError";
import { generateAuthToken, generatePasswordHash } from "../../helpers/authHelper";
import { prisma } from "../../lib/prisma";

export async function registerUser(params: RegisterUserDto) {
  await verifyIfEmailAlreadyInUse(params.email)
  const passwordHash = await generatePasswordHash(params.password);
  const user = await prisma.user.create({
    data: {
      name: params.name,
      email: params.email,
      passwordHash: passwordHash
    },
  });
  return { accessToken: generateAuthToken(user.id) }
}

async function verifyIfEmailAlreadyInUse(email: string) {
  const userFound = await prisma.user.findFirst({ where: { email } });
  if (userFound) throw new CustomError("Este e-mail já está em uso", 400)
}
Enter fullscreen mode Exit fullscreen mode
// src/dtos/RegisterUserDto.ts
import z from "zod";

export const RegisterUserValidation = z.object({
  name: z.string().nonempty(),
  email: z.email().nonempty(),
  password: z.string().nonempty().min(9)
})

export type RegisterUserDto = z.infer<typeof RegisterUserValidation>;
Enter fullscreen mode Exit fullscreen mode
// src/services/auth/signinService.ts
import { SigninDto } from "../../dtos/SigninDto";
import { CustomError } from "../../errors/customError";
import { generateAuthToken, comparePasswordHash } from "../../helpers/authHelper";
import { prisma } from "../../lib/prisma";

export async function signin(params: SigninDto) {
  const notFoundMessage = "E-mail ou senha inválidos"
  const userFound = await prisma.user.findFirst({ where: { email: params.email } });
  if (!userFound) throw new CustomError(notFoundMessage, 404)

  const isPasswordValid = await comparePasswordHash(params.password, userFound.passwordHash)
  if (!isPasswordValid)
    throw new CustomError(notFoundMessage, 404)

  return {
    accessToken: generateAuthToken(userFound.id)
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/dtos/SigninDto.ts
import z from "zod";

export const SigninValidation = z.object({
  email: z.email().nonempty(),
  password: z.string().nonempty()
})

export type SigninDto = z.infer<typeof SigninValidation>;
Enter fullscreen mode Exit fullscreen mode
// src/server.ts
import { RegisterUserValidation } from "./dtos/RegisterUserDto";
import { registerUser } from "./services/auth/registerUserService";
import { SigninValidation } from '../dtos/SigninDto';
import { signin } from '../services/auth/signinService';

app.post(
    '/auth/register', 
    { schema: { body: RegisterUserValidation } }, 
    async (request, reply) => {
        const response = await registerUser(request.body)
        reply.code(201).send(response)
    }
)

app.post(
    '/auth/login',
    { schema: { body: SigninValidation } },
    async (request, reply) => {
        const response = await signin(request.body)
        reply.code(201).send(response)
    }
)
Enter fullscreen mode Exit fullscreen mode

Agora algo que sempre gosto de adicionar nas minhas APIs são middlewares para tratamento de erros e uma classe de erro customizada, vamos implementar isto:

// src/errors/customError.ts
export class CustomError extends Error {
  constructor(message: string, private code=500, private description="") {
    super(message)
  }

  public getCode() {
    return this.code
  }

  public getDescription() {
    return this.description
  }
}
Enter fullscreen mode Exit fullscreen mode
// src/server.ts
app.setErrorHandler((error, request, reply) => {
    if (hasZodFastifySchemaValidationErrors(error)) {
        return reply.status(400).send({
            statusCode: 400,
            error: "invalid params",
            description: error.message
        });
    }
    else if (error instanceof CustomError) {
        return reply.status(error.getCode()).send({
            statusCode: error.getCode(),
            error: error.message,
            description: error.getDescription()
        });
    } else {
        return reply.status(500).send({
            statusCode: 500,
            error: "internal server error",
            description: error
        });
    }
});
Enter fullscreen mode Exit fullscreen mode

"hasZodFastifySchemaValidationErrors(error)" é a forma de identificarmos e tratarmos erros de validação vindos do zod.
"error instanceof CustomError" seria para tratarmos erros comuns como e-mail já em uso, senha incorreta etc.
Caso não seja nenhum dos outros 2 casos, então consideramos como um erro de servidor, se fosse o caso de uma aplicação maior nesse escopo estariamos adicionando alguma ferramenta de monitoramento como o Sentry, ou adicionar um disparo de aviso via e-mail.

Setup para os testes

Enfim, agora que temos todo nosso projetinho construido, vamos implementar os testes, mas antes temos que fazer o setup para executar eles.

A ideia aqui é termos um container para cada dependência da aplicação como banco de dados, cache, filas etc. Podemos fazer isto de duas maneiras, uma seria adicionar tudo em um arquivo docker-compose.yml e subir antes de executar os testes, poderiamos rodar inclusive o proprio teste dentro de um container OU podemos usar uma ferramenta especializada em criar containers para testes chamada testcontainer.

O testcontainer existe para diferentes linguagens, meu primeiro contato com ela foi no Ruby On Rails por exemplo, ela oferece uma variedade bem grande de containers para serem trabalhados como MySql, PostgreSql, Redis, RabbitMq etc. No nosso caso como a única dependência é o postgres, vamos instalar apenas ela:

$ npm install @testcontainers/postgresql --save-dev
Enter fullscreen mode Exit fullscreen mode

Nossa estrela da vez para a realização dos testes:

$ npm install -D vitest
Enter fullscreen mode Exit fullscreen mode

Na raiz do projeto crie um arquivo de configuração para o vitest

// vitest.config.mts
import { defineConfig } from 'vitest/config'

export default defineConfig({
    test: {
        globalSetup: './tests/setup/global.ts',
        setupFiles: ['./tests/setup/reset-db.ts'],
        environment: 'node',
        globals: true,
        maxWorkers: 1
    },
})
Enter fullscreen mode Exit fullscreen mode

Aqui tem 3 importantes campos de configuração:

  • globalSetup: irá rodar uma única vez antes de todos os testes, ele será responsável no nosso caso por subir os containers.
  • setupFiles: este executa antes de cada caso de teste, vai servir para resetarmos o banco. Isso garante que registros de um teste não entrem em conflito com os de outro, tornando eles isolados e independentes.
  • maxWorkers: seria para definirmos o nível de paralelismo da execução dos testes, no nosso caso como vamos resetar o banco a cada teste, é importante que não haja mais de 1 workers, do contrário um teste pode resetar o banco enquanto outro está executando. Agora vamos implementar os arquivos de configuração:
// tests/setup/global.ts
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { execSync } from 'node:child_process';

let container: any;

export async function setup() {
  console.log("Subindo o PostgreSQL Testcontainer...");
  const postgreContainer = await new PostgreSqlContainer("postgres:16-alpine")
    .withDatabase("dummy")
    .withUsername("dummy")
    .withPassword("dummy")
    .start();

  const uri = postgreContainer.getConnectionUri();

  console.log("Adicionando envs...")
  process.env.DATABASE_URL = uri;
  process.env.NODE_ENV = 'test';
  process.env.JWT_SECRET = 'secret';


  console.log("Rodando as migrations...")
  execSync('npx prisma migrate deploy', { stdio: 'inherit', env: { ...process.env, DATABASE_URL: uri } });
}

export async function teardown() {
  if (container) {
    console.log("Derrubando o Testcontainer...");
    await container.stop();
  }
}
Enter fullscreen mode Exit fullscreen mode

Note que é aqui que:

  1. subimos o container do nosso banco
  2. setamos nossas envs
  3. rodamos as migrations do banco

Antes de finalizar os testes precisamos derrubar estes containers, portanto a instancia do container fica em uma variável de escopo global no arquivo para ser encerrada na função teardown.

// tests/setup/reset-db.ts
import { beforeEach, afterAll } from 'vitest';
import { prisma } from '../../src/lib/prisma';

beforeEach(async () => {
    await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Click", "Link", "User" CASCADE;`);
});

afterAll(async () => {
    await prisma.$disconnect();
});
Enter fullscreen mode Exit fullscreen mode

Aqui estamos usando a operação truncate table para resetarmos os registros de forma mais segura.

Agora que temos o setup, podemos criar nossos testes:

// tests/integration/auth.spec.ts
import { describe, it, expect } from 'vitest';
import { prisma } from '../../src/lib/prisma';
import { app } from '../../src/server';
import { simpleUser } from '../factories/userFactory';

describe('User Registration Route', () => {
  it('should successfully register a new user', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/auth/register',
      payload: {
        name: 'Jackson Aquino',
        email: 'jackson@test.com',
        password: 'securepassword123'
      }
    });

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

    const body = JSON.parse(response.body);
    expect(body).toHaveProperty('accessToken');
    expect(typeof body.accessToken).toBe('string');

    const count = await prisma.user.count({ where: { name: 'Jackson Aquino', email: 'jackson@test.com'} });
    expect(count).toBe(1);
  });

  it('should return 400 when email is already in use', async () => {
    const userAlreadyRegistered = await simpleUser()

    const response = await app.inject({
      method: 'POST',
      url: '/auth/register',
      payload: {
        name: 'Jackson Aquino',
        email: userAlreadyRegistered.email,
        password: 'securepassword123'
      }
    });

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

    const body = JSON.parse(response.body);
    expect(body.error).toBe('Este e-mail já está em uso');
  });

  it('should return 400 when body validation fails (missing name)', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/auth/register',
      payload: {
        email: 'jackson@test.com',
        password: 'securepassword123'
      }
    });

    expect(response.statusCode).toBe(400);
    const body = JSON.parse(response.body);
    expect(body.error).toBe('invalid params');
    expect(body.description).toBe('body/name Invalid input: expected string, received undefined');
  });

  it('should return 400 when body validation fails (pasword length less than 9)', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/auth/register',
      payload: {
        name: 'jackson Aquino',
        email: 'jackson@test.com',
        password: '12345678'
      }
    });

    expect(response.statusCode).toBe(400);
    const body = JSON.parse(response.body);
    expect(body.error).toBe('invalid params');
    expect(body.description).toBe('body/password Too small: expected string to have >=9 characters');
  });
});

describe('User Login Route', () => {
  it('should successfully login when credentials are valid', async () => {
    const user = await simpleUser()
    const response = await app.inject({
      method: 'POST',
      url: '/auth/login',
      payload: {
        email: user.email,
        password: '123456789'
      }
    });

    expect(response.statusCode).toBe(201);
    const body = JSON.parse(response.body);
    expect(body).toHaveProperty('accessToken');
  });

  it('should return 404 when email does not exist', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/auth/login',
      payload: {
        email: 'unknown@test.com',
        password: 'securepassword123'
      }
    });

    expect(response.statusCode).toBe(404);
    const body = JSON.parse(response.body);
    expect(body.error).toBe('E-mail ou senha inválidos');
  });

  it('should return 404 when password is wrong', async () => {
    const user = await simpleUser()
    const response = await app.inject({
      method: 'POST',
      url: '/auth/login',
      payload: {
        email: user.email,
        password: 'wrongpassword'
      }
    });

    expect(response.statusCode).toBe(404);
    const body = JSON.parse(response.body);
    expect(body.error).toBe('E-mail ou senha inválidos');
  });
});
Enter fullscreen mode Exit fullscreen mode

Note que existem funções chamadas "simpleUser" vindos de um diretório chamado factory, esse é um conceito que também aprendi do Ruby, nele temos o FactoryBot que cria registros no banco de forma que podemos reaproveitar esses registros em diferentes casos de teste. Então se houvessem testes de listagem de posts, comentários etc. Poderiamos usar o mesmo factory de criação de usuários.

// tests/factories/userFactory.ts
import { User } from "../../src/generated/prisma/client";
import { generatePasswordHash } from "../../src/helpers/authHelper";
import { generateShortCode } from "../../src/helpers/linkHelper";
import { prisma } from "../../src/lib/prisma";

export async function simpleUser(): Promise<User> {
  return await prisma.user.create({
    data: {
      name: "exemplo",
      email: "exemplo@gmail.com",
      passwordHash: await generatePasswordHash("123456789")
    },
  });
}

export async function randomUser(): Promise<User> {
  const randomCode = generateShortCode()
  return await prisma.user.create({
    data: {
      name: `exemplo-${randomCode}`,
      email: `exemplo-${randomCode}@gmail.com`,
      passwordHash: await generatePasswordHash("123456789")
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Outra questão é que dessa forma não precisamos mockar operações que envolvem o banco, todas são chamadas e de fato fazem alterações no banco, o que conhecemos como efeitos colaterais. Ao final de cada teste podemos chamar o prisma e verificar se de fato os dados foram preenchidos corretamente, se obedecem as constraints do banco como e-mails únicos, regras de negócio como senhas terem obrigatoriamente no mínimo 9 digitos etc.

Referências:

Top comments (0)