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.
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.
// 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;
}// 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);
});node --testRepare 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:
test('banho de cachorro pequeno custa 45', () => {
assert.equal(precoDoServico('banho', 'pequeno'), 50);
});✖ 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
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:
// 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',
});
});
});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':
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' });
});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:
// test/ajuda.js — não é teste, mas mora em test/
console.log('este arquivo NAO deveria rodar como teste');
export const tutorPadrao = 'Renata';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:
// 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:
PORT=3005 node --testE 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:
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'));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.
// 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;// 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.
node -e "import('./src/app.js').then(() => console.log('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.
npm i -D supertest// 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' });
});
});Não é simulação: houve requisição HTTP mesmo. Dá para ver a porta que o sistema entregou em cada chamada.
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);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.
// 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 });
});// 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' });
});.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:
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');
});AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
2 !== 1
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.
// 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;
}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:
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 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:
node --test --experimental-test-coverageA coluna que importa é a última. Linha 15 do app.js é exatamente esta:
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:
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');
});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:
node --test --test-name-pattern="201"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:
node --test --watch test/precos.test.jsRestarted 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:
{
"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:
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'),
});
});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:
TZ=America/Sao_Paulo node --test test/recibo.test.jsO servidor de integração contínua roda em UTC. O mesmo comando, com o mesmo código:
TZ=UTC node --test test/recibo.test.jsAssertionError [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.
dia: new Date(agendamento.horario).toLocaleDateString('pt-BR', {
timeZone: 'America/Sao_Paulo',
}),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.
Perguntas frequentes
Ainda preciso do Jest para testar uma API em Node?
O supertest sobe um servidor de verdade?
Qual a diferença entre resposta.body e resposta.text?
Preciso de banco de dados de verdade para testar a rota?
Que porcentagem de cobertura eu devo perseguir?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
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
- Node.js — Test runner — nodejs.org
- Node.js — assert — nodejs.org
- supertest — npm — npmjs.com


