Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIntermediáriocódigo testado

Testar rota de API com node:test e supertest, sem Jest

Teste de rota com o runner que já vem no Node: separar app de servidor, checar status, corpo e header com supertest e medir cobertura sem instalar Jest.

Rodolfo Mori13 min de leitura

Testar uma rota é fazer uma requisição de verdade contra o seu app e conferir o que voltou: status, corpo e header. No Node 24 isso não exige biblioteca de teste nenhuma — o runner já vem instalado, e o único pacote que entra é o supertest, para falar HTTP com o app sem ocupar porta.

Os exemplos são todos da API do Pet Feliz, um petshop de bairro que marca banho e tosa. A rota principal é /agendamentos, e ela nasceu lá na lição do Express do zero.

Teste é a checklist antes de abrir a loja

Antes de um petshop abrir, alguém confere se a porta destranca, se a agenda aceita horário e se um serviço indisponível é recusado. Ninguém considera a loja pronta porque “ontem funcionou”. Um teste de rota faz essa conferência de forma repetível: envia a requisição, observa status, headers e corpo e compara com o contrato esperado.

O termo técnico aqui é teste de integração, porque várias peças trabalham juntas — roteador, middleware e resposta HTTP. Ele não prova que todo o sistema está perfeito; prova exatamente os cenários que você escreveu. Antes de criar a suíte, escreva em português uma regra: “POST válido responde 201 e devolve o agendamento”. Essa frase vira uma requisição e três verificações. Se você não consegue dizer o comportamento sem código, ainda não sabe o que o teste deve proteger.

O runner que já vem instalado: node --test

Não instale nada. Crie um arquivo terminado em .test.js e escreva uma função com test. Comece pela regra de negócio mais simples do petshop: a tabela de preço por serviço e porte.

js
// src/precos.js
const TABELA = {
  banho: { pequeno: 45, medio: 60, grande: 85 },
  tosa: { pequeno: 70, medio: 90, grande: 120 },
};

export function precoDoServico(servico, porte) {
  const linha = TABELA[servico];
  if (!linha) throw new Error(`Serviço desconhecido: ${servico}`);
  const preco = linha[porte];
  if (preco === undefined) throw new Error(`Porte desconhecido: ${porte}`);
  return preco;
}
js
// test/precos.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { precoDoServico } from '../src/precos.js';

test('banho de cachorro pequeno custa 45', () => {
  assert.equal(precoDoServico('banho', 'pequeno'), 45);
});

test('tosa de cachorro grande custa 120', () => {
  assert.equal(precoDoServico('tosa', 'grande'), 120);
});
bash
node --test
✔ banho de cachorro pequeno custa 45 (0.299417ms) ✔ tosa de cachorro grande custa 120 (0.050083ms) ℹ tests 2 ℹ suites 0 ℹ pass 2 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 47.41975

Repare no rodapé: tests, pass, fail, duration_ms. É esse bloco que o servidor de integração contínua lê, e é o mesmo que aparece quando a suíte tem quatrocentos testes.

Agora quebre de propósito — troque o 45 esperado por 50 — para ver o que o runner mostra quando falha:

js
test('banho de cachorro pequeno custa 45', () => {
  assert.equal(precoDoServico('banho', 'pequeno'), 50);
});
✖ banho de cachorro pequeno custa 45 (0.552459ms) ℹ tests 1 ℹ pass 0 ℹ fail 1

✖ failing tests:

test at test/precos.test.js:5:1 ✖ banho de cachorro pequeno custa 45 (0.552459ms) AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

45 !== 50

plaintext
  at TestContext.<anonymous> (file:///private/tmp/petfeliz/test/precos.test.js:6:10)
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 45,
expected: 50,
operator: 'strictEqual',
diff: 'simple'

}

O relatório dá o arquivo, a linha, o valor real e o esperado. É tudo que você precisa para corrigir sem abrir o depurador.

describe, it e node:assert/strict: o vocabulário inteiro

describe agrupa, it é o mesmo que test com outro nome. Quem vem do Jest ou do Mocha não muda uma vírgula da estrutura mental:

js
// test/precos.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { precoDoServico } from '../src/precos.js';

describe('precoDoServico', () => {
  it('cobra 45 pelo banho de cachorro pequeno', () => {
    assert.equal(precoDoServico('banho', 'pequeno'), 45);
  });

  it('cobra 120 pela tosa de cachorro grande', () => {
    assert.equal(precoDoServico('tosa', 'grande'), 120);
  });

  it('recusa serviço fora da tabela', () => {
    assert.throws(() => precoDoServico('hidratacao', 'medio'), {
      message: 'Serviço desconhecido: hidratacao',
    });
  });
});
▶ precoDoServico ✔ cobra 45 pelo banho de cachorro pequeno (0.256208ms) ✔ cobra 120 pela tosa de cachorro grande (0.04825ms) ✔ recusa serviço fora da tabela (0.478333ms) ✔ precoDoServico (1.186083ms) ℹ tests 3 ℹ suites 1 ℹ pass 3 ℹ fail 0

Cinco funções de node:assert resolvem quase toda a suíte de uma API:

função serve para exemplo na API do petshop
assert.equal comparar valor simples assert.equal(resposta.status, 201)
assert.deepEqual comparar objeto ou array inteiro assert.deepEqual(resposta.body, [])
assert.match conferir pedaço de string assert.match(tipo, /application\/json/)
assert.ok afirmar que algo é verdadeiro assert.ok(resposta.body.id)
assert.throws garantir que a função explode preço de porte inexistente

O /strict no fim do import não é decoração. Ele faz equal virar === e deepEqual comparar tipo por tipo — um 45 nunca passa por '45':

js
import { it } from 'node:test';
import assert from 'node:assert/strict';

it('45 e "45" não são a mesma coisa no modo estrito', () => {
  assert.deepEqual({ preco: 45 }, { preco: '45' });
});
✖ 45 e "45" não são a mesma coisa no modo estrito (2.441667ms)

AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:

  • actual - expected

    {

  • preco: 45

  • preco: ‘45’ }

Isso importa muito numa API: número que virou string no caminho do banco para o JSON é bug de verdade, e o modo estrito pega.

Onde o runner procura os testes (e o arquivo que roda sem querer)

O node --test varre a pasta atual e considera teste qualquer arquivo com .test.js no nome — e tudo que estiver dentro de uma pasta chamada test, mesmo sem .test no nome. Coloque um utilitário lá dentro e ele é executado:

js
// test/ajuda.js — não é teste, mas mora em test/
console.log('este arquivo NAO deveria rodar como teste');
export const tutorPadrao = 'Renata';
este arquivo NAO deveria rodar como teste ✔ test/ajuda.js (49.677333ms) ℹ tests 15 ℹ fail 0

Ele contou como um teste que passou. Dado de apoio e função auxiliar ficam fora de test/ — numa pasta apoio/ ou fixtures/, importada pelo caminho relativo.

As duas linhas que separam o app do servidor

Aqui está o detalhe que trava quase todo mundo na primeira vez. Se o seu app.js termina assim, ele não dá para testar em paz:

js
// src/app.js — a versão que causa problema
import express from 'express';
import { listar } from './agenda.js';

const app = express();
app.use(express.json());
app.get('/agendamentos', (req, res) => res.json(listar()));

const porta = process.env.PORT ?? 3000;
app.listen(porta, () => console.log(`Pet Feliz ouvindo na porta ${porta}`));

export default app;

O motivo: o node --test roda cada arquivo de teste num processo separado, e vários ao mesmo tempo. Se dois arquivos importam esse app.js, dois processos tentam ocupar a mesma porta. Rodando a suíte com esse arquivo — aqui com PORT=3005, porque a 3000 já estava ocupada nesta máquina — o resultado é este:

bash
PORT=3005 node --test
Pet Feliz ouvindo na porta 3005 ✔ a agenda começa vazia (7.68725ms)

E parou aí. Um teste passou, o outro nunca reportou, e o terminal não voltou — matei o processo à força depois de quinze segundos. O servidor aberto segura o processo vivo para sempre, então a suíte nunca termina.

Quando a porta está de fato disputada, o segundo processo não trava: ele morre com EADDRINUSE. Provocando a mesma colisão de propósito, num script só — app.listen() é http.createServer(app).listen() por baixo — o erro sai inteiro:

js
import http from 'node:http';

// é isto que os dois arquivos de teste fazem, cada um no seu processo:
// app.listen() é http.createServer(app).listen() por baixo
http.createServer().listen(3005, () => console.log('arquivo de teste 1: servidor de pe na 3005'));
http.createServer().listen(3005, () => console.log('arquivo de teste 2: servidor de pe na 3005'));
arquivo de teste 1: servidor de pe na 3005 node:events:487 throw er; // Unhandled 'error' event ^

Error: listen EADDRINUSE: address already in use :::3005 at Server.setupListenHandle [as _listen2] (node:net:2008:16) at listenInCluster (node:net:2065:12) at Server.listen (node:net:2170:7) at file:///private/tmp/petfeliz-errado/c2.mjs:6:21 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) Emitted ‘error’ event on Server instance at: at emitErrorNT (node:net:2044:8) { code: ‘EADDRINUSE’, errno: -48, syscall: ‘listen’, address: ‘::’, port: 3005 }

Node.js v24.16.0

O conserto são duas linhas e uma tesoura: app.js monta o app e exporta; server.js escuta. Só o server.js chama listen, e nenhum teste importa o server.js.

js
// src/app.js — monta e exporta, sem escutar
import express from 'express';
import { listar, buscar, agendar } from './agenda.js';
import { precoDoServico } from './precos.js';

const app = express();
app.use(express.json());

app.get('/agendamentos', (req, res) => {
  res.json(listar());
});

app.get('/agendamentos/:id', (req, res) => {
  const agendamento = buscar(Number(req.params.id));
  if (!agendamento) return res.status(404).json({ erro: 'Agendamento não encontrado' });
  res.json(agendamento);
});

export default app;
js
// src/server.js — o único lugar do projeto com listen
import app from './app.js';

const porta = process.env.PORT ?? 3000;
app.listen(porta, () => console.log(`Pet Feliz ouvindo na porta ${porta}`));

O teste dessa separação é objetivo: importar o app.js sozinho tem que terminar o processo, porque nada ficou escutando.

bash
node -e "import('./src/app.js').then(() => console.log('o app carregou e o processo terminou'))"
o app carregou e o processo terminou

Se esse comando ficar pendurado no seu projeto, ainda tem listen no lugar errado. E se o EADDRINUSE aparecer também fora do teste, o diagnóstico completo está em EADDRINUSE: address already in use.

supertest: bater na rota numa porta que ninguém escolheu

Com o app exportado, o supertest faz o resto. Ele recebe o app, sobe um servidor na porta 0 — o sistema operacional entrega uma porta livre qualquer —, dispara a requisição e fecha tudo.

bash
npm i -D supertest
js
// test/listar.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import app from '../src/app.js';

describe('GET /agendamentos', () => {
  it('devolve 200 e uma lista', async () => {
    const resposta = await request(app).get('/agendamentos');

    assert.equal(resposta.status, 200);
    assert.match(resposta.headers['content-type'], /application\/json/);
    assert.deepEqual(resposta.body, []);
  });

  it('devolve 404 para agendamento que não existe', async () => {
    const resposta = await request(app).get('/agendamentos/999');

    assert.equal(resposta.status, 404);
    assert.deepEqual(resposta.body, { erro: 'Agendamento não encontrado' });
  });
});
▶ GET /agendamentos ✔ devolve 200 e uma lista (8.212833ms) ✔ devolve 404 para agendamento que não existe (1.266333ms) ✔ GET /agendamentos (9.871959ms) ℹ tests 2 ℹ suites 1 ℹ pass 2 ℹ fail 0

Não é simulação: houve requisição HTTP mesmo. Dá para ver a porta que o sistema entregou em cada chamada.

js
import request from 'supertest';
import app from './src/app.js';

const primeira = await request(app).get('/agendamentos');
const segunda = await request(app).get('/agendamentos');

console.log('1a chamada:', primeira.request.url);
console.log('2a chamada:', segunda.request.url);
1a chamada: http://127.0.0.1:56883/agendamentos 2a chamada: http://127.0.0.1:56885/agendamentos

Portas diferentes, altas, sorteadas na hora. É por isso que a suíte inteira roda em paralelo sem ninguém pisar em ninguém — e por isso o listen fixo do começo da lição atrapalhava.

POST: status 201, header Location e o corpo criado

A rota de criação exercita mais coisa: corpo enviado, validação, status diferente de 200 e um header. Ela devolve 201 com Location apontando para o recurso novo, como pede a lição de métodos HTTP e status code.

js
// src/app.js — o trecho do POST
app.post('/agendamentos', (req, res) => {
  const { pet, tutor, servico, porte, horario } = req.body ?? {};
  if (!pet || !tutor) {
    return res.status(400).json({ erro: 'pet e tutor são obrigatórios' });
  }
  const preco = precoDoServico(servico, porte);
  const novo = agendar({ pet, tutor, servico, porte, horario });
  res.status(201).location(`/agendamentos/${novo.id}`).json({ ...novo, preco });
});
js
// test/agendar.test.js
it('cria o agendamento e devolve 201 com Location', async () => {
  const resposta = await request(app).post('/agendamentos').send({
    pet: 'Amora',
    tutor: 'Renata',
    servico: 'banho',
    porte: 'pequeno',
    horario: '2026-08-24T14:00',
  });

  assert.equal(resposta.status, 201);
  assert.equal(resposta.headers.location, '/agendamentos/1');
  assert.equal(resposta.body.pet, 'Amora');
  assert.equal(resposta.body.preco, 45);
});

it('recusa cadastro sem tutor com 400', async () => {
  const resposta = await request(app)
    .post('/agendamentos')
    .send({ pet: 'Amora', servico: 'banho', porte: 'pequeno' });

  assert.equal(resposta.status, 400);
  assert.deepEqual(resposta.body, { erro: 'pet e tutor são obrigatórios' });
});
▶ POST /agendamentos ✔ cria o agendamento e devolve 201 com Location (13.314167ms) ✔ recusa cadastro sem tutor com 400 (1.800416ms) ✔ o que foi criado aparece no GET seguinte (1.80975ms) ✔ POST /agendamentos (17.440459ms) ℹ tests 3 ℹ pass 3 ℹ fail 0

.send(objeto) já manda Content-Type: application/json, que é o que o express.json() espera. Todo header sai em minúsculo em resposta.headers: é resposta.headers.location, nunca Location.

O estado que vaza de um teste para o outro

O terceiro teste do bloco acima cria um agendamento e confere se ele aparece na listagem. Ele passa sozinho — e derruba a suíte quando roda depois dos outros, porque a agenda em memória guardou o que o primeiro teste marcou:

js
it('o que foi criado aparece no GET seguinte', async () => {
  await request(app)
    .post('/agendamentos')
    .send({ pet: 'Thor', tutor: 'Caio', servico: 'tosa', porte: 'grande' });

  const lista = await request(app).get('/agendamentos');

  assert.equal(lista.body.length, 1);
  assert.equal(lista.body[0].pet, 'Thor');
});
✔ cria o agendamento e devolve 201 com Location (10.793833ms) ✔ recusa cadastro sem tutor com 400 (1.7875ms) ✖ o que foi criado aparece no GET seguinte (2.078792ms)

AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

2 !== 1

plaintext
  at TestContext.<anonymous> (file:///private/tmp/petfeliz/test/agendar.test.js:39:12)
actual: 2,
expected: 1,
operator: 'strictEqual'</div>

Dois agendamentos onde deveria haver um: sobrou a Amora do teste anterior. O conserto é dar ao repositório uma função que zera tudo, e chamá-la antes de cada teste.

js
// src/agenda.js
let sequencia = 1;
let agendamentos = [];

export function limparAgenda() {
  agendamentos = [];
  sequencia = 1;
}

export function listar() {
  return agendamentos;
}

export function agendar({ pet, tutor, servico, porte, horario }) {
  const novo = { id: sequencia++, pet, tutor, servico, porte, horario };
  agendamentos.push(novo);
  return novo;
}
js
import { describe, it, beforeEach } from 'node:test';
import { limparAgenda } from '../src/agenda.js';

describe('POST /agendamentos', () => {
  beforeEach(() => limparAgenda());
  // ...
});

São quatro hooks, e a ordem entre eles vale um exemplo — este arquivo tem dois testes e imprime cada etapa:

js
describe('ordem dos hooks', () => {
  before(() => console.log('before: abre a agenda do dia'));
  beforeEach(() => {
    limparAgenda();
    console.log('beforeEach: agenda zerada');
  });
  afterEach(() => console.log('afterEach: sobraram', listar().length, 'agendamentos'));
  after(() => console.log('after: fecha a agenda do dia'));

  it('marca o banho da Amora', () => {
    agendar({ pet: 'Amora', tutor: 'Renata', servico: 'banho', porte: 'pequeno' });
  });

  it('marca a tosa do Thor', () => {
    agendar({ pet: 'Thor', tutor: 'Caio', servico: 'tosa', porte: 'grande' });
  });
});
before: abre a agenda do dia beforeEach: agenda zerada afterEach: sobraram 1 agendamentos beforeEach: agenda zerada afterEach: sobraram 1 agendamentos after: fecha a agenda do dia ▶ ordem dos hooks ✔ marca o banho da Amora (0.363667ms) ✔ marca a tosa do Thor (0.662791ms) ✔ ordem dos hooks (2.005542ms)

before e after correm uma vez por bloco; beforeEach e afterEach, uma vez por teste. Repare também que os console.log saíram todos antes do relatório: o runner segura a saída dos testes e imprime o resumo no fim.

A regra que evita a maioria dos testes instáveis: quem cria o dado é quem limpa. Quando o banco entrar no lugar do array, o mesmo desenho vale — o beforeEach apaga as tabelas de um banco de teste apontado por uma variável de ambiente própria, nunca o banco que você usa para desenvolver.

Cobertura: a coluna que aponta a linha esquecida

O runner mede cobertura sem ferramenta externa:

bash
node --test --experimental-test-coverage
ℹ start of coverage report ℹ ----------------------------------------------------------- ℹ file | line % | branch % | funcs % | uncovered lines ℹ ----------------------------------------------------------- ℹ src | | | | ℹ agenda.js | 100.00 | 100.00 | 100.00 | ℹ app.js | 97.30 | 80.00 | 100.00 | 15 ℹ precos.js | 100.00 | 100.00 | 100.00 | ℹ ----------------------------------------------------------- ℹ all files | 98.57 | 90.91 | 100.00 | ℹ ----------------------------------------------------------- ℹ end of coverage report

A coluna que importa é a última. Linha 15 do app.js é exatamente esta:

js
app.get('/agendamentos/:id', (req, res) => {
  const agendamento = buscar(Number(req.params.id));
  if (!agendamento) return res.status(404).json({ erro: 'Agendamento não encontrado' });
  res.json(agendamento);   // ← linha 15, nunca executada
});

Havia teste para o 404 e nenhum para o caso em que o agendamento existe. É um buraco real, não estatística. Escrito o teste que faltava, a tabela fecha:

js
it('devolve o agendamento pelo id', async () => {
  agendar({ pet: 'Amora', tutor: 'Renata', servico: 'banho', porte: 'pequeno' });

  const resposta = await request(app).get('/agendamentos/1');

  assert.equal(resposta.status, 200);
  assert.equal(resposta.body.pet, 'Amora');
});
ℹ file | line % | branch % | funcs % | uncovered lines ℹ ----------------------------------------------------------- ℹ src | | | | ℹ agenda.js | 100.00 | 100.00 | 100.00 | ℹ app.js | 100.00 | 90.00 | 100.00 | ℹ precos.js | 100.00 | 100.00 | 100.00 | ℹ ----------------------------------------------------------- ℹ all files | 100.00 | 95.45 | 100.00 |

Cem por cento de linha e noventa de ramo, no mesmo arquivo. A diferença é o req.body ?? {} do POST: a metade {} só roda quando chega uma requisição sem corpo nenhum, e nenhum teste faz isso. Linha coberta não é caminho coberto — é a lição mais útil dessa tabela.

Rodar um teste só, e deixar a suíte rodando sozinha

Enquanto você conserta um teste, não faz sentido esperar a suíte inteira. --test-name-pattern filtra pelo nome:

bash
node --test --test-name-pattern="201"
▶ POST /agendamentos ✔ cria o agendamento e devolve 201 com Location (10.330708ms) ✔ POST /agendamentos (10.753875ms) ✔ test/listar.test.js (83.486584ms) ✔ test/precos.test.js (41.949416ms) ✔ test/recibo.test.js (82.520959ms) ℹ tests 4 ℹ pass 4 ℹ fail 0

Os outros arquivos aparecem como um item só, porque nenhum teste dentro deles casou com o filtro. E --watch reexecuta a cada gravação, do mesmo jeito que o node --watch faz com o servidor:

bash
node --test --watch test/precos.test.js
▶ precoDoServico ✔ cobra 45 pelo banho de cachorro pequeno (0.237458ms) ✔ cobra 120 pela tosa de cachorro grande (0.040667ms) ✔ recusa serviço fora da tabela (0.209709ms) ✔ recusa porte fora da tabela (0.355042ms) ✔ precoDoServico (1.229625ms) ℹ tests 4 ℹ pass 4

Restarted at 8/22/2026, 8:17:53 PM ▶ precoDoServico ✔ cobra 45 pelo banho de cachorro pequeno (0.275333ms) ✔ cobra 120 pela tosa de cachorro grande (0.048542ms) ✔ recusa serviço fora da tabela (0.2365ms) ✔ recusa porte fora da tabela (0.615792ms) ✔ precoDoServico (1.6805ms)

Aquele Restarted at é o runner percebendo que src/precos.js foi salvo. Vale deixar tudo pronto no package.json:

json
{
  "scripts": {
    "dev": "node --watch src/server.js",
    "test": "node --test",
    "test:watch": "node --test --watch",
    "test:cov": "node --test --experimental-test-coverage"
  }
}

O teste que passa na sua máquina e falha no CI

O Pet Feliz manda um recibo com o dia do banho. O horário vem do banco em UTC, como todo horário bem guardado, e a rota formata para o tutor ler:

js
app.get('/agendamentos/:id/recibo', (req, res) => {
  const agendamento = buscar(Number(req.params.id));
  if (!agendamento) return res.status(404).json({ erro: 'Agendamento não encontrado' });
  res.json({
    pet: agendamento.pet,
    dia: new Date(agendamento.horario).toLocaleDateString('pt-BR'),
  });
});
js
it('mostra o dia do banho da Amora', async () => {
  agendar({ pet: 'Amora', tutor: 'Renata', servico: 'banho', porte: 'pequeno',
    horario: '2026-08-25T00:30:00Z' });

  const resposta = await request(app).get('/agendamentos/1/recibo');

  assert.equal(resposta.body.dia, '24/08/2026');
});

Na sua máquina, com o relógio em Brasília, ele passa:

bash
TZ=America/Sao_Paulo node --test test/recibo.test.js
▶ GET /agendamentos/:id/recibo ✔ mostra o dia do banho da Amora (32.493083ms) ✔ GET /agendamentos/:id/recibo (32.849875ms) ℹ tests 1 ℹ pass 1 ℹ fail 0

O servidor de integração contínua roda em UTC. O mesmo comando, com o mesmo código:

bash
TZ=UTC node --test test/recibo.test.js
▶ GET /agendamentos/:id/recibo ✖ mostra o dia do banho da Amora (18.0665ms) ✖ GET /agendamentos/:id/recibo (18.63025ms) ℹ tests 1 ℹ pass 0 ℹ fail 1

AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  • actual - expected

  • ‘25/08/2026’

  • ‘24/08/2026’

    at TestContext.<anonymous> (file:///private/tmp/petfeliz/test/recibo.test.js:21:12) actual: ‘25/08/2026’, expected: ‘24/08/2026’, operator: ‘strictEqual’

Nada mudou no código. Mudou o fuso do processo — e toLocaleDateString sem timeZone usa o fuso da máquina. Meia-noite e meia em UTC é vinte e uma e meia do dia anterior em Brasília, e o recibo trocou de dia.

O conserto não é no teste, é na rota: o petshop atende em Brasília, então o dia do recibo é o dia de Brasília, sempre.

js
dia: new Date(agendamento.horario).toLocaleDateString('pt-BR', {
  timeZone: 'America/Sao_Paulo',
}),
▶ GET /agendamentos/:id/recibo ✔ mostra o dia do banho da Amora (15.78725ms) ✔ GET /agendamentos/:id/recibo (16.172792ms) ℹ tests 1 ℹ pass 1 ℹ fail 0

Com TZ=Asia/Tokyo também passa. Três dependências escondidas fazem o mesmo estrago e valem uma revisão antes de culpar o CI:

dependência escondida como ela quebra o que fazer
fuso do sistema data muda de dia entre o seu relógio e o UTC fixar timeZone na formatação
relógio real (Date.now) teste que depende de “hoje” falha à meia-noite receber a data por parâmetro
ordem dos arquivos um teste depende do dado que outro criou beforeEach que zera o estado

Rodar a suíte uma vez com TZ=UTC antes de abrir o pull request custa cinco segundos e economiza a tarde inteira.

O que vem depois

A API do Pet Feliz agora tem doze testes que sobem em cento e trinta milissegundos e cobrem 100% das linhas. O passo seguinte é proteger as rotas de escrita e testar o token junto — o supertest manda header com .set(), e a lição de autenticação com JWT no Express mostra o que precisa entrar no cabeçalho. Depois disso, npm test vira uma etapa do deploy da API: se a suíte falha, o contêiner não sobe. O mapa inteiro de onde essas peças se encaixam está no guia de Node.js, e a sequência de estudo, na trilha de Node e APIs.

  • teste
  • node:test
  • supertest
  • express
  • api
  • qualidade

Perguntas frequentes

Ainda preciso do Jest para testar uma API em Node?
Para uma API Express em ESM, não. O node:test cobre describe, it, hooks, mock, watch e cobertura sem nenhuma dependência. O Jest continua fazendo sentido em projeto React com jsdom, snapshot e transform de JSX.
O supertest sobe um servidor de verdade?
Sobe, mas na porta 0 — o sistema operacional escolhe uma porta livre, o supertest faz a requisição e fecha o servidor no fim. Por isso dois arquivos de teste podem rodar ao mesmo tempo sem brigar por porta.
Qual a diferença entre resposta.body e resposta.text?
O supertest só preenche resposta.body quando o content-type é JSON. Se a rota devolve HTML ou texto puro, body vem como objeto vazio e o conteúdo está em resposta.text.
Preciso de banco de dados de verdade para testar a rota?
Não para o primeiro teste. Comece com o repositório em memória e uma função que zera o estado no beforeEach. Quando o banco entrar, aponte a variável de ambiente do teste para um banco separado do de desenvolvimento.
Que porcentagem de cobertura eu devo perseguir?
Nenhuma. Cobertura é mapa, não nota: use a coluna uncovered lines para achar caminho sem teste e decidir se aquele caminho importa. Uma API com 70% de linhas nos lugares certos é melhor que 100% de teste raso.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. Node.js — Test runner — nodejs.org
  2. Node.js — assert — nodejs.org
  3. supertest — npm — npmjs.com

Continue por aqui