Backend
Vitest
Framework de testes moderno e rápido com suporte nativo ao Vite e TypeScript
O que é Vitest?
Vitest é um framework de testes unitários ultrarrápido alimentado pelo Vite. Oferece uma API compatível com Jest, mas com melhor performance, suporte nativo ao ESM, TypeScript e Hot Module Replacement (HMR) para testes.
Por que utilizamos Vitest na IngenioLab?
- Performance extrema: Até 10x mais rápido que Jest
- TypeScript nativo: Zero configuração para TypeScript
- ESM first: Suporte completo a ES Modules
- HMR para testes: Re-execução instantânea durante desenvolvimento
- API familiar: Compatível com Jest/Jasmine
- Vite integration: Compartilha configuração com Vite
- Coverage nativo: Relatórios de cobertura integrados
Instalação
# Vitest corenpm install -D vitest# Utilitários de teste adicionaisnpm install -D @vitest/ui @vitest/coverage-v8npm install -D happy-dom jsdom # Para testes de DOM# Para testes de API/HTTPnpm install -D supertest @types/supertest
Configuração IngenioLab
1. vitest.config.ts:
// vitest.config.tsimport { defineConfig } from 'vitest/config'import path from 'node:path'export default defineConfig({test: {// Ambiente globalglobals: true,environment: 'node', // ou 'jsdom' para testes frontend// Configuração de arquivosinclude: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],exclude: ['**/node_modules/**','**/dist/**','**/cypress/**','**/.{idea,git,cache,output,temp}/**'],// Setup filessetupFiles: ['./tests/setup.ts'],// Coveragecoverage: {provider: 'v8',reporter: ['text', 'json', 'html'],exclude: ['coverage/**','dist/**','packages/*/test{,s}/**','**/*.d.ts','cypress/**','test{,s}/**','test{,-*}.{js,cjs,mjs,ts,tsx,jsx}','**/*{.,-}test.{js,cjs,mjs,ts,tsx,jsx}','**/__tests__/**','**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc}.config.*','**/.{eslint,mocha}rc.{js,cjs}'],thresholds: {global: {branches: 80,functions: 80,lines: 80,statements: 80}}},// Reportersreporter: ['verbose', 'json', 'html'],outputFile: {json: './coverage/test-results.json',html: './coverage/index.html'}},// Resolução de importsresolve: {alias: {'@': path.resolve(__dirname, './src'),'@/tests': path.resolve(__dirname, './tests')}}})
2. Setup file para configurações globais:
// tests/setup.tsimport { vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'import { PrismaClient } from '@prisma/client'// Mock do Prisma Clientvi.mock('@prisma/client', () => ({PrismaClient: vi.fn()}))// Mock de variáveis de ambientebeforeAll(() => {process.env.NODE_ENV = 'test'process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'process.env.JWT_SECRET = 'test-secret-key'})// Limpar mocks entre testesafterEach(() => {vi.clearAllMocks()})// Global test utilitiesglobal.console = {...console,log: vi.fn(), // Mock console.log em testes se necessárioerror: console.error,warn: console.warn,info: vi.fn()}
3. Scripts no package.json:
{"scripts": {"test": "vitest","test:run": "vitest run","test:ui": "vitest --ui","test:coverage": "vitest run --coverage","test:watch": "vitest --watch","test:related": "vitest related"}}
Estrutura de Testes IngenioLab
src/├── services/│ ├── userService.ts│ └── __tests__/│ └── userService.test.ts├── routes/│ ├── users.ts│ └── __tests__/│ └── users.test.ts├── utils/│ ├── validation.ts│ └── __tests__/│ └── validation.test.ts└── __tests__/├── integration/│ └── api.test.ts└── mocks/├── prisma.ts└── fastify.ts
Padrões de Teste
1. Testes Unitários (Services):
// src/services/__tests__/userService.test.tsimport { describe, it, expect, vi, beforeEach } from 'vitest'import { userService } from '../userService'import { prisma } from '../../lib/prisma'// Mock do Prismavi.mock('../../lib/prisma', () => ({prisma: {user: {create: vi.fn(),findUnique: vi.fn(),findMany: vi.fn(),update: vi.fn(),delete: vi.fn()}}}))describe('UserService', () => {beforeEach(() => {vi.clearAllMocks()})describe('createUser', () => {it('should create user successfully', async () => {// Arrangeconst userData = {name: 'João Silva',email: 'joao@exemplo.com',password: 'senha123'}const expectedUser = {id: '123',...userData,createdAt: new Date()}vi.mocked(prisma.user.create).mockResolvedValue(expectedUser)// Actconst result = await userService.createUser(userData)// Assertexpect(prisma.user.create).toHaveBeenCalledWith({data: userData,select: expect.any(Object)})expect(result).toEqual(expectedUser)})it('should throw error for duplicate email', async () => {// Arrangeconst userData = {name: 'João Silva',email: 'joao@exemplo.com',password: 'senha123'}const duplicateError = new Error('Unique constraint failed')duplicateError.code = 'P2002'vi.mocked(prisma.user.create).mockRejectedValue(duplicateError)// Act & Assertawait expect(userService.createUser(userData)).rejects.toThrow('Email já está em uso')})})describe('getUserById', () => {it('should return user when found', async () => {// Arrangeconst userId = '123'const expectedUser = {id: userId,name: 'João Silva',email: 'joao@exemplo.com'}vi.mocked(prisma.user.findUnique).mockResolvedValue(expectedUser)// Actconst result = await userService.getUserById(userId)// Assertexpect(prisma.user.findUnique).toHaveBeenCalledWith({where: { id: userId },include: expect.any(Object)})expect(result).toEqual(expectedUser)})it('should throw error when user not found', async () => {// Arrangeconst userId = '999'vi.mocked(prisma.user.findUnique).mockResolvedValue(null)// Act & Assertawait expect(userService.getUserById(userId)).rejects.toThrow('Usuário não encontrado')})})})
2. Testes de Integração (API Routes):
// src/routes/__tests__/users.test.tsimport { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'import { build } from '../../app'import { FastifyInstance } from 'fastify'import { prisma } from '../../lib/prisma'import jwt from 'jsonwebtoken'describe('Users API', () => {let app: FastifyInstancelet authToken: stringbeforeAll(async () => {app = await build({ logger: false })// Criar token de testeauthToken = jwt.sign({ id: '123', role: 'ADMIN' },process.env.JWT_SECRET!)})afterAll(async () => {await app.close()})beforeEach(async () => {// Limpar dados de testeawait prisma.user.deleteMany({where: { email: { contains: 'test' } }})})describe('POST /users', () => {it('should create user successfully', async () => {// Arrangeconst userData = {name: 'João Test',email: 'joao.test@exemplo.com',password: 'MinhaSenh@123'}// Actconst response = await app.inject({method: 'POST',url: '/users',headers: {authorization: `Bearer ${authToken}`,'content-type': 'application/json'},payload: userData})// Assertexpect(response.statusCode).toBe(201)const responseBody = response.json()expect(responseBody).toHaveProperty('id')expect(responseBody.name).toBe(userData.name)expect(responseBody.email).toBe(userData.email)expect(responseBody).not.toHaveProperty('password') // Não deve retornar senha})it('should return 400 for invalid email', async () => {// Arrangeconst userData = {name: 'João Test',email: 'email-inválido',password: 'MinhaSenh@123'}// Actconst response = await app.inject({method: 'POST',url: '/users',headers: {authorization: `Bearer ${authToken}`,'content-type': 'application/json'},payload: userData})// Assertexpect(response.statusCode).toBe(400)const responseBody = response.json()expect(responseBody).toHaveProperty('message')expect(responseBody.errors).toBeDefined()})it('should return 401 without auth token', async () => {// Actconst response = await app.inject({method: 'POST',url: '/users',headers: {'content-type': 'application/json'},payload: {}})// Assertexpect(response.statusCode).toBe(401)})})describe('GET /users/:id', () => {it('should return user by id', async () => {// Arrange - Criar usuário de testeconst testUser = await prisma.user.create({data: {name: 'João Test',email: 'joao.get.test@exemplo.com',password: 'hashed-password'}})// Actconst response = await app.inject({method: 'GET',url: `/users/${testUser.id}`,headers: {authorization: `Bearer ${authToken}`}})// Assertexpect(response.statusCode).toBe(200)const responseBody = response.json()expect(responseBody.id).toBe(testUser.id)expect(responseBody.name).toBe(testUser.name)expect(responseBody.email).toBe(testUser.email)})it('should return 404 for non-existent user', async () => {// Actconst response = await app.inject({method: 'GET',url: '/users/999',headers: {authorization: `Bearer ${authToken}`}})// Assertexpect(response.statusCode).toBe(404)})})})
3. Testes com Mocks Avançados:
// tests/mocks/prisma.tsimport { vi } from 'vitest'import { PrismaClient } from '@prisma/client'import { mockDeep, DeepMockProxy } from 'vitest-mock-extended'export const prismaMock = mockDeep<PrismaClient>()// Mock factory para criar dados de testeexport const createMockUser = (overrides = {}) => ({id: '123',name: 'João Silva',email: 'joao@exemplo.com',role: 'USER',isActive: true,createdAt: new Date('2023-01-01'),updatedAt: new Date('2023-01-01'),...overrides})export const createMockPost = (overrides = {}) => ({id: '456',title: 'Post de Teste',content: 'Conteúdo do post',published: true,authorId: '123',createdAt: new Date('2023-01-01'),updatedAt: new Date('2023-01-01'),...overrides})
4. Testes de Utilitários:
// src/utils/__tests__/validation.test.tsimport { describe, it, expect } from 'vitest'import { validateEmail, sanitizeString, generateSlug } from '../validation'describe('Validation Utils', () => {describe('validateEmail', () => {it.each([['test@example.com', true],['user.name+tag@example.co.uk', true],['invalid-email', false],['@example.com', false],['test@', false],['', false]])('should validate email %s as %s', (email, expected) => {expect(validateEmail(email)).toBe(expected)})})describe('sanitizeString', () => {it('should remove HTML tags', () => {const input = '<script>alert("xss")</script>Hello World'const result = sanitizeString(input)expect(result).toBe('Hello World')})it('should trim whitespace', () => {const input = ' Hello World 'const result = sanitizeString(input)expect(result).toBe('Hello World')})})describe('generateSlug', () => {it('should generate URL-friendly slug', () => {const input = 'Meu Artigo Sobre TypeScript'const result = generateSlug(input)expect(result).toBe('meu-artigo-sobre-typescript')})it('should handle special characters', () => {const input = 'Artigo com Acentos: São Paulo & Rio!'const result = generateSlug(input)expect(result).toBe('artigo-com-acentos-sao-paulo-rio')})})})
5. Testes Assíncronos e Timers:
// src/services/__tests__/cacheService.test.tsimport { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'import { cacheService } from '../cacheService'describe('CacheService', () => {beforeEach(() => {vi.useFakeTimers()})afterEach(() => {vi.useRealTimers()})it('should cache value with TTL', async () => {// Arrangeconst key = 'test-key'const value = { data: 'test' }const ttl = 1000 // 1 segundo// Actawait cacheService.set(key, value, ttl)const result1 = await cacheService.get(key)// Assertexpect(result1).toEqual(value)// Advance time beyond TTLvi.advanceTimersByTime(1001)const result2 = await cacheService.get(key)expect(result2).toBeNull()})it('should handle concurrent cache operations', async () => {// Arrangeconst key = 'concurrent-key'const promises = []// Act - Simular múltiplas operações concorrentesfor (let i = 0; i < 10; i++) {promises.push(cacheService.set(`${key}-${i}`, { value: i }))}await Promise.all(promises)// Assertfor (let i = 0; i < 10; i++) {const result = await cacheService.get(`${key}-${i}`)expect(result).toEqual({ value: i })}})})
Comandos de Desenvolvimento
# Executar testes em modo watchnpm run test# Executar todos os testes uma veznpm run test:run# Executar testes com UI webnpm run test:ui# Executar com coveragenpm run test:coverage# Executar testes relacionados a arquivos alteradosnpm run test:related# Executar teste específiconpm run test -- users.test.ts# Executar testes com patternnpm run test -- --grep "should create user"# Modo debugnpm run test -- --inspect-brk
Configurações Avançadas
1. Testes com diferentes ambientes:
// vitest.config.integration.tsimport { defineConfig } from 'vitest/config'export default defineConfig({test: {environment: 'node',testTimeout: 30000, // Timeout maior para testes de integraçãoinclude: ['**/*.integration.test.ts'],setupFiles: ['./tests/setup.integration.ts']}})
2. Parallel testing:
// vitest.config.tsexport default defineConfig({test: {pool: 'threads', // ou 'forks'poolOptions: {threads: {singleThread: false,maxThreads: 4,minThreads: 2}}}})
3. Custom matchers:
// tests/matchers.tsimport { expect } from 'vitest'expect.extend({toBeValidUuid(received: string) {const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ireturn {pass: uuidRegex.test(received),message: () => `Expected ${received} to be a valid UUID`}},toHaveBeenCalledWithUser(received: any, expected: any) {const calls = received.mock.callsconst found = calls.some((call: any) =>call[0] && call[0].email === expected.email)return {pass: found,message: () => `Expected function to have been called with user ${expected.email}`}}})// Usar nos testesexpect('550e8400-e29b-41d4-a716-446655440000').toBeValidUuid()
Debugging e Troubleshooting
1. Debug de testes:
# Executar com debuggingnpm run test -- --inspect-brk# No VS Code, adicionar breakpoints e usar F5
2. Logging em testes:
import { it, expect } from 'vitest'it('should debug test', () => {const result = someFunction()// Log temporário para debugconsole.log('Debug result:', result)expect(result).toBe('expected')})