Skip to main content
backend2 de novembro de 202516 min de leitura

Testes Unitários em 2026: Sem Desculpas Restantes

As ferramentas evoluíram. A IA escreve scaffolds de teste, os frameworks são rápidos e não há mais uma boa razão para entregar código não testado.

testingvitestjest
Testes Unitários em 2026: Sem Desculpas Restantes

Três anos atrás, as desculpas para não escrever testes eram pelo menos plausíveis. Os frameworks de teste eram lentos. Configurar mocks era tedioso. Escrever testes levava mais tempo do que escrever o código. O ROI não era óbvio para uma startup de 3 pessoas correndo para o product-market fit. Eu não concordava com essas desculpas, mas as entendia.

Em 2026, essas desculpas desapareceram. O Vitest executa uma suíte de testes completa no tempo que o Jest levava para inicializar. Ferramentas de IA criam arquivos de teste abrangentes a partir de uma assinatura de função. O TypeScript detecta categorias inteiras de bugs em tempo de compilação, reduzindo o que realmente precisa ser testado. O Docker Compose levanta bancos de dados reais para testes de integração em segundos. A lacuna entre "sem testes" e "bem testado" nunca foi tão pequena.

O Cenário Moderno de Testes

O ecossistema de testes se consolidou em torno de algumas ferramentas excelentes, e a fragmentação que costumava tornar a configuração de testes um projeto de pesquisa praticamente desapareceu.

Vitest vs Jest: A Decisão Está Tomada

Para qualquer novo projeto, Vitest é a escolha padrão. Não porque Jest seja ruim — Jest é um framework sólido e testado em batalha — mas porque Vitest é comprovadamente melhor em todas as dimensões que importam para o desenvolvimento moderno.

Velocidade: O Vitest usa esbuild para transformação e executa testes em worker threads com suporte nativo a ESM. Nos meus projetos, a mesma suíte de testes é executada 3-5x mais rápido com Vitest em comparação com Jest. Uma suíte que levava 45 segundos no Jest é concluída em 12 segundos no Vitest.

Configuração: O Vitest reutiliza sua configuração do Vite. Se você já está usando Vite para sua build (e em 2026, a maioria dos projetos está), não há configuração adicional. Compare isso com moduleNameMapper, transform, transformIgnorePatterns do Jest, e a inevitável solução de problemas de por que algum pacote ESM não funciona.

Compatibilidade: O Vitest implementa uma API compatível com Jest. describe, it, expect, beforeEach, afterEach, vi.fn(), vi.mock() — todos funcionam como esperado. Migrar de Jest para Vitest é principalmente um localizar e substituir de jest.fn() para vi.fn().

// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
    },
  },
});

Quando manter o Jest: Se você tem uma grande suíte de testes Jest existente (mais de 500 testes) e nenhum problema imediato, a migração não é urgente. O Jest continua a receber atualizações e funciona bem. Mas para novos projetos ou projetos com menos de 100 testes, Vitest é a escolha óbvia.

A Pirâmide de Testes na Prática

A pirâmide de testes tradicional (muitos testes unitários, menos testes de integração, poucos testes E2E) permanece correta em princípio, mas os limites mudaram.

Testes unitários verificam funções individuais, métodos utilitários e lógica de negócios pura. Eles devem ser rápidos (menos de 10ms cada), não ter dependências externas e testar o comportamento em vez da implementação.

Testes de integração verificam se os componentes funcionam juntos — rotas de API com consultas de banco de dados, métodos de serviço com chamadas de API externas, componentes React com seu gerenciamento de estado. Estes são mais lentos (100-500ms cada), mas detectam bugs que os testes unitários perdem.

Testes E2E (End-to-End) verificam fluxos de trabalho completos do usuário através da aplicação real. Estes são os mais lentos (5-30 segundos cada) e mais frágeis, mas detectam bugs que nada mais faz. Ferramentas como Playwright tornaram os testes E2E significativamente mais confiáveis do que na era do Selenium.

A proporção que eu almejo: 70% unitários, 20% de integração, 10% E2E. Os números exatos importam menos do que ter representação em cada nível.

O Que Testar e O Que Pular

Nem todo código precisa do mesmo rigor de teste. Saber onde investir o esforço de teste é tão importante quanto saber como escrever testes.

Sempre Testar

Lógica de negócios e regras de domínio. Se o código implementa uma regra de negócios — cálculo de preços, verificação de permissão, validação de dados, transição de máquina de estados — ele precisa de testes. Estas são as regras que, se erradas, custam dinheiro ou criam vulnerabilidades de segurança.

// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  if (!discount) return subtotal;

  if (discount.type === 'percentage') {
    return subtotal * (1 - discount.value / 100);
  }

  if (discount.type === 'fixed') {
    return Math.max(0, subtotal - discount.value);
  }

  return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';

describe('calculateOrderTotal', () => {
  const items: OrderItem[] = [
    { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
    { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
  ];

  it('calculates subtotal without discount', () => {
    expect(calculateOrderTotal(items)).toBe(30.97);
  });

  it('applies percentage discount', () => {
    const discount = { type: 'percentage' as const, value: 10 };
    expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
  });

  it('applies fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 5 };
    expect(calculateOrderTotal(items, discount)).toBe(25.97);
  });

  it('does not go below zero with fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 50 };
    expect(calculateOrderTotal(items, discount)).toBe(0);
  });

  it('handles empty items array', () => {
    expect(calculateOrderTotal([])).toBe(0);
  });
});

Funções de transformação de dados. Qualquer função que transforma dados de uma forma para outra — mappers de resposta de API, serializadores de dados de formulário, parsers CSV — precisa de testes com entradas representativas e casos de borda.

Caminhos de tratamento de erros. O que acontece quando a API retorna um 500? Quando a entrada é nula? Quando o arquivo não existe? Os caminhos de erro são onde os bugs se escondem porque os desenvolvedores testam o caminho feliz manualmente e assumem que o caminho de erro funciona.

Funções utilitárias. Formatadores de string, helpers de data, utilitários de array, funções de validação. Estes são usados em todos os lugares, e um bug em uma função utilitária se multiplica por toda a base de código.

Pular ou Testar Levemente

Wrappers diretos de frameworks. Se sua função é um wrapper fino em torno de um método de framework bem testado, testá-la testa o framework, não seu código. Um componente React que renderiza <h1>{title}</h1> não precisa de um teste verificando se o h1 é renderizado.

Arquivos de configuração. Configuração estática, constantes, definições de tipo — estes não precisam de testes. O TypeScript já os valida em tempo de compilação.

Cola de integração de bibliotecas de terceiros. O código que chama stripe.charges.create() com parâmetros do seu modelo de dados não precisa de um teste unitário que simule o Stripe e verifique se você o chamou. Ele precisa de um teste de integração que verifique o fluxo de cobrança de ponta a ponta.

Estratégias de Mocking

Mocking é onde os testes passam de "verificar comportamento" para "testar detalhes de implementação". O objetivo é simular o mínimo possível, mantendo os testes rápidos e determinísticos.

A Abordagem de Injeção de Dependência

Em vez de simular módulos, passe as dependências como parâmetros. Isso torna o teste natural e evita a mágica de vi.mock() que acopla os testes à estrutura do módulo.

// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
  return {
    async createUser(data: CreateUserInput): Promise<User> {
      const user = await db.users.create({ data });
      await emailClient.send({
        to: user.email,
        template: 'welcome',
        data: { name: user.name },
      });
      return user;
    },
  };
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';

describe('createUserService', () => {
  it('creates user and sends welcome email', async () => {
    const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
    const db = {
      users: { create: vi.fn().mockResolvedValue(mockUser) },
    };
    const emailClient = {
      send: vi.fn().mockResolvedValue(undefined),
    };

    const service = createUserService(db as any, emailClient as any);
    const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });

    expect(result).toEqual(mockUser);
    expect(emailClient.send).toHaveBeenCalledWith({
      to: 'jane@example.com',
      template: 'welcome',
      data: { name: 'Jane' },
    });
  });
});

Sem chamadas vi.mock(), sem strings de caminho de módulo, sem configuração dependente de ordem. O teste é explícito sobre o que é real e o que é falso.

Quando Usar vi.mock()

O mocking em nível de módulo é apropriado quando você não pode controlar a injeção de dependência — tipicamente ao testar componentes React que importam módulos diretamente ou ao testar código que usa globais específicos do ambiente.

// When you genuinely need module mocking
vi.mock('./api-client', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));

MSW para Mocking de API

Para testes que fazem requisições HTTP, o Mock Service Worker (MSW) intercepta requisições de rede no nível do service worker. Isso é superior a simular fetch ou axios porque testa seu código cliente HTTP real.

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('https://api.example.com/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test User',
      email: 'test@example.com',
    });
  }),

  http.post('https://api.example.com/orders', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 'order-1', ...body, status: 'created' },
      { status: 201 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Geração de Testes Assistida por IA

Ferramentas de IA realmente mudaram a economia da escrita de testes. Gerar o scaffold inicial do teste — o boilerplate, os casos básicos de caminho feliz, os casos de borda padrão — é exatamente o tipo de trabalho repetitivo e baseado em padrões que os LLMs lidam bem.

O Que a IA Faz Bem

  • Gerar arquivos de teste a partir de assinaturas de função e definições de tipo
  • Sugerir casos de borda que você talvez não pensaria (arrays vazios, valores nulos, números de limite)
  • Escrever boilerplate repetitivo de setup/teardown
  • Criar objetos mock que correspondem a formas de interface
  • Gerar casos de teste parametrizados para funções com muitas combinações de entrada

O Que a IA Faz Mal

  • Entender o contexto de negócios ("isso deve falhar porque os usuários não podem fazer pedidos depois da meia-noite" é conhecimento de domínio)
  • Testar interações de estado complexas em várias chamadas de função
  • Escrever testes de integração significativos que exercitam os limites reais do sistema
  • Decidir o que testar e o que não testar
  • Escrever testes que verificam o comportamento em vez da implementação

O Fluxo de Trabalho Prático

Meu fluxo de trabalho com testes assistidos por IA:

  1. Escrever a função ou módulo.
  2. Pedir à IA para gerar um arquivo de teste com casos básicos.
  3. Revisar e corrigir os testes gerados — testes gerados por IA frequentemente testam detalhes de implementação em vez de comportamento.
  4. Adicionar casos específicos de domínio que a IA perdeu.
  5. Executar os testes e verificar se eles realmente detectam bugs quebrando temporariamente o código.

A IA faz o passo 2 (que costumava ser a parte tediosa) e eu me concentro nos passos 3-5 (que exigem julgamento). Isso reduz o tempo de escrita de testes em aproximadamente 50-60% para código utilitário e de serviço padrão.

Testando Componentes React

O teste de componentes evoluiu significativamente. A mudança da renderização shallow do Enzyme para a filosofia de teste centrada no usuário do React Testing Library mudou a forma como penso sobre os testes de componentes.

O Que Testar em Componentes

  • O componente renderiza corretamente com diferentes props?
  • As interações do usuário (clicar, digitar, selecionar) acionam o comportamento esperado?
  • O componente lida com estados de carregamento, erro e vazio?
  • A renderização condicional funciona corretamente?

O Que Não Testar em Componentes

  • Valores de estado interno (teste o que o usuário vê, não o que o React rastreia)
  • Detalhes de implementação (qual função foi chamada, quantas re-renderizações ocorreram)
  • Estilização (use testes de regressão visual para isso)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';

describe('OrderSummary', () => {
  const defaultProps = {
    items: [
      { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
      { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
    ],
    onCheckout: vi.fn(),
  };

  it('displays order items with prices', () => {
    render(<OrderSummary {...defaultProps} />);

    expect(screen.getByText('Burger')).toBeInTheDocument();
    expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
    expect(screen.getByText('Fries')).toBeInTheDocument();
    expect(screen.getByText('$4.99')).toBeInTheDocument();
  });

  it('displays total', () => {
    render(<OrderSummary {...defaultProps} />);
    expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
  });

  it('calls onCheckout when checkout button is clicked', async () => {
    const user = userEvent.setup();
    render(<OrderSummary {...defaultProps} />);

    await user.click(screen.getByRole('button', { name: /checkout/i }));
    expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
  });

  it('shows empty state when no items', () => {
    render(<OrderSummary {...defaultProps} items={[]} />);
    expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
  });

  it('disables checkout button during loading', () => {
    render(<OrderSummary {...defaultProps} isLoading={true} />);
    expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
  });
});

Observe: sem getByTestId a menos que necessário, sem verificação de estado interno, sem verificação de classes CSS. Os testes são lidos como uma descrição do que um usuário vê e faz.

Testando Rotas de API

Os testes de rota de API são testes de integração que verificam se o seu handler HTTP funciona corretamente com seu middleware, validação e formatação de resposta.

// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';

describe('POST /api/orders', () => {
  let authToken: string;

  beforeAll(async () => {
    await db.migrate.latest();
    await db.seed.run();
    // Get auth token for test user
    const loginResponse = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'testpassword' });
    authToken = loginResponse.body.token;
  });

  afterAll(async () => {
    await db.destroy();
  });

  it('creates an order with valid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'prod-1', quantity: 2 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id: expect.any(String),
      status: 'pending',
      items: expect.arrayContaining([
        expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
      ]),
    });
  });

  it('returns 401 without auth token', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ items: [{ productId: 'prod-1', quantity: 2 }] });

    expect(response.status).toBe(401);
  });

  it('returns 400 with invalid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [] });

    expect(response.status).toBe(400);
    expect(response.body.errors).toBeDefined();
  });

  it('returns 400 when product does not exist', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'nonexistent', quantity: 1 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(400);
    expect(response.body.message).toContain('not found');
  });
});

Testando Operações de Banco de Dados

Testar operações de banco de dados requer um banco de dados real. Simular consultas SQL testa o mock, não a consulta. Use um banco de dados de teste que é recriado para cada execução de teste.

// database.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});

beforeEach(async () => {
  // Clean tables in dependency order
  await prisma.orderItem.deleteMany();
  await prisma.order.deleteMany();
  await prisma.user.deleteMany();
});

describe('Order repository', () => {
  it('creates order with items and calculates total', async () => {
    const user = await prisma.user.create({
      data: { email: 'test@example.com', name: 'Test User' },
    });

    const order = await prisma.order.create({
      data: {
        userId: user.id,
        items: {
          create: [
            { productName: 'Burger', price: 12.99, quantity: 2 },
            { productName: 'Fries', price: 4.99, quantity: 1 },
          ],
        },
        total: 30.97,
      },
      include: { items: true },
    });

    expect(order.items).toHaveLength(2);
    expect(order.total).toBe(30.97);
    expect(order.userId).toBe(user.id);
  });

  it('enforces unique email constraint', async () => {
    await prisma.user.create({
      data: { email: 'duplicate@example.com', name: 'User 1' },
    });

    await expect(
      prisma.user.create({
        data: { email: 'duplicate@example.com', name: 'User 2' },
      })
    ).rejects.toThrow();
  });
});

Configuração do Banco de Dados de Teste em CI

# In GitHub Actions
services:
  postgres:
    image: postgres:16
    env:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test_db
    ports:
      - 5432:5432

Com Prisma, execute npx prisma migrate deploy antes dos testes para aplicar o esquema. Com SQL puro, execute scripts de migração. O banco de dados de teste inicia do zero para cada execução de CI.

Métricas de Cobertura Que Importam

A cobertura de código é um sinal útil, mas é um objetivo terrível.

A Armadilha da Cobertura

Otimizar para 100% de cobertura leva a testes que existem para atingir linhas não cobertas, em vez de verificar o comportamento. Já vi testes como este:

// This test exists purely for coverage
it('should have a default export', () => {
  expect(module).toBeDefined();
});

Este teste não verifica nada significativo. Ele aumenta o número de cobertura enquanto adiciona zero confiança.

Metas de Cobertura Significativas

Em vez de um limite de cobertura geral, uso diferentes metas para diferentes categorias de código:

Categoria Meta Justificativa
Lógica de negócios / Domínio 90%+ Bugs aqui custam dinheiro
Rotas de API / Controladores 80%+ Limites de integração
Funções utilitárias 95%+ Amplamente usadas, alto impacto
Componentes de UI 70%+ Bugs visuais detectados por E2E
Configuração / Setup Sem meta Estático, raramente quebra

Configure os limites de cobertura por diretório:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        'src/domain/**': { statements: 90, branches: 85 },
        'src/api/**': { statements: 80, branches: 75 },
        'src/utils/**': { statements: 95, branches: 90 },
      },
    },
  },
});

A Alternativa do Teste de Mutação

Se os números de cobertura parecem vazios, o teste de mutação oferece uma avaliação mais honesta. Ferramentas como Stryker introduzem pequenas mudanças (mutações) em seu código e verificam se seus testes as detectam. Uma mutação que sobrevive (os testes ainda passam) indica uma lacuna em sua suíte de testes.

npx stryker run

O teste de mutação é lento (executa sua suíte de testes inteira para cada mutação), mas revelador. Eu o executo mensalmente, em vez de em cada commit.

Desenvolvimento Orientado a Testes na Prática

Eu pratico TDD seletivamente, não dogmaticamente. Funciona excepcionalmente bem para alguns códigos e adiciona atrito a outros.

Onde o TDD Brilha

Funções puras com especificações claras. Se você conhece as entradas e saídas esperadas antes de escrever o código, escrever o teste primeiro é natural e produtivo.

Correções de bugs. Antes de corrigir um bug, escreva um teste que o reproduza. Em seguida, corrija o código até que o teste passe. Isso garante que o bug permaneça corrigido.

Refatoração. Escreva testes para o comportamento existente antes de refatorar. Os testes atuam como uma rede de segurança, verificando se o código refatorado produz resultados idênticos.

Onde o TDD Adiciona Atrito

Código exploratório. Quando você está descobrindo como algo deve funcionar — experimentando uma API, prototipando uma UI, explorando uma estrutura de dados — escrever testes primeiro o atrasa. Escreva o código, estabilize a interface e, em seguida, adicione os testes.

Componentes de UI. Os testes do Testing Library para componentes React são melhor escritos depois que o componente existe, porque a saída renderizada do componente (na qual os testes se baseiam) não é conhecida até que você o construa.

O Ciclo Vermelho-Verde-Refatorar

Quando pratico TDD, sigo o ciclo clássico estritamente:

  1. Vermelho: Escreva um teste que falhe (porque o código ainda não existe).
  2. Verde: Escreva o código mínimo para fazer o teste passar. Não otimize.
  3. Refatorar: Limpe o código mantendo todos os testes verdes.

A disciplina do passo 2 — escrever o código mínimo — é a mais importante e mais frequentemente violada. A tentação de escrever "a implementação real" imediatamente anula o propósito do TDD, que é permitir que os testes guiem o design incrementalmente.

O Investimento Paga Juros Compostos

Cada teste que você escreve tem uma vida útil medida em anos. Um teste escrito hoje detectará regressões na refatoração da próxima semana, na adição de recursos do próximo mês e na atualização do framework do próximo ano. Os 10 minutos que você gasta escrevendo um teste economizam 2 horas de depuração daqui a seis meses, mas você nunca vê essa economia explicitamente — você simplesmente nunca tem o bug.

A parte mais difícil do teste não são as ferramentas ou as técnicas. É a disciplina de escrever o teste agora, quando o código está fresco em sua mente, em vez de adicioná-lo ao backlog onde ele morre silenciosamente. A desculpa das ferramentas se foi. A desculpa da velocidade se foi. A única desculpa restante é a priorização, e entregar código testado não deve ser negociável.

DU

Danil Ulmashev

Full Stack Developer

Interesse em trabalhar juntos?