Introdução ao Vitest e TypeScript
Vitest é um framework de testes unitários moderno, construído sobre Vite, que oferece uma experiência de desenvolvimento extremamente rápida. Diferentemente do Jest, que é mais pesado e com tempos de inicialização maiores, Vitest aproveita a natureza do Vite para fornecer feedback instantâneo durante o desenvolvimento. Quando combinado com TypeScript, você obtém segurança de tipos em tempo de compilação para seus testes, reduzindo drasticamente bugs relacionados a tipagem e tornando o refatoramento do código muito mais seguro.
A razão pela qual testes totalmente tipados importam é simples: seus testes são código também. Se você escreve um teste sem tipos adequados, pode estar testando algo que não existe ou com parâmetros completamente errados sem nem perceber. TypeScript força você a ser explícito sobre o que está testando, e isso resulta em testes mais confiáveis e maintíveis. Neste artigo, vamos explorar como configurar Vitest com TypeScript desde zero, implementar testes bem estruturados e aproveitar o sistema de tipos para criar uma suite de testes robusta.
Configuração Inicial do Ambiente
Instalação e Setup Básico
Antes de qualquer coisa, você precisa ter Node.js 18+ instalado em sua máquina. Em seguida, crie um novo projeto e instale as dependências necessárias:
npm create vite@latest meu-projeto -- --template vanilla
cd meu-projeto
npm install -D vitest @vitest/ui typescript @types/node
Agora, vamos configurar o tsconfig.json para garantir que TypeScript entenda corretamente os tipos do Vitest:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vitest/globals"]
},
"include": ["src", "tests"],
"references": [{ "path": "./tsconfig.node.json" }]
}
A chave aqui é "strict": true e "types": ["vitest/globals"], que habilitam verificação de tipos rigorosa e permitem usar describe, it, expect sem importar explicitamente em cada arquivo.
Configuração do Vitest
Crie um arquivo vitest.config.ts na raiz do seu projeto:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/**/*.test.ts',
'src/**/*.spec.ts'
]
}
}
});
Isso configura Vitest para usar variáveis globais (você não precisa importar describe e it), define o ambiente como Node.js, especifica quais arquivos são testes e configura cobertura de código. Agora adicione os scripts no package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
Estruturando Testes Tipados com TypeScript
Funções Utilitárias e Seus Testes
Vamos criar uma função real que faremos testes. Crie o arquivo src/utils/math.ts:
export interface CalculationResult {
value: number;
operation: string;
timestamp: Date;
}
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export function divide(a: number, b: number): CalculationResult {
if (b === 0) {
throw new Error('Division by zero is not allowed');
}
return {
value: a / b,
operation: 'divide',
timestamp: new Date()
};
}
Agora, crie os testes em src/utils/math.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { add, multiply, divide, CalculationResult } from './math';
describe('Math Utils', () => {
describe('add', () => {
it('should sum two positive numbers correctly', () => {
const result: number = add(2, 3);
expect(result).toBe(5);
});
it('should handle negative numbers', () => {
const result = add(-5, 3);
expect(result).toBe(-2);
});
it('should return 0 when adding 0', () => {
expect(add(0, 5)).toBe(5);
expect(add(5, 0)).toBe(5);
});
});
describe('multiply', () => {
it('should multiply two numbers correctly', () => {
const result = multiply(4, 5);
expect(result).toBe(20);
});
it('should return 0 when multiplying by 0', () => {
expect(multiply(10, 0)).toBe(0);
expect(multiply(0, 10)).toBe(0);
});
});
describe('divide', () => {
it('should return correct division result with structure', () => {
const result: CalculationResult = divide(10, 2);
expect(result.value).toBe(5);
expect(result.operation).toBe('divide');
expect(result.timestamp).toBeInstanceOf(Date);
});
it('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero is not allowed');
});
it('should maintain type safety with CalculationResult', () => {
const result = divide(20, 4);
// TypeScript garante que essas propriedades existem
const value: number = result.value;
const operation: string = result.operation;
const timestamp: Date = result.timestamp;
expect(typeof value).toBe('number');
expect(typeof operation).toBe('string');
expect(timestamp.getTime()).toBeLessThanOrEqual(Date.now());
});
});
});
Repare que estamos sendo explícitos com as anotações de tipo. Isso não é apenas documentation — TypeScript vai verificar se você está realmente testando o que pensa que está testando.
Testes com Genéricos
Para demonstrar o poder da tipagem com Vitest, vamos criar uma função genérica e testá-la adequadamente. Crie src/utils/transform.ts:
export interface Transformer<T, U> {
(value: T): U;
}
export function applyTransform<T, U>(value: T, transformer: Transformer<T, U>): U {
return transformer(value);
}
export function chain<T, U, V>(
value: T,
first: Transformer<T, U>,
second: Transformer<U, V>
): V {
return second(first(value));
}
Agora, em src/utils/transform.test.ts:
import { describe, it, expect } from 'vitest';
import { applyTransform, chain, Transformer } from './transform';
describe('Transform Utils', () => {
describe('applyTransform', () => {
it('should apply string transformation', () => {
const toUpper: Transformer<string, string> = (s) => s.toUpperCase();
const result = applyTransform('hello', toUpper);
expect(result).toBe('HELLO');
// TypeScript garante que result é string
expect(typeof result).toBe('string');
});
it('should apply number transformation', () => {
const double: Transformer<number, number> = (n) => n * 2;
const result = applyTransform(5, double);
expect(result).toBe(10);
});
it('should transform between different types', () => {
const stringify: Transformer<number, string> = (n) => `Number: ${n}`;
const result = applyTransform(42, stringify);
expect(result).toBe('Number: 42');
expect(typeof result).toBe('string');
});
});
describe('chain', () => {
it('should chain transformations correctly', () => {
const addTen: Transformer<number, number> = (n) => n + 10;
const double: Transformer<number, number> = (n) => n * 2;
const result = chain(5, addTen, double);
expect(result).toBe(30); // (5 + 10) * 2
});
it('should chain different types in sequence', () => {
const numberToString: Transformer<number, string> = (n) => `Value: ${n}`;
const addExclamation: Transformer<string, string> = (s) => s + '!';
const result = chain(42, numberToString, addExclamation);
expect(result).toBe('Value: 42!');
});
});
});
Aqui vemos a força de TypeScript: se você tentar passar transformers com tipos incompatíveis, o compilador vai reclamar antes mesmo de você rodar o teste.
Padrões Avançados e Melhores Práticas
Fixtures e Setup/Teardown
Para testes que dependem de estado compartilhado, use beforeEach e afterEach com tipagem adequada. Crie src/services/user.ts:
export interface User {
id: number;
name: string;
email: string;
createdAt: Date;
}
export class UserService {
private users: Map<number, User> = new Map();
private nextId: number = 1;
create(name: string, email: string): User {
const user: User = {
id: this.nextId++,
name,
email,
createdAt: new Date()
};
this.users.set(user.id, user);
return user;
}
getById(id: number): User | undefined {
return this.users.get(id);
}
update(id: number, updates: Partial<Omit<User, 'id' | 'createdAt'>>): User {
const user = this.users.get(id);
if (!user) {
throw new Error(`User ${id} not found`);
}
const updated = { ...user, ...updates };
this.users.set(id, updated);
return updated;
}
delete(id: number): boolean {
return this.users.delete(id);
}
list(): User[] {
return Array.from(this.users.values());
}
clear(): void {
this.users.clear();
this.nextId = 1;
}
}
Agora, em src/services/user.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { UserService, User } from './user';
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
});
afterEach(() => {
userService.clear();
});
describe('create', () => {
it('should create a new user with correct properties', () => {
const user: User = userService.create('João Silva', 'joao@example.com');
expect(user.id).toBe(1);
expect(user.name).toBe('João Silva');
expect(user.email).toBe('joao@example.com');
expect(user.createdAt).toBeInstanceOf(Date);
});
it('should increment user id for each creation', () => {
const user1 = userService.create('User 1', 'user1@example.com');
const user2 = userService.create('User 2', 'user2@example.com');
expect(user1.id).toBe(1);
expect(user2.id).toBe(2);
});
});
describe('getById', () => {
it('should return user when found', () => {
const created = userService.create('Test User', 'test@example.com');
const found = userService.getById(created.id);
expect(found).toEqual(created);
});
it('should return undefined when user not found', () => {
const found = userService.getById(999);
expect(found).toBeUndefined();
});
});
describe('update', () => {
it('should update user properties', () => {
const user = userService.create('Original Name', 'original@example.com');
const updated = userService.update(user.id, {
name: 'Updated Name',
email: 'updated@example.com'
});
expect(updated.name).toBe('Updated Name');
expect(updated.email).toBe('updated@example.com');
expect(updated.id).toBe(user.id);
expect(updated.createdAt).toEqual(user.createdAt);
});
it('should throw when updating non-existent user', () => {
expect(() => {
userService.update(999, { name: 'Test' });
}).toThrow('User 999 not found');
});
});
describe('list', () => {
it('should return all users', () => {
userService.create('User 1', 'user1@example.com');
userService.create('User 2', 'user2@example.com');
userService.create('User 3', 'user3@example.com');
const users: User[] = userService.list();
expect(users).toHaveLength(3);
expect(users.every(u => u.id > 0)).toBe(true);
});
it('should return empty array when no users', () => {
const users = userService.list();
expect(users).toEqual([]);
});
});
});
O padrão aqui é importante: beforeEach cria uma nova instância limpa para cada teste, garantindo que testes não interfiram um com o outro. A tipagem explícita de userService: UserService permite que TypeScript autocomplete todos os métodos.
Assertivas Customizadas com Tipos
Crie src/test/assertions.ts para adicionar assertivas customizadas e totalmente tipadas:
import { expect } from 'vitest';
export interface ValidationError {
field: string;
message: string;
}
export interface ValidationResult<T> {
valid: boolean;
errors: ValidationError[];
data?: T;
}
export function expectValidationSuccess<T>(
result: ValidationResult<T>,
expectedData?: T
): void {
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
if (expectedData !== undefined) {
expect(result.data).toEqual(expectedData);
}
}
export function expectValidationFailure(
result: ValidationResult<unknown>,
expectedFields: string[]
): void {
expect(result.valid).toBe(false);
expect(result.errors).not.toHaveLength(0);
const errorFields = result.errors.map(e => e.field);
expectedFields.forEach(field => {
expect(errorFields).toContain(field);
});
}
E use em seus testes:
import { describe, it } from 'vitest';
import { expectValidationSuccess, expectValidationFailure, ValidationResult } from '../test/assertions';
describe('Custom Assertions', () => {
it('should validate correct data', () => {
const result: ValidationResult<{ name: string }> = {
valid: true,
errors: [],
data: { name: 'João' }
};
expectValidationSuccess(result, { name: 'João' });
});
it('should detect validation errors', () => {
const result: ValidationResult<unknown> = {
valid: false,
errors: [
{ field: 'email', message: 'Invalid email' },
{ field: 'age', message: 'Must be 18+' }
]
};
expectValidationFailure(result, ['email', 'age']);
});
});
Conclusão
Ao trabalhar com Vitest e TypeScript, você está construindo uma fundação sólida para sua aplicação. Os três pontos principais que você precisa dominar são: primeiro, a configuração adequada com tsconfig.json e vitest.config.ts garante que todos os tipos sejam respeitados desde o início; segundo, usar anotações de tipo explícitas em seus testes força você a ser claro sobre o contrato que está testando, prevenindo bugs sutis; terceiro, padrões como fixtures com beforeEach/afterEach e assertivas customizadas tipadas tornam seus testes mais mantíveis e profissionais conforme a suite cresce.