DevGuideTK
← Trilha Backend

Testes unitários, de integração e ponta a ponta

Os níveis de teste, o que cada um garante e como equilibrar custo e confiança.

Os exemplos testam a mesma regra nas cinco linguagens, com o framework de testes mais usado em cada uma. Depois vêm um teste de integração com o banco real e uma lista para revisar testes gerados por IA.

A regra testada

O cupom DEZ dá 10% de desconto, limitado a R$ 50,00. Os valores são guardados em centavos, para evitar erros de arredondamento com números decimais. Um total negativo é um erro.

TotalDesconto esperadoPor que este caso
R$ 100,00R$ 10,00Caso comum, abaixo do limite
R$ 500,00R$ 50,00Exatamente no limite
R$ 800,00R$ 50,00Acima do limite: o teto precisa valer
R$ 0,00R$ 0,00Carrinho vazio
R$ 0,05R$ 0,00Arredondamento: 10% de 5 centavos
-R$ 0,01erroEntrada inválida

Os casos não foram escolhidos ao acaso. Eles cobrem o caminho comum, o limite exato, os dois lados do limite, o zero, o arredondamento e a entrada inválida. É nos limites que a maior parte dos defeitos mora: um > no lugar de >=, um arredondamento para o lado errado.

Testes unitários, em cada linguagem

// desconto.ts
export function calcularDesconto(totalCentavos: number, cupom: string): number {
  if (totalCentavos < 0) throw new RangeError("total negativo");
  if (cupom !== "DEZ") return 0;
  return Math.min(Math.floor(totalCentavos * 0.1), 5_000);
}

// desconto.test.ts (Vitest)
import { describe, expect, it } from "vitest";
import { calcularDesconto } from "./desconto";

describe("calcularDesconto com o cupom DEZ", () => {
  it.each([
    [10_000, 1_000],
    [50_000, 5_000],
    [80_000, 5_000],
    [0, 0],
    [5, 0],
  ])("total %i centavos dá desconto de %i", (total, esperado) => {
    expect(calcularDesconto(total, "DEZ")).toBe(esperado);
  });

  it("recusa total negativo", () => {
    expect(() => calcularDesconto(-1, "DEZ")).toThrow(RangeError);
  });

  it("cupom desconhecido não dá desconto", () => {
    expect(calcularDesconto(10_000, "OUTRO")).toBe(0);
  });
});

O que muda de uma linguagem para outra

FrameworkTestes com vários casosConferir um erro
NodeVitest (ou Jest, ou node:test)it.eachexpect(() => ...).toThrow(Tipo)
Pythonpytest@pytest.mark.parametrizewith pytest.raises(Tipo)
GoPacote testing, da biblioteca padrãoTabela de casos com t.RunO erro é um valor de retorno, conferido com errors.Is
JavaJUnit 5@ParameterizedTest com @CsvSourceassertThrows(Tipo.class, ...)
C#xUnit (ou NUnit, ou MSTest)[Theory] com [InlineData]Assert.Throws<Tipo>(...)

Os testes com vários casos são o formato natural para regras de negócio: cada linha é um exemplo, e acrescentar um caso novo custa uma linha. Em Go, a tabela de casos é tão comum que é praticamente a convenção da linguagem.

Teste de integração com o banco real

O exemplo do nível Conceito: a função cadastra um cliente e devolve null quando o e-mail já existe.

export async function registrarCliente(db: Queryable, email: string) {
  const { rows } = await db.query(
    `INSERT INTO clientes (email) VALUES ($1)
     ON CONFLICT (email) DO NOTHING RETURNING id`,
    [email],
  );
  return rows[0]?.id ?? null;
}

Com o Testcontainers, o teste sobe um PostgreSQL de verdade, na mesma versão de produção, aplica as migrações do projeto e roda contra ele:

import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Pool } from "pg";
import { afterAll, beforeAll, beforeEach, expect, it } from "vitest";

let container: StartedPostgreSqlContainer;
let db: Pool;

beforeAll(async () => {
  container = await new PostgreSqlContainer("postgres:17-alpine").start();
  db = new Pool({ connectionString: container.getConnectionUri() });
  await aplicarMigracoes(db); // as mesmas migrações usadas em produção
}, 60_000);

afterAll(async () => {
  await db.end();
  await container.stop();
});

beforeEach(async () => {
  await db.query("TRUNCATE clientes RESTART IDENTITY");
});

it("cadastra o cliente e recusa e-mail repetido", async () => {
  const id = await registrarCliente(db, "marina@exemplo.com");
  expect(id).not.toBeNull();

  expect(await registrarCliente(db, "marina@exemplo.com")).toBeNull();
});

No nosso teste com essa função, os dois testes unitários com o banco simulado passaram, e o de integração falhou com o erro do PostgreSQL sobre a falta de restrição única para o ON CONFLICT. Depois de acrescentar UNIQUE à coluna email na migração, ele passou.

LinguagemBiblioteca
Nodetestcontainers e @testcontainers/postgresql
Pythontestcontainers (PostgresContainer)
Gotestcontainers-go, módulo postgres
JavaTestcontainers com JUnit 5 (@Testcontainers, @Container)
C#Testcontainers.PostgreSql

Subir o container leva alguns segundos. Por isso ele é iniciado uma vez para o arquivo de testes, ou para a suíte inteira, e não a cada teste. Entre os testes, as tabelas são limpas.

Revisar testes gerados por IA

Uma IA escreve testes que passam com facilidade, e esse é exatamente o problema: um teste que nunca falha não protege nada. Confira estes pontos:

PerguntaSinal de problema
O teste confere o resultado?Só confere que uma função foi chamada, ou que não houve exceção
O valor esperado foi calculado à mão?O teste repete a fórmula da implementação: expect(f(x)).toBe(x * 0.1)
Há casos-limite e de erro?Só o caminho feliz
O que está sendo testado é real?O banco, ou a própria função, foi substituído por um dublê
O teste falharia se o código estivesse errado?Mude o código de propósito e veja se algum teste falha

A última pergunta é a mais poderosa e custa um minuto: troque um >= por >, remova uma linha de validação, mude o limite de 5.000 para 6.000. Se nenhum teste falhar, os testes não estão conferindo aquilo. É o teste de mutação feito à mão.

Lista de revisão

Testes unitários

  • Cada regra de negócio tem testes para o caso comum, os limites, o zero e as entradas inválidas?
  • Os valores esperados foram calculados à mão, e não com a mesma fórmula da implementação?
  • O nome de cada teste descreve o comportamento esperado?
  • Relógio e aleatoriedade são controlados pelo teste?

Testes de integração

  • Consultas, transações e restrições são testadas num banco igual ao de produção?
  • As migrações aplicadas no teste são as mesmas de produção?
  • Cada teste começa com um estado conhecido?
  • Serviços externos de terceiros são substituídos por dublês?

Suíte

  • Os testes rodam a cada mudança proposta, e uma falha impede a entrada?
  • Existe algum teste frágil sendo ignorado?
  • Mudar o código de propósito faz algum teste falhar?
  • Nenhum teste foi afrouxado ou pulado para a suíte passar?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Escrever testes para uma regra de negócio

    Quero aprender a escrever bons testes unitários para uma regra de negócio, escolhendo os casos com método.
    
    Pergunte qual linguagem e framework de testes eu uso. Peça que eu descreva uma regra de negócio do meu sistema, ou use como exemplo uma regra de frete: grátis acima de R$ 200, R$ 15 para a capital, R$ 25 para o interior, e erro para CEP inválido.
    
    Vamos em etapas:
    1. Liste comigo os casos de teste, explicando a técnica: caso comum, limites exatos, os dois lados de cada limite, zero, valores vazios e entradas inválidas. Peça que eu sugira os casos antes de você completar.
    2. Escreva os testes no formato de vários casos da minha linguagem.
    3. Peça que eu implemente a regra até os testes passarem.
    4. Mude a implementação de propósito, em três pontos diferentes, e confira se algum teste falha em cada um.
    5. Se algum defeito passar, acrescente o caso que faltava.
  • Um teste de integração com Testcontainers

    Quero escrever meu primeiro teste de integração com o banco real usando Testcontainers.
    
    Pergunte qual linguagem, framework de testes, banco e forma de migração eu uso.
    
    Vamos em etapas. Em cada uma, deixe que eu tente e depois mostre a sua versão:
    1. Instalar e configurar a biblioteca do Testcontainers para o meu banco.
    2. Subir o container uma vez para o arquivo de testes e aplicar as migrações do projeto.
    3. Escrever um teste para uma função de acesso a dados que usa uma restrição do banco, como uma chave única.
    4. Limpar os dados entre os testes.
    5. Remover a restrição da migração de propósito e ver o teste falhar.
    6. Configurar a integração contínua para rodar esse teste.
    
    Explique o que observar em cada etapa.
  • Revisar testes gerados por IA

    Vou colar abaixo o código de uma função e os testes que foram gerados para ela. Revise se os testes realmente protegem o comportamento.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o teste:
    1. Os testes conferem o resultado, ou só que algo foi chamado ou que não houve exceção?
    2. Os valores esperados foram calculados de forma independente, ou repetem a fórmula da implementação?
    3. Há casos para os limites, o zero, os valores vazios e as entradas inválidas?
    4. Algum dublê substitui algo que deveria ser testado de verdade, como o banco ou a própria função?
    5. Os testes são independentes entre si e do horário?
    6. Os nomes descrevem o comportamento esperado?
    
    Depois, sugira três mudanças pequenas no código da função que introduzem defeitos, e diga se os testes atuais pegariam cada uma. Para cada defeito que passaria, escreva o teste que faltava.
    
    Função e testes:
    [cole aqui]