Testes tipados com Jest e ts-jest

1. Por que testar com TypeScript?

A tipagem estática em testes não é um luxo — é uma necessidade para projetos que almejam robustez. Quando escrevemos testes em JavaScript puro, erros como undefined is not a function só aparecem em runtime. Com TypeScript, muitos desses erros são capturados durante a compilação, antes mesmo do teste executar.

Considere a diferença entre um teste .js e .ts:

// JavaScript: erro só em runtime
const result = calculateTotal(undefined, 50);
// TypeError: Cannot read properties of undefined

// TypeScript: erro em tempo de compilação
const result = calculateTotal(undefined, 50);
// Argument of type 'undefined' is not assignable to parameter of type 'number'

O verdadeiro ganho surge quando a interface do seu código muda. Se um parâmetro de função for renomeado ou um tipo de retorno alterado, os testes tipados quebram imediatamente, forçando a atualização. Isso cria um ecossistema onde o sistema de tipos age como uma rede de segurança para refatorações.

2. Configurando o ambiente: Jest + ts-jest

Para começar, instale as dependências necessárias:

npm install --save-dev jest ts-jest @types/jest typescript

Crie um arquivo jest.config.ts na raiz do projeto:

import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
};

export default config;

Ajuste o tsconfig.json para incluir os arquivos de teste:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "types": ["jest", "node"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

3. Escrevendo o primeiro teste tipado

Vamos testar uma função pura com parâmetros e retorno tipados:

// src/utils/math.ts
export function calculateDiscount(price: number, discountPercent: number): number {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error('Discount must be between 0 and 100');
  }
  return price * (1 - discountPercent / 100);
}
// src/__tests__/math.test.ts
import { calculateDiscount } from '../utils/math';

describe('calculateDiscount', () => {
  it('should apply 20% discount correctly', () => {
    const result: number = calculateDiscount(100, 20);
    expect(result).toBe(80);
  });

  it('should throw error for invalid discount', () => {
    expect(() => calculateDiscount(100, 150)).toThrow('Discount must be between 0 and 100');
  });

  it('should return same price for 0% discount', () => {
    const result = calculateDiscount(100, 0);
    expect(result).toBe(100);
  });
});

Note como o TypeScript infere automaticamente os tipos de result e valida que calculateDiscount retorna um número.

4. Mocks e spies com tipagem forte

O uso de jest.fn() sem tipos pode introduzir any perigoso. Veja como tipar corretamente:

// src/services/email.ts
export interface EmailService {
  send(to: string, subject: string, body: string): Promise<boolean>;
}

export class NotificationService {
  constructor(private emailService: EmailService) {}

  async notifyUser(email: string, message: string): Promise<boolean> {
    return this.emailService.send(email, 'Notification', message);
  }
}
// src/__tests__/notification.test.ts
import { NotificationService } from '../services/email';
import { jest } from '@jest/globals';

describe('NotificationService', () => {
  it('should send notification successfully', async () => {
    const mockSend = jest.fn<(to: string, subject: string, body: string) => Promise<boolean>>()
      .mockResolvedValue(true);

    const service = new NotificationService({ send: mockSend });
    const result = await service.notifyUser('user@example.com', 'Hello!');

    expect(result).toBe(true);
    expect(mockSend).toHaveBeenCalledWith(
      'user@example.com',
      'Notification',
      'Hello!'
    );
  });
});

Para mocks mais complexos, use jest.Mocked<T>:

import { jest } from '@jest/globals';

const mockEmailService: jest.Mocked<EmailService> = {
  send: jest.fn().mockResolvedValue(true),
};

5. Testando classes e interfaces

Classes com dependências injetadas são testáveis com tipagem forte:

// src/models/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

export class UserRepository {
  async findById(id: string): Promise<User | null> {
    // Implementação real
    return null;
  }
}

export class UserService {
  constructor(private repository: UserRepository) {}

  async getUserEmail(id: string): Promise<string | null> {
    const user = await this.repository.findById(id);
    return user?.email ?? null;
  }
}
// src/__tests__/user.test.ts
import { UserService, UserRepository } from '../models/user';
import { jest } from '@jest/globals';

describe('UserService', () => {
  it('should return user email when user exists', async () => {
    const mockUser = { id: '1', name: 'John', email: 'john@example.com' };
    const mockFindById = jest.fn<(id: string) => Promise<typeof mockUser | null>>()
      .mockResolvedValue(mockUser);

    const repository = new UserRepository();
    repository.findById = mockFindById;

    const service = new UserService(repository);
    const email = await service.getUserEmail('1');

    expect(email).toBe('john@example.com');
  });
});

6. Testes assíncronos e Promises tipadas

Funções assíncronas exigem tratamento cuidadoso de tipos:

// src/services/api.ts
interface ApiResponse<T> {
  data: T;
  status: number;
}

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  const data: T = await response.json();
  return { data, status: response.status };
}
// src/__tests__/api.test.ts
import { fetchData } from '../services/api';

describe('fetchData', () => {
  it('should return typed response', async () => {
    expect.assertions(2);

    interface UserResponse {
      id: number;
      name: string;
    }

    const result = await fetchData<UserResponse>('/api/user/1');
    expect(result.status).toBe(200);
    expect(result.data.id).toBeDefined();
  });

  it('should handle errors with typed rejection', async () => {
    await expect(fetchData('/invalid-url')).rejects.toThrow();
  });
});

7. Integração com tsconfig paths e aliases

Para usar aliases de path, configure o tsconfig.json e o Jest:

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@utils/*": ["src/utils/*"]
    }
  }
}

No jest.config.ts:

const config: Config = {
  preset: 'ts-jest',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '^@utils/(.*)$': '<rootDir>/src/utils/$1',
  },
};

Agora você pode importar com aliases nos testes:

import { calculateDiscount } from '@/utils/math';

8. Boas práticas e armadilhas comuns

Evite any e as unknown as T sempre que possível. Eles quebram a segurança de tipos:

// ❌ Ruim
const mockData: any = { name: 'John' };

// ✅ Bom
interface UserData { name: string; }
const mockData: UserData = { name: 'John' };

Mantenha mocks sincronizados com o código real. Se uma interface mudar, os mocks devem refletir essa mudança:

// ✅ Use tipos derivados
type MockEmailService = jest.Mocked<EmailService>;

Performance: Para projetos grandes, use isolatedModules: true no tsconfig.json dos testes:

{
  "compilerOptions": {
    "isolatedModules": true
  }
}

Isso acelera a compilação, mas desabilita algumas verificações cross-file. Em contrapartida, a compilação completa com ts-jest oferece mais segurança de tipos.

Com essas práticas, seus testes não apenas verificam comportamento, mas também servem como documentação viva das interfaces do sistema. A tipagem forte transforma o conjunto de testes em uma barreira confiável contra regressões silenciosas.

Referências