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.
| Total | Desconto esperado | Por que este caso |
|---|---|---|
| R$ 100,00 | R$ 10,00 | Caso comum, abaixo do limite |
| R$ 500,00 | R$ 50,00 | Exatamente no limite |
| R$ 800,00 | R$ 50,00 | Acima do limite: o teto precisa valer |
| R$ 0,00 | R$ 0,00 | Carrinho vazio |
| R$ 0,05 | R$ 0,00 | Arredondamento: 10% de 5 centavos |
| -R$ 0,01 | erro | Entrada 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);
});
});# desconto.py
def calcular_desconto(total_centavos: int, cupom: str) -> int:
if total_centavos < 0:
raise ValueError("total negativo")
if cupom != "DEZ":
return 0
return min(total_centavos // 10, 5_000)
# test_desconto.py (pytest)
import pytest
from desconto import calcular_desconto
@pytest.mark.parametrize(
("total", "esperado"),
[(10_000, 1_000), (50_000, 5_000), (80_000, 5_000), (0, 0), (5, 0)],
)
def test_cupom_dez(total, esperado):
assert calcular_desconto(total, "DEZ") == esperado
def test_recusa_total_negativo():
with pytest.raises(ValueError):
calcular_desconto(-1, "DEZ")
def test_cupom_desconhecido_nao_da_desconto():
assert calcular_desconto(10_000, "OUTRO") == 0// desconto.go
package loja
import "errors"
var ErrTotalNegativo = errors.New("total negativo")
func CalcularDesconto(totalCentavos int64, cupom string) (int64, error) {
if totalCentavos < 0 {
return 0, ErrTotalNegativo
}
if cupom != "DEZ" {
return 0, nil
}
return min(totalCentavos/10, 5_000), nil
}
// desconto_test.go (pacote testing)
package loja
import (
"errors"
"testing"
)
func TestCalcularDescontoCupomDez(t *testing.T) {
casos := []struct {
nome string
total int64
esperado int64
}{
{"abaixo do limite", 10_000, 1_000},
{"no limite", 50_000, 5_000},
{"acima do limite", 80_000, 5_000},
{"carrinho vazio", 0, 0},
{"arredondamento", 5, 0},
}
for _, c := range casos {
t.Run(c.nome, func(t *testing.T) {
desconto, err := CalcularDesconto(c.total, "DEZ")
if err != nil {
t.Fatalf("erro inesperado: %v", err)
}
if desconto != c.esperado {
t.Errorf("esperava %d, obteve %d", c.esperado, desconto)
}
})
}
}
func TestCalcularDescontoRecusaTotalNegativo(t *testing.T) {
if _, err := CalcularDesconto(-1, "DEZ"); !errors.Is(err, ErrTotalNegativo) {
t.Errorf("esperava ErrTotalNegativo, obteve %v", err)
}
}// Desconto.java
public final class Desconto {
public static long calcular(long totalCentavos, String cupom) {
if (totalCentavos < 0) throw new IllegalArgumentException("total negativo");
if (!"DEZ".equals(cupom)) return 0;
return Math.min(totalCentavos / 10, 5_000);
}
}
// DescontoTest.java (JUnit 5)
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class DescontoTest {
@ParameterizedTest(name = "total {0} centavos dá desconto de {1}")
@CsvSource({ "10000, 1000", "50000, 5000", "80000, 5000", "0, 0", "5, 0" })
void cupomDez(long total, long esperado) {
assertEquals(esperado, Desconto.calcular(total, "DEZ"));
}
@Test
void recusaTotalNegativo() {
assertThrows(IllegalArgumentException.class, () -> Desconto.calcular(-1, "DEZ"));
}
@Test
void cupomDesconhecidoNaoDaDesconto() {
assertEquals(0, Desconto.calcular(10_000, "OUTRO"));
}
}// Desconto.cs
public static class Desconto
{
public static long Calcular(long totalCentavos, string cupom)
{
ArgumentOutOfRangeException.ThrowIfNegative(totalCentavos);
if (cupom != "DEZ") return 0;
return Math.Min(totalCentavos / 10, 5_000);
}
}
// DescontoTests.cs (xUnit)
using Xunit;
public class DescontoTests
{
[Theory]
[InlineData(10_000, 1_000)]
[InlineData(50_000, 5_000)]
[InlineData(80_000, 5_000)]
[InlineData(0, 0)]
[InlineData(5, 0)]
public void CupomDez(long total, long esperado)
{
Assert.Equal(esperado, Desconto.Calcular(total, "DEZ"));
}
[Fact]
public void RecusaTotalNegativo()
{
Assert.Throws<ArgumentOutOfRangeException>(() => Desconto.Calcular(-1, "DEZ"));
}
[Fact]
public void CupomDesconhecidoNaoDaDesconto()
{
Assert.Equal(0, Desconto.Calcular(10_000, "OUTRO"));
}
}O que muda de uma linguagem para outra
| Framework | Testes com vários casos | Conferir um erro | |
|---|---|---|---|
| Node | Vitest (ou Jest, ou node:test) | it.each | expect(() => ...).toThrow(Tipo) |
| Python | pytest | @pytest.mark.parametrize | with pytest.raises(Tipo) |
| Go | Pacote testing, da biblioteca padrão | Tabela de casos com t.Run | O erro é um valor de retorno, conferido com errors.Is |
| Java | JUnit 5 | @ParameterizedTest com @CsvSource | assertThrows(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.
| Linguagem | Biblioteca |
|---|---|
| Node | testcontainers e @testcontainers/postgresql |
| Python | testcontainers (PostgresContainer) |
| Go | testcontainers-go, módulo postgres |
| Java | Testcontainers 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:
| Pergunta | Sinal 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]