Testes automatizados com Jest

[115] Testes automatizados com Jest

Mexer num código que estava funcionando e quebrar outra coisa é o problema que os testes resolvem. Aqui o Jest entra do zero: a anatomia de describe, it e expect, os matchers que importam, it.each para vários casos, mocks para isolar dependência, Supertest nas rotas e o que a cobertura não mede.
Javascript

30 min de leitura

Existe um momento na vida de todo desenvolvedor em que ele mexe em um código que "estava funcionando" e quebra algo que nem sabia que existia. A solução para isso não é ter mais cuidado — é ter testes automatizados.

Testes não são burocracia. São a rede de segurança que te permite refatorar com confiança, adicionar funcionalidades sem medo e documentar o comportamento esperado do código de forma executável.

O Jest é o framework de testes mais popular do ecossistema JavaScript — funciona para Node.js, React, TypeScript e praticamente qualquer projeto JS moderno.

Por que testar?

Sem testes                         Com testes
──────────────────────────────     ──────────────────────────────────
"Funciona na minha máquina"        "Funciona em qualquer máquina"
Medo de refatorar                  Refatoração com confiança
Bugs descobertos em produção       Bugs descobertos antes do deploy
Documentação desatualizada         Testes são documentação viva
Dependência de testes manuais      Feedback em segundos

Tipos de teste

┌──────────────────────────────────────────────────────┐
│                    PIRÂMIDE DE TESTES                │
│                                                      │
│                        /                            │
│                       /E2E         Poucos, lentos   │
│                      /──────       (Cypress, etc.)  │
│                     /Integra-                       │
│                    / ção           Médios            │
│                   /────────────                     │
│                  /                                  │
│                 /  Unitários       Muitos, rápidos  │
│                /──────────────────                  │
└──────────────────────────────────────────────────────┘

Unitários    → Testam uma função isolada
Integração   → Testam módulos funcionando juntos
E2E          → Testam o sistema completo como o usuário usa

Neste artigo focamos em testes unitários e de integração com Jest.

Instalando e configurando

npm install -D jest

# Para projetos com ES Modules ou TypeScript:
npm install -D @babel/core @babel/preset-env babel-jest
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "jest": {
    "testEnvironment": "node",
    "coverageDirectory": "coverage",
    "collectCoverageFrom": ["src/**/*.js"],
    "testMatch": ["**/tests/**/*.test.js", "**/*.test.js"]
  }
}

A anatomia de um teste

// soma.js
function soma(a, b) {
  return a + b;
}
module.exports = { soma };

// soma.test.js
const { soma } = require("./soma");

// describe — agrupa testes relacionados
describe("soma()", () => {

  // it / test — um caso de teste
  it("soma dois números positivos", () => {
    // Arrange — prepara
    const a = 3;
    const b = 4;

    // Act — executa
    const resultado = soma(a, b);

    // Assert — verifica
    expect(resultado).toBe(7);
  });

  it("soma números negativos", () => {
    expect(soma(-2, -3)).toBe(-5);
  });

  it("soma zero", () => {
    expect(soma(5, 0)).toBe(5);
  });
});
npx jest soma.test.js

# PASS soma.test.js
#   soma()
#     ✓ soma dois números positivos (2ms)
#     ✓ soma números negativos
#     ✓ soma zero
#
# Test Suites: 1 passed
# Tests:       3 passed

Matchers — o vocabulário dos assertions

// ── Igualdade ──────────────────────────────────────
expect(2 + 2).toBe(4);              // ===  (primitivos)
expect({ a: 1 }).toEqual({ a: 1 }); // igualdade profunda (objetos)
expect({ a: 1 }).not.toBe({ a: 1 });// referências diferentes

// ── Verdade / Falsidade ────────────────────────────
expect(true).toBeTruthy();
expect(0).toBeFalsy();
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect("texto").toBeDefined();

// ── Números ────────────────────────────────────────
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(3).toBeLessThan(5);
expect(3.14).toBeCloseTo(3.1415, 1); // casas decimais

// ── Strings ────────────────────────────────────────
expect("JavaScript").toContain("Script");
expect("hello@email.com").toMatch(/^\S+@\S+\.\S+$/);
expect("Node.js").toMatch("Node");

// ── Arrays ─────────────────────────────────────────
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
expect([1, 2, 3]).toEqual(expect.arrayContaining([1, 3]));

// ── Objetos ────────────────────────────────────────
expect({ nome: "Ana", idade: 28 }).toMatchObject({ nome: "Ana" });
expect({ a: 1 }).toHaveProperty("a");
expect({ a: 1 }).toHaveProperty("a", 1);

// ── Erros ──────────────────────────────────────────
expect(() => JSON.parse("inválido")).toThrow();
expect(() => JSON.parse("inválido")).toThrow(SyntaxError);
expect(() => { throw new Error("ops") }).toThrow("ops");

// ── Async ──────────────────────────────────────────
await expect(Promise.resolve(42)).resolves.toBe(42);
await expect(Promise.reject(new Error("falhou"))).rejects.toThrow("falhou");

Testando funções puras

As mais fáceis de testar — sem dependências externas:

// src/utils/validacoes.js
function validarEmail(email) {
  if (!email || typeof email !== "string") return false;
  return /^\S+@\S+\.\S+$/.test(email.trim());
}

function validarCPF(cpf) {
  const limpo = cpf.replace(/\D/g, "");
  if (limpo.length !== 11) return false;
  if (/^(\d)\1{10}$/.test(limpo)) return false; // todos iguais

  // Algoritmo de validação do CPF
  let soma = 0;
  for (let i = 0; i < 9; i++) soma += Number(limpo[i]) * (10 - i);
  let dig1 = 11 - (soma % 11);
  if (dig1 >= 10) dig1 = 0;
  if (dig1 !== Number(limpo[9])) return false;

  soma = 0;
  for (let i = 0; i < 10; i++) soma += Number(limpo[i]) * (11 - i);
  let dig2 = 11 - (soma % 11);
  if (dig2 >= 10) dig2 = 0;
  return dig2 === Number(limpo[10]);
}

function calcularIdade(dataNascimento) {
  const hoje = new Date();
  const nasc = new Date(dataNascimento);
  let idade = hoje.getFullYear() - nasc.getFullYear();
  const mes = hoje.getMonth() - nasc.getMonth();
  if (mes < 0 || (mes === 0 && hoje.getDate() < nasc.getDate())) {
    idade--;
  }
  return idade;
}

module.exports = { validarEmail, validarCPF, calcularIdade };
// tests/utils/validacoes.test.js
const { validarEmail, validarCPF, calcularIdade } = require("../../src/utils/validacoes");

describe("validarEmail()", () => {
  describe("emails válidos", () => {
    it.each([
      "usuario@email.com",
      "nome.sobrenome@empresa.com.br",
      "user+tag@domain.org",
      "123@numbers.com",
    ])("aceita %s", (email) => {
      expect(validarEmail(email)).toBe(true);
    });
  });

  describe("emails inválidos", () => {
    it.each([
      ["sem arroba", "semArroba.com"],
      ["sem domínio", "usuario@"],
      ["vazio", ""],
      ["null", null],
      ["número", 123],
    ])("%s: %s", (_, email) => {
      expect(validarEmail(email)).toBe(false);
    });
  });
});

describe("validarCPF()", () => {
  it("valida CPF válido com formatação", () => {
    expect(validarCPF("529.982.247-25")).toBe(true);
  });

  it("valida CPF válido sem formatação", () => {
    expect(validarCPF("52998224725")).toBe(true);
  });

  it("rejeita CPF com dígitos repetidos", () => {
    expect(validarCPF("111.111.111-11")).toBe(false);
    expect(validarCPF("00000000000")).toBe(false);
  });

  it("rejeita CPF com tamanho errado", () => {
    expect(validarCPF("123")).toBe(false);
    expect(validarCPF("")).toBe(false);
  });

  it("rejeita CPF com dígito verificador errado", () => {
    expect(validarCPF("529.982.247-26")).toBe(false);
  });
});

// Teste que depende da data de HOJE é teste que quebra sozinho num dia
// qualquer. A saída é congelar o relógio: com o tempo fixo, os dois casos
// viram entradas estáveis e o teste passa em janeiro e em dezembro.
describe("calcularIdade()", () => {
  beforeAll(() => {
    jest.useFakeTimers().setSystemTime(new Date(2025, 5, 15)); // 15/06/2025
  });
  afterAll(() => jest.useRealTimers());

  it("conta aniversário que já passou neste ano", () => {
    // Atenção ao formato: new Date("2000-03-10") é lida como UTC e, em
    // qualquer fuso negativo, cai no dia anterior. Com a hora junto, a
    // leitura passa a ser local — que é o que o teste quer dizer.
    expect(calcularIdade("2000-03-10T00:00")).toBe(25);
  });

  it("não conta aniversário que ainda não chegou", () => {
    expect(calcularIdade("2000-09-10T00:00")).toBe(24);
  });

  it("conta o ano no próprio dia do aniversário", () => {
    expect(calcularIdade("2000-06-15T00:00")).toBe(25);
  });
});

Mocks — isolando dependências

Mocks substituem dependências reais (banco de dados, APIs externas, sistema de arquivos) por versões controladas:

// src/services/emailService.js
const nodemailer = require("nodemailer");

async function enviarEmail({ para, assunto, corpo }) {
  const transporter = nodemailer.createTransport({
    host: process.env.EMAIL_HOST,
    port: 587,
    auth: {
      user: process.env.EMAIL_USER,
      pass: process.env.EMAIL_PASS,
    },
  });

  await transporter.sendMail({
    from: process.env.EMAIL_USER,
    to: para,
    subject: assunto,
    html: corpo,
  });
}

module.exports = { enviarEmail };
// tests/services/emailService.test.js
const nodemailer = require("nodemailer");

// Mock do módulo inteiro
jest.mock("nodemailer");

describe("enviarEmail()", () => {
  let sendMailMock;

  beforeEach(() => {
    // Configura o mock antes de cada teste
    sendMailMock = jest.fn().mockResolvedValue({ messageId: "abc123" });
    nodemailer.createTransport.mockReturnValue({ sendMail: sendMailMock });
  });

  afterEach(() => {
    jest.clearAllMocks(); // limpa chamadas registradas
  });

  it("chama sendMail com os dados corretos", async () => {
    const { enviarEmail } = require("../../src/services/emailService");

    await enviarEmail({
      para: "ana@email.com",
      assunto: "Bem-vinda!",
      corpo: "<h1>Olá Ana</h1>",
    });

    expect(sendMailMock).toHaveBeenCalledTimes(1);
    expect(sendMailMock).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "ana@email.com",
        subject: "Bem-vinda!",
        html: "<h1>Olá Ana</h1>",
      })
    );
  });

  it("propaga erros do transporter", async () => {
    sendMailMock.mockRejectedValue(new Error("SMTP indisponível"));
    const { enviarEmail } = require("../../src/services/emailService");

    await expect(
      enviarEmail({ para: "x@x.com", assunto: "Teste", corpo: "" })
    ).rejects.toThrow("SMTP indisponível");
  });
});

jest.fn() — funções mock

// Criar uma função mock
const minhaFn = jest.fn();

// Definir retorno
const soma = jest.fn().mockReturnValue(10);
const buscar = jest.fn().mockResolvedValue({ id: 1, nome: "Ana" });
const falhar = jest.fn().mockRejectedValue(new Error("falhou"));

// Retornos diferentes em chamadas sequenciais
const iterador = jest.fn()
  .mockReturnValueOnce("primeiro")
  .mockReturnValueOnce("segundo")
  .mockReturnValue("padrão");

// Inspecionar chamadas
minhaFn("arg1", "arg2");
expect(minhaFn).toHaveBeenCalled();
expect(minhaFn).toHaveBeenCalledTimes(1);
expect(minhaFn).toHaveBeenCalledWith("arg1", "arg2");
expect(minhaFn).toHaveBeenLastCalledWith("arg1", "arg2");

// Acessar chamadas diretamente
console.log(minhaFn.mock.calls);     // [[arg1, arg2], ...]
console.log(minhaFn.mock.results);   // [{ type: "return", value: ... }]

Hooks de ciclo de vida

describe("Suite de testes", () => {

  // Executa uma vez antes de todos os testes do describe
  beforeAll(async () => {
    await conectarBancoDeTeste();
  });

  // Executa uma vez após todos os testes do describe
  afterAll(async () => {
    await desconectarBancoDeTeste();
  });

  // Executa antes de CADA teste
  beforeEach(() => {
    jest.clearAllMocks();
    estadoInicial = criarEstadoInicial();
  });

  // Executa após CADA teste
  afterEach(async () => {
    await limparBancoDeTeste();
  });

  it("teste 1", () => { /* ... */ });
  it("teste 2", () => { /* ... */ });
});

Testando a API com Supertest

O Supertest permite testar rotas HTTP sem subir um servidor real:

npm install -D supertest
// tests/integration/auth.test.js
const request = require("supertest");
const mongoose = require("mongoose");
const app = require("../../src/index");
const Usuario = require("../../src/models/Usuario");

// Conecta ao banco de teste antes de tudo
beforeAll(async () => {
  await mongoose.connect(process.env.MONGODB_TEST_URL ||
    "mongodb://localhost:27017/tarefas-test");
});

// Limpa a coleção antes de cada teste
beforeEach(async () => {
  await Usuario.deleteMany({});
});

// Desconecta após todos os testes
afterAll(async () => {
  await mongoose.connection.close();
});

describe("POST /auth/registrar", () => {
  const dadosValidos = {
    nome: "Ana Paula",
    email: "ana@email.com",
    senha: "senha123",
  };

  it("registra um novo usuário com sucesso", async () => {
    const res = await request(app)
      .post("/auth/registrar")
      .send(dadosValidos);

    expect(res.status).toBe(201);
    expect(res.body).toHaveProperty("token");
    expect(res.body.usuario).toMatchObject({
      nome: "Ana Paula",
      email: "ana@email.com",
    });
    expect(res.body.usuario).not.toHaveProperty("senha");
  });

  it("rejeita email duplicado", async () => {
    await request(app).post("/auth/registrar").send(dadosValidos);

    const res = await request(app)
      .post("/auth/registrar")
      .send(dadosValidos);

    expect(res.status).toBe(409);
    expect(res.body.erro).toMatch(/já cadastrado/i);
  });

  it("rejeita nome ausente", async () => {
    const res = await request(app)
      .post("/auth/registrar")
      .send({ email: "ana@email.com", senha: "123456" });

    expect(res.status).toBe(422);
  });

  it("rejeita senha curta", async () => {
    const res = await request(app)
      .post("/auth/registrar")
      .send({ nome: "Ana", email: "ana@email.com", senha: "123" });

    expect(res.status).toBe(422);
  });
});

describe("POST /auth/login", () => {
  beforeEach(async () => {
    await request(app).post("/auth/registrar").send({
      nome: "Ana Paula",
      email: "ana@email.com",
      senha: "senha123",
    });
  });

  it("faz login com credenciais corretas", async () => {
    const res = await request(app)
      .post("/auth/login")
      .send({ email: "ana@email.com", senha: "senha123" });

    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty("token");
    expect(typeof res.body.token).toBe("string");
  });

  it("rejeita senha incorreta", async () => {
    const res = await request(app)
      .post("/auth/login")
      .send({ email: "ana@email.com", senha: "errada" });

    expect(res.status).toBe(401);
    expect(res.body.erro).toMatch(/credenciais inválidas/i);
  });

  it("rejeita email não cadastrado", async () => {
    const res = await request(app)
      .post("/auth/login")
      .send({ email: "naoexiste@email.com", senha: "senha123" });

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

describe("GET /tarefas (rota protegida)", () => {
  // Sem este beforeEach o segundo teste falharia: o deleteMany global apaga
  // os usuários antes de cada caso, e quem criava a Ana era o beforeEach do
  // describe do login — que não vale aqui. Cada bloco monta o próprio cenário.
  beforeEach(async () => {
    await request(app).post("/auth/registrar").send({
      nome: "Ana Paula",
      email: "ana@email.com",
      senha: "senha123",
    });
  });

  it("rejeita requisição sem token", async () => {
    const res = await request(app).get("/tarefas");
    expect(res.status).toBe(401);
  });

  it("aceita requisição com token válido", async () => {
    const loginRes = await request(app)
      .post("/auth/login")
      .send({ email: "ana@email.com", senha: "senha123" });

    // Usa o token na requisição protegida
    const res = await request(app)
      .get("/tarefas")
      .set("Authorization", `Bearer ${loginRes.body.token}`);

    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty("dados");
    expect(Array.isArray(res.body.dados)).toBe(true);
  });
});

Cobertura de código

npm run test:coverage

# ----------------------|---------|----------|---------|---------|
# File                  | % Stmts | % Branch | % Funcs | % Lines |
# ----------------------|---------|----------|---------|---------|
# All files             |   87.5  |    82.3  |   91.2  |   87.1  |
#  controllers/         |         |          |         |         |
#   authController.js   |   92.3  |    88.9  |   100   |   92.0  |
#   tarefaController.js |   84.2  |    77.8  |   85.7  |   83.9  |
#  utils/               |         |          |         |         |
#   validacoes.js        |   100   |    100   |   100   |   100   |
# ----------------------|---------|----------|---------|---------|
// package.json — definir meta de cobertura mínima
{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 70,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

Boas práticas com Jest

// ✅ 1. Nomeie testes de forma descritiva
// ❌ it("funciona", ...)
// ✅ it("retorna 404 quando o usuário não existe", ...)

// ✅ 2. Um assert por teste (idealmente)
// Facilita identificar exatamente o que falhou

// ✅ 3. Use describe para agrupar casos relacionados
// Cada arquivo de teste → um módulo
// Cada describe → um comportamento
// Cada it → um cenário

// ✅ 4. Siga o padrão AAA
// Arrange — Prepara os dados
// Act     — Executa a função
// Assert  — Verifica o resultado

// ✅ 5. Teste os casos de borda
// Valores nulos, vazios, negativos, limites
// Não apenas o "caminho feliz"

// ✅ 6. Mantenha testes rápidos e independentes
// Cada teste deve poder rodar isoladamente
// Sem dependência de ordem de execução

// ✅ 7. Use factories para criar dados de teste
function criarUsuario(overrides = {}) {
  return {
    nome: "Ana Paula",
    email: "ana@email.com",
    senha: "senha123",
    ...overrides,
  };
}

// ✅ 8. Não teste implementação — teste comportamento
// ❌ Verificar se uma variável interna mudou
// ✅ Verificar o resultado retornado ou o efeito colateral

Tarefa para você

Escreva testes para o sistema de tarefas do artigo Projeto: API REST com autenticação JWT:

// 1. Testes unitários para uma função utilitária:
//    gerarSlug("Meu Post Incrível!") → "meu-post-incrivel"
//    Casos: acentos, caracteres especiais, múltiplos espaços, vazio

// 2. Testes de integração para POST /tarefas:
//    - Cria tarefa com sucesso (201)
//    - Rejeita tarefa sem título (422)
//    - Rejeita requisição sem token (401)
//    - Cria tarefa com prazo e verifica campo "atrasada"

// 3. Testes para GET /tarefas:
//    - Retorna apenas tarefas do usuário logado
//    - Filtro por status funciona
//    - Paginação funciona (pagina=1&por_pagina=2)
//    - Usuário A não vê tarefas do Usuário B

// 4. Mock de serviço externo:
//    Simule um serviço de notificação que é chamado
//    quando uma tarefa é criada. Verifique que:
//    - Foi chamado uma vez
//    - Recebeu o ID da tarefa criada
//    - Se falhar, a tarefa ainda é criada (log de erro)
Ver solução — a suíte completa — unitários, integração e mock do serviço externo
// ---- package.json
// {
//   "scripts": {
//     "test": "jest",
//     "test:watch": "jest --watch",
//     "test:cov": "jest --coverage"
//   },
//   "devDependencies": {
//     "jest": "^30.0.0",
//     "supertest": "^7.0.0",
//     "mongodb-memory-server": "^10.0.0"
//   }
// }

// ---- jest.config.js
module.exports = {
  testEnvironment: "node",
  setupFilesAfterEnv: ["<rootDir>/testes/setup.js"],
  testMatch: ["<rootDir>/testes/**/*.test.js"],
  collectCoverageFrom: ["src/**/*.js", "!src/index.js"],
  // O primeiro teste sobe o mongod em memória; 5s (o padrão) não bastam.
  testTimeout: 30000,
};

// ---- src/app.js
// PRÉ-REQUISITO DA SUÍTE: separar o app do servidor.
//
// No projeto de API REST com JWT, o src/index.js chama conectar() e
// app.listen() no topo do módulo.
// Um `require("./src/index")` dentro do teste abriria uma porta e uma conexão
// com o Mongo de verdade — o Jest terminaria reclamando de handle aberto.
// A regra: `app.js` monta e exporta; `index.js` conecta e escuta.
const express = require("express");
const { naoEncontrado, tratadorDeErros } = require("./middlewares/erros");
const tarefasRoutes = require("./routes/tarefas");

const app = express();
app.use(express.json());
app.use("/tarefas", tarefasRoutes);
app.use(naoEncontrado);
app.use(tratadorDeErros);

module.exports = app;

// ---- testes/setup.js
const mongoose = require("mongoose");
const { MongoMemoryServer } = require("mongodb-memory-server");

process.env.JWT_SECRET = "segredo-de-teste-nao-usar-em-producao";
process.env.JWT_EXPIRA_EM = "1h";
process.env.NOTIFICACOES_URL = "http://notificacoes.teste";

let servidor;

beforeAll(async () => {
  servidor = await MongoMemoryServer.create();
  await mongoose.connect(servidor.getUri());
});

// Limpar entre testes, e não recriar o banco: subir o mongod é o caro.
afterEach(async () => {
  const colecoes = await mongoose.connection.db.collections();
  for (const colecao of colecoes) await colecao.deleteMany({});
});

afterAll(async () => {
  await mongoose.disconnect();
  await servidor.stop();
});

// ---- testes/ajudantes.js
const jwt = require("jsonwebtoken");
const Usuario = require("../src/models/Usuario");

async function criarUsuario(dados = {}) {
  return Usuario.create({
    nome: dados.nome || "Ana Souza",
    email: dados.email || "ana@teste.com",
    senha: dados.senha || "senha-secreta",
  });
}

// Token assinado direto, sem passar por POST /auth/login: o teste de tarefas
// não deve quebrar quando o login mudar.
async function usuarioComToken(dados = {}) {
  const usuario = await criarUsuario(dados);
  const token = jwt.sign({ id: usuario._id }, process.env.JWT_SECRET, { expiresIn: "1h" });
  return { usuario, token, auth: `Bearer ${token}` };
}

module.exports = { criarUsuario, usuarioComToken };

// ---- src/utils/slug.js
function gerarSlug(texto) {
  if (typeof texto !== "string") return "";

  return texto
    .normalize("NFD")                    // separa a letra do acento
    .replace(/[\u0300-\u036f]/g, "")   // remove os acentos, agora soltos
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")         // tudo que não é letra/número vira hífen
    .replace(/^-+|-+$/g, "");            // apara os hífens das pontas
}

module.exports = { gerarSlug };

// ---- testes/unitarios/slug.test.js
// 1 — TESTES UNITÁRIOS DA FUNÇÃO UTILITÁRIA
const { gerarSlug } = require("../../src/utils/slug");

describe("gerarSlug", () => {
  it("converte o caso do enunciado", () => {
    expect(gerarSlug("Meu Post Incrível!")).toBe("meu-post-incrivel");
  });

  it.each([
    ["Ação e Reação", "acao-e-reacao"],
    ["Configuração de CI/CD", "configuracao-de-ci-cd"],
    ["Você já usou map()?", "voce-ja-usou-map"],
  ])("remove acentos de %s", (entrada, esperado) => {
    expect(gerarSlug(entrada)).toBe(esperado);
  });

  it("colapsa espaços múltiplos em um único hífen", () => {
    expect(gerarSlug("Node    e     Express")).toBe("node-e-express");
  });

  it("não deixa hífen nas pontas", () => {
    expect(gerarSlug("  --- Olá, mundo! --- ")).toBe("ola-mundo");
  });

  it.each([["", ""], ["!!!", ""], ["   ", ""]])("devolve vazio para %p", (entrada, esperado) => {
    expect(gerarSlug(entrada)).toBe(esperado);
  });

  // O caso que ninguém escreve e que sempre acontece em produção.
  it("não quebra com entrada que não é string", () => {
    expect(gerarSlug(null)).toBe("");
    expect(gerarSlug(undefined)).toBe("");
    expect(gerarSlug(42)).toBe("");
  });
});

// ---- testes/integracao/tarefas.post.test.js
// 2 — TESTES DE INTEGRAÇÃO PARA POST /tarefas
const request = require("supertest");
const app = require("../../src/app");
const Tarefa = require("../../src/models/Tarefa");
const { usuarioComToken } = require("../ajudantes");

// O serviço externo não pode ser chamado de verdade nem aqui: um teste que
// depende de rede não é teste, é aposta.
jest.mock("../../src/servicos/notificacao", () => ({
  notificarTarefaCriada: jest.fn().mockResolvedValue({ ok: true }),
}));

describe("POST /tarefas", () => {
  it("cria a tarefa e devolve 201", async () => {
    const { auth, usuario } = await usuarioComToken();

    const resposta = await request(app)
      .post("/tarefas")
      .set("Authorization", auth)
      .send({ titulo: "Estudar Jest", prioridade: "alta" });

    expect(resposta.status).toBe(201);
    expect(resposta.body).toMatchObject({
      titulo: "Estudar Jest",
      prioridade: "alta",
      status: "pendente",
    });

    // Conferir no banco, não só na resposta: 201 com gravação errada existe.
    const gravada = await Tarefa.findById(resposta.body._id);
    expect(gravada.usuario.toString()).toBe(usuario._id.toString());
  });

  it("rejeita tarefa sem título com 422", async () => {
    const { auth } = await usuarioComToken();

    const resposta = await request(app)
      .post("/tarefas")
      .set("Authorization", auth)
      .send({ descricao: "sem título" });

    expect(resposta.status).toBe(422);
    expect(resposta.body.detalhes).toContain("Título é obrigatório.");
    expect(await Tarefa.countDocuments()).toBe(0);
  });

  it("rejeita requisição sem token com 401", async () => {
    const resposta = await request(app).post("/tarefas").send({ titulo: "Sem token" });

    expect(resposta.status).toBe(401);
    expect(resposta.body.erro).toMatch(/ausente/i);
  });

  it("marca como atrasada a tarefa com prazo vencido", async () => {
    const { auth } = await usuarioComToken();
    const ontem = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();

    const resposta = await request(app)
      .post("/tarefas")
      .set("Authorization", auth)
      .send({ titulo: "Entregar relatório", prazo: ontem });

    expect(resposta.status).toBe(201);
    expect(resposta.body.atrasada).toBe(true);
  });

  it("não marca como atrasada a tarefa com prazo futuro", async () => {
    const { auth } = await usuarioComToken();
    const amanha = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();

    const resposta = await request(app)
      .post("/tarefas")
      .set("Authorization", auth)
      .send({ titulo: "Planejar sprint", prazo: amanha });

    expect(resposta.body.atrasada).toBe(false);
  });
});

// ---- testes/integracao/tarefas.get.test.js
// 3 — TESTES PARA GET /tarefas
const request = require("supertest");
const app = require("../../src/app");
const Tarefa = require("../../src/models/Tarefa");
const { usuarioComToken } = require("../ajudantes");

jest.mock("../../src/servicos/notificacao", () => ({
  notificarTarefaCriada: jest.fn().mockResolvedValue({ ok: true }),
}));

async function semear(usuarioId, tarefas) {
  for (const [titulo, status] of tarefas) {
    await Tarefa.create({ titulo, status, usuario: usuarioId });
  }
}

describe("GET /tarefas", () => {
  it("devolve apenas as tarefas do usuário logado", async () => {
    const ana = await usuarioComToken({ email: "ana@teste.com" });
    const bruno = await usuarioComToken({ email: "bruno@teste.com" });

    await semear(ana.usuario._id, [["Tarefa da Ana", "pendente"]]);
    await semear(bruno.usuario._id, [["Tarefa do Bruno", "pendente"]]);

    const resposta = await request(app).get("/tarefas").set("Authorization", ana.auth);

    expect(resposta.status).toBe(200);
    expect(resposta.body.dados).toHaveLength(1);
    expect(resposta.body.dados[0].titulo).toBe("Tarefa da Ana");
  });

  it("filtra por status", async () => {
    const { auth, usuario } = await usuarioComToken();
    await semear(usuario._id, [
      ["Uma", "pendente"],
      ["Duas", "concluida"],
      ["Três", "concluida"],
    ]);

    const resposta = await request(app)
      .get("/tarefas")
      .query({ status: "concluida" })
      .set("Authorization", auth);

    expect(resposta.body.paginacao.total).toBe(2);
    expect(resposta.body.dados.every((t) => t.status === "concluida")).toBe(true);
  });

  it("pagina o resultado", async () => {
    const { auth, usuario } = await usuarioComToken();
    await semear(usuario._id, [["Uma", "pendente"], ["Duas", "pendente"], ["Três", "pendente"]]);

    // `ordenar: createdAt` fixa a ordem. Sem isso, dois documentos criados no
    // mesmo milissegundo podem trocar de página entre uma execução e outra —
    // e o teste falha uma vez a cada vinte, que é pior que falhar sempre.
    const pagina1 = await request(app)
      .get("/tarefas")
      .query({ pagina: 1, por_pagina: 2, ordenar: "createdAt" })
      .set("Authorization", auth);

    const pagina2 = await request(app)
      .get("/tarefas")
      .query({ pagina: 2, por_pagina: 2, ordenar: "createdAt" })
      .set("Authorization", auth);

    expect(pagina1.body.dados).toHaveLength(2);
    expect(pagina2.body.dados).toHaveLength(1);
    expect(pagina1.body.paginacao.total_paginas).toBe(2);

    // A garantia que importa: nenhuma tarefa aparece nas duas páginas.
    const ids = [...pagina1.body.dados, ...pagina2.body.dados].map((t) => t._id);
    expect(new Set(ids).size).toBe(3);
  });

  it("não deixa o usuário A ver a tarefa do usuário B pelo id direto", async () => {
    const ana = await usuarioComToken({ email: "ana@teste.com" });
    const bruno = await usuarioComToken({ email: "bruno@teste.com" });

    const doBruno = await Tarefa.create({ titulo: "Segredo", usuario: bruno.usuario._id });

    const resposta = await request(app)
      .get(`/tarefas/${doBruno._id}`)
      .set("Authorization", ana.auth);

    // 404, não 403: responder 403 confirmaria que o id existe.
    expect(resposta.status).toBe(404);
  });
});

// ---- src/servicos/notificacao.js
async function notificarTarefaCriada(tarefaId, usuarioId) {
  const resposta = await fetch(`${process.env.NOTIFICACOES_URL}/eventos`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ tipo: "tarefa.criada", tarefaId, usuarioId }),
  });

  if (!resposta.ok) throw new Error(`Serviço de notificação: HTTP ${resposta.status}`);
  return resposta.json();
}

module.exports = { notificarTarefaCriada };

// ---- src/controllers/tarefaController.js
// O trecho de `criar` que o teste 4 cobre. A notificação vai em try/catch
// próprio: ela é efeito colateral, não parte da transação. Se o serviço cair,
// a tarefa ainda tem de ser criada.
async function criar(req, res, next) {
  try {
    const { titulo, descricao, status, prioridade, prazo, tags } = req.body;

    const tarefa = await Tarefa.create({
      titulo,
      descricao,
      status,
      prioridade,
      prazo: prazo ? new Date(prazo) : null,
      tags,
      usuario: req.usuario._id,
    });

    try {
      await notificarTarefaCriada(tarefa._id.toString(), req.usuario._id.toString());
    } catch (erro) {
      console.error(`[notificacao] falhou para a tarefa ${tarefa._id}: ${erro.message}`);
    }

    res.status(201).json(tarefa);
  } catch (erro) {
    next(erro);
  }
}

// ---- testes/integracao/notificacao.test.js
// 4 — MOCK DO SERVIÇO EXTERNO
const request = require("supertest");
const app = require("../../src/app");
const Tarefa = require("../../src/models/Tarefa");
const { usuarioComToken } = require("../ajudantes");
const { notificarTarefaCriada } = require("../../src/servicos/notificacao");

// Sem fábrica: o jest.mock automático troca cada função exportada por um
// jest.fn(). Assim o próprio teste decide, caso a caso, se resolve ou rejeita.
jest.mock("../../src/servicos/notificacao");

describe("notificação ao criar tarefa", () => {
  beforeEach(() => {
    jest.clearAllMocks();
    notificarTarefaCriada.mockResolvedValue({ ok: true });
  });

  it("chama o serviço uma vez, com o id da tarefa criada", async () => {
    const { auth, usuario } = await usuarioComToken();

    const resposta = await request(app)
      .post("/tarefas")
      .set("Authorization", auth)
      .send({ titulo: "Avisar o time" });

    expect(notificarTarefaCriada).toHaveBeenCalledTimes(1);
    expect(notificarTarefaCriada).toHaveBeenCalledWith(
      resposta.body._id,
      usuario._id.toString()
    );
  });

  it("cria a tarefa mesmo quando a notificação falha", async () => {
    notificarTarefaCriada.mockRejectedValue(new Error("Serviço fora do ar"));
    const erroNoLog = jest.spyOn(console, "error").mockImplementation(() => {});

    const { auth } = await usuarioComToken();
    const resposta = await request(app)
      .post("/tarefas")
      .set("Authorization", auth)
      .send({ titulo: "Sobrevive à falha" });

    expect(resposta.status).toBe(201);
    expect(await Tarefa.countDocuments()).toBe(1);
    expect(erroNoLog).toHaveBeenCalledWith(expect.stringContaining("[notificacao]"));

    erroNoLog.mockRestore();
  });
});

// ---------------------------------------------------------------
// A saída real desta suíte
// ---------------------------------------------------------------
//
//   Test Suites: 4 passed, 4 total
//   Tests:       21 passed, 21 total
//   Time:        2.87 s
//
// Só que a primeira execução foi assim:
//
//   ● POST /tarefas › cria a tarefa e devolve 201
//     TypeError: next is not a function
//       at model.next (src/models/Usuario.js:20:3)
//
// O erro não estava no teste: estava no `pre("save")` do modelo daquele projeto.
//
//   usuarioSchema.pre("save", async function (next) {   // quebra no Mongoose 9
//     if (!this.isModified("senha")) return next();
//     this.senha = await bcrypt.hash(this.senha, 12);
//     next();
//   });
//
// Em hook `async`, o Mongoose 8 ainda passava `next` como primeiro argumento;
// o Mongoose 9 passa um objeto de opções. Chamar `next()` virou chamar um
// objeto. A forma que funciona nas duas versões é não declarar o parâmetro —
// numa função async, `return` já sinaliza o fim:
//
//   usuarioSchema.pre("save", async function () {
//     if (!this.isModified("senha")) return;
//     this.senha = await bcrypt.hash(this.senha, 12);
//   });
//
// Foi o teste de integração que achou. Rodando a API à mão você veria o mesmo
// erro — depois de subir o Mongo, criar um usuário e ler o stack trace.

O teste que passa sem testar nada é o pior resultado possível. Três hábitos evitam isso: conferir no banco, e não só no corpo da resposta (um 201 com gravação errada existe); fixar a ordenação em teste de paginação, senão dois documentos criados no mesmo milissegundo trocam de página e o teste falha uma vez a cada vinte; e responder 404, não 403, quando o recurso é de outro usuário — 403 confirma que o id existe, e essa confirmação já é vazamento.

Teste não prova que o código está certo; prova que ele continua fazendo o que já fazia. Esse é o valor real, e ele só aparece na segunda vez — no dia da refatoração, do upgrade de dependência, do bug que voltou. Daí a ordem de prioridade ser sempre a mesma: cobrir primeiro função pura e regra de negócio, que são baratas de testar e caras de quebrar, e deixar o resto para quando houver folga.

Fontes e Referências

Exercícios

Exercício 1

Este teste passou em março e começou a falhar em novembro, sem que ninguém tocasse no código. O que aconteceu?

it("considera aniversário que ainda não ocorreu este ano", () => {
  const hoje = new Date();
  const anoNasc = hoje.getFullYear() - 30;

  // Usa mês futuro para garantir que o aniversário não ocorreu
  const mesFuturo = ((hoje.getMonth() + 2) % 12) + 1;
  const dataNasc = `${anoNasc}-${String(mesFuturo).padStart(2, "0")}-01`;

  expect(calcularIdade(dataNasc)).toBe(29);
});
Ver resposta

✓ Resposta: O % 12 dá a volta no calendário. Em novembro, getMonth() é 10, e a conta produz ((10 + 2) % 12) + 1, que é 1 — janeiro, um mês que já passou. O aniversário então já ocorreu, a idade calculada é 30, e a expectativa de 29 falha. Em dezembro dá fevereiro, mesmo problema. O comentário promete "mês futuro" e o código entrega um mês do passado durante dois meses do ano. Há um segundo defeito, mais silencioso, na mesma linha: new Date("2000-01-01") — data pura, sem hora — é interpretada como UTC, então em qualquer fuso a oeste de Greenwich ela vira 31 de dezembro do ano anterior, e a idade sai um ano maior. Esse aparece em janeiro. A causa de fundo é a mesma nos dois casos: o teste depende de quando é executado. Teste bom tem entrada fixa e saída fixa. A correção é congelar o relógio — jest.useFakeTimers().setSystemTime(new Date(2025, 5, 15)) — e usar datas literais, o que transforma os dois casos em entradas estáveis e permite ainda testar o caso de borda mais difícil, que é o aniversário exatamente hoje. E vale a regra geral: todo teste que chama new Date(), Date.now() ou Math.random() é um teste que vai falhar sozinho algum dia.

Exercício 2

Os dois testes passam. Ainda assim, um revisor pediria correção. Por quê?

it("valida CPF válido sem formatação", () => {
  expect(validarCPF("529.982.247-25")).toBe(true);
});

it("valida CPF válido com formatação", () => {
  expect(validarCPF("52998224725")).toBe(true);
});
Ver resposta

✓ Resposta: Os títulos estão trocados. O primeiro diz "sem formatação" e passa um CPF com pontos e traço; o segundo diz "com formatação" e passa a string crua. Como os dois casos funcionam, a suíte fica verde e o erro sobrevive indefinidamente. O problema não é estético: o nome do teste é o que aparece no relatório quando ele falha, e é a única documentação que a maioria das pessoas lê. No dia em que a limpeza de caracteres quebrar, o relatório vai dizer "valida CPF válido sem formatação" enquanto o caso que falhou é justamente o formatado — e o primeiro palpite de quem for consertar já nasce errado. É por isso que a boa prática de nomear testes descritivamente, que o próprio artigo lista, só vale se o nome for verdadeiro: um nome errado é pior que um nome genérico, porque induz. Vale notar que o it.each, usado logo acima no mesmo arquivo para os emails, elimina a classe inteira desse problema — com o valor interpolado no título, não há como descrever um caso e testar outro.

Exercício 3

Rodando o arquivo inteiro, este teste passa. Rodando só ele, com -t "aceita requisição com token válido", ele falha com 401. O que explica a diferença?

beforeEach(async () => { await Usuario.deleteMany({}); });

describe("POST /auth/login", () => {
  beforeEach(async () => {
    await request(app).post("/auth/registrar").send({
      nome: "Ana Paula", email: "ana@email.com", senha: "senha123",
    });
  });
  // ... testes de login
});

describe("GET /tarefas (rota protegida)", () => {
  it("aceita requisição com token válido", async () => {
    const loginRes = await request(app)
      .post("/auth/login")
      .send({ email: "ana@email.com", senha: "senha123" });

    const res = await request(app)
      .get("/tarefas")
      .set("Authorization", `Bearer ${loginRes.body.token}`);

    expect(res.status).toBe(200);
  });
});
Ver resposta

✓ Resposta: Na verdade ele falha nos dois casos — e a pergunta contém a armadilha. O beforeEach que cria a Ana está dentro do describe do login e só vale para os testes daquele bloco; o deleteMany global roda antes de cada teste e limpa tudo. Quando o bloco da rota protegida executa, não há usuário nenhum: o login devolve 401, loginRes.body.token é undefined, o cabeçalho vai como Bearer undefined e a resposta é 401 em vez de 200. O que torna esse defeito instrutivo é o tipo de dependência que ele revela — o teste só teria chance de passar se um bloco anterior deixasse estado para trás, que é exatamente o que o deleteMany existe para impedir. É o artigo contradizendo a própria lista de boas práticas, onde se lê "cada teste deve poder rodar isoladamente, sem dependência de ordem de execução". A correção é cada describe montar o próprio cenário, com um beforeEach que registre o usuário que ele vai usar. Duas práticas evitam essa classe inteira de problema: rodar a suíte com --randomize, que embaralha a ordem e expõe dependências escondidas, e usar uma factory — a função criarUsuario que o próprio artigo sugere — para que montar o cenário custe uma linha e ninguém tenha preguiça de repeti-la.

Exercício 4

Um colega diz que o projeto está seguro porque a cobertura é de 100%. Este é o código e o teste. O que a métrica não viu?

function calcularDesconto(valor, percentual) {
  return valor - (valor * percentual) / 100;
}

it("calcula desconto", () => {
  expect(calcularDesconto(100, 10)).toBe(90);
});
Ver resposta

✓ Resposta: A cobertura mede quais linhas foram executadas, não quais comportamentos foram verificados. A função tem uma linha só; um teste a percorre; o relatório mostra 100% em statements, branches, functions e lines. E ficam de fora, sem que a métrica reclame: percentual negativo, que aumenta o valor; percentual acima de 100, que devolve um total negativo; valor ou percentual como string, em que "100" - ... ainda funciona por coerção enquanto valor * percentual pode produzir NaN; e o arredondamento, já que calcularDesconto(0.1, 10) devolve 0.09000000000000001 por causa do ponto flutuante — o que em dinheiro é problema real. Nada disso é coberto, e a cobertura continua em 100%. A conclusão prática é que cobertura é um detector de ausência, não uma prova de presença: 40% dizem com segurança que falta teste, mas 100% não dizem que está tudo testado. Por isso a meta do artigo — 70% a 80% — é um piso razoável, e subir a régua para 100% costuma produzir testes escritos para agradar a ferramenta, do tipo que chama a função e não verifica nada. O que substitui a métrica é olhar para os casos de borda: nulo, vazio, negativo, zero, limite e tipo errado.

Exercício 5

A suíte de integração passa, mas o Jest não encerra: fica pendurado com Jest did not exit one second after the test run has completed. Onde está o problema?

// src/index.js — o arquivo importado pelo teste
const app = express();
app.use("/auth", authRoutes);

conectar().then(() => {
  app.listen(PORTA, () => console.log(`API em http://localhost:${PORTA}`));
});

module.exports = app;

// tests/integration/auth.test.js
const app = require("../../src/index");

beforeAll(async () => { await mongoose.connect(URL_DE_TESTE); });
afterAll(async () => { await mongoose.connection.close(); });
Ver resposta

✓ Resposta: Porque importar o src/index.js executa tudo o que está no topo dele — e ali estão duas coisas que não deviam acontecer num teste: a conexão com o banco de produção, via conectar(), e o app.listen(), que abre uma porta TCP. O Supertest não precisa de porta nenhuma: ele sobe um servidor efêmero a partir do objeto app. O socket aberto é um recurso vivo que o Node não fecha sozinho, e é ele que segura o processo — daí a mensagem sobre o Jest não encerrar. Pior: há duas conexões com o Mongo abertas ao mesmo tempo, a do conectar() e a do beforeAll, e a segunda pode nem ser a que o app usa, o que faz o deleteMany limpar um banco e os testes lerem outro. A correção é estrutural e vale para qualquer projeto Express: separar o app do servidor. O app.js monta rotas e middlewares e exporta o app, sem conectar e sem escutar; o index.js importa o app, conecta ao banco e chama listen. O teste importa o app.js e controla a conexão por conta própria. É por isso que a lista de boas práticas do artigo anterior já trazia "separe app e server para facilitar testes" — aqui está o motivo concreto. Em último caso, --detectOpenHandles mostra qual recurso ficou aberto.

Comentários

Mais em Javascript

Revisão + Projeto Final: Produção Real
Revisão + Projeto Final: Produção Real

Deploy, segurança e performance só existem depois que a aplicação existe, e…

NPM: gerenciando pacotes e dependências
NPM: gerenciando pacotes e dependências

Todo projeto Node começa com um package.json de quinze linhas e termina com um…

Mini Projeto: App de Clima Completo
Mini Projeto: App de Clima Completo

O Módulo 3 fecha juntando tudo num app de clima: geolocalização do navegador…