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
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}`);
});
}
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);
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
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"),
},
});
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
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 };
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
}
Agora vamos gerar uma migration:
$ npx prisma migrate dev --name createUser
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
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
// 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);
}
// 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)
}
// 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>;
// 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)
}
}
// 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>;
// 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)
}
)
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
}
}
// 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
});
}
});
"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
Nossa estrela da vez para a realização dos testes:
$ npm install -D vitest
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
},
})
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();
}
}
Note que é aqui que:
- subimos o container do nosso banco
- setamos nossas envs
- 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();
});
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');
});
});
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")
},
});
}
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)