O que é Supertest?
Supertest é uma biblioteca de asserções HTTP para Node.js. Ela recebe um objeto de app — um app Express, uma instância Fastify, ou um request listener http simples — e permite que você faça requisições a ele através de uma API encadeável (chainable), para então validar a resposta.
Ele é construído sobre o superagent, então a parte de requisições parecerá familiar se você já utilizou essa biblioteca. O que o Supertest adiciona é a ergonomia de testes: um app pode ser passado diretamente em vez de uma URL, o ciclo de vida do servidor é gerenciado automaticamente e asserções .expect() podem ser anexadas à cadeia de chamadas.
O ponto importante a entender é que o Supertest não executa um navegador e não inicia seu servidor de produção. Ele cria um servidor HTTP de curta duração no mesmo processo, vinculado a uma porta efêmera no localhost, despacha a requisição e o encerra quando a resposta é resolvida. Seu teste interage com a stack HTTP real — códigos de status, headers, corpos, serialização — sem o custo e a instabilidade de um processo separado.
Por que testar in-process
A maior parte da dificuldade nos testes de HTTP vem do servidor, não da requisição. Um processo separado precisa de uma porta, tempo de inicialização, um health check e um teardown. Portas fixas causam conflitos em CI, e uma corrida entre listen e a primeira requisição gera falhas que parecem bugs da aplicação.
Testar in-process remove tudo isso. Não há processo para iniciar, portanto, não há verificação de prontidão (readiness check). Não há porta fixa, então arquivos de teste paralelos não conflitam entre si. Não há barreira de rede, logo, uma falha aponta para o seu handler em vez de apontar para o ambiente.
Você ainda tem o pipeline de requisição completo: middleware, roteamento, body parsing, autenticação e tratamento de erros funcionam exatamente como em produção, porque são o mesmo código. O que você abre mão são as coisas que apenas um navegador real ou uma rede real podem fornecer — execução de JavaScript, um motor de renderização e o comportamento exato de um proxy na frente do seu app.
Exporte o app, não o listener
Para que o Supertest consiga importar seu app, ele precisa ser exportável. Um erro comum é definir as rotas e chamar listen no mesmo módulo, o que inicia o servidor no momento em que o arquivo é importado e causa conflitos de porta nos seus testes.
Separe essas duas responsabilidades:
// src/app.ts
import express from "express";
import { postsRouter } from "./routes/posts.js";
export const app = express();
app.use(express.json());
app.use("/posts", postsRouter);
app.use((err, req, res, next) => {
res.status(err.status ?? 500).json({ error: err.code ?? "internal_error" });
});
// src/server.ts
import { app } from "./app.js";
app.listen(3000, () => console.log("listening on http://localhost:3000"));
Agora src/app.ts exporta o request listener e nada mais. Os testes o importam, e o ambiente de produção o importa através de server.ts. Essa simples separação é a diferença entre uma API que você consegue testar em milissegundos e uma que você precisa dar boot.
O app do Express é, por si só, uma função com a assinatura (req, res), que é exatamente o que o http.createServer do Node espera. É por isso que request(app) funciona sem qualquer adaptador. O Fastify precisa de app.server ou de um app.ready() com await, e um handler puro do Node funciona diretamente.
Fazendo uma requisição
Uma requisição começa com request(app) e o método HTTP. Todos os métodos suportados pelo superagent estão disponíveis, e a cadeia (chain) retorna o mesmo objeto de requisição, permitindo que as chamadas sejam empilhadas.
import request from "supertest";
import app from "../src/app.js";
await request(app).get("/posts");
await request(app).post("/posts").send({ title: "Hello" });
await request(app).patch("/posts/1").send({ title: "Updated" });
await request(app).delete("/posts/1");
send serializa um objeto para JSON e define o header Content-Type automaticamente. Uma string é enviada como está, o que é útil quando você deseja testar propositalmente um corpo malformado.
Os headers são definidos com set, seja um por um ou como um objeto. Parâmetros de query ficam mais limpos através de query, que os codifica e anexa para você.
await request(app)
.get("/posts")
.query({ page: 2, perPage: 10 })
.set("Accept", "application/json")
.set({ "X-Request-Id": "test-1" });
A URL resultante é /posts?page=2&perPage=10. Se você precisar de um corpo bruto — XML, uma string simples, um payload deliberadamente quebrado — passe set("Content-Type", ...) e send a string.
Fazendo asserções na resposta
A resposta é um objeto comum com status, headers, body e text. Você pode fazer asserções nela usando o expect do seu test runner, ou pode usar o .expect() do Supertest diretamente na chain.
const res = await request(app).get("/posts").expect(200);
expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(res.body).toHaveLength(3);
O .expect() do Supertest é conveniente porque, em caso de falha, ele exibe a resposta completa na mensagem de erro, o que geralmente é suficiente para entender o que deu errado sem precisar adicionar logs.
await request(app)
.get("/posts")
.expect("Content-Type", /json/)
.expect(200);
O .expect() aceita um código de status, o nome e valor de um header, um corpo para igualdade profunda (deep equality), ou uma função que recebe a resposta e pode lançar um erro. A forma de função é a “válvula de escape” quando uma asserção exige lógica.
await request(app)
.get("/posts")
.expect((res) => {
if (!res.body.every((p: { id: number }) => p.id > 0)) {
throw new Error("every post must have a positive id");
}
});
Prefira o expect do runner para qualquer coisa além da linha de status e do content type. Ele oferece diffs melhores, suporta matchers como toMatchObject e arrayContaining, e mantém o estilo de asserção consistente com o restante da sua suíte de testes.
Combinando Supertest com seu test runner
O Supertest não é um test runner. Ele constrói requisições e pode fazer asserções nas respostas, mas não descobre testes, não fornece describe e it, não faz mock de módulos nem reporta resultados. Esse trabalho pertence ao Vitest, Jest ou node:test.
Os dois se compõem perfeitamente porque uma requisição do Supertest é “thenable”. Ao usar o await, a resposta é resolvida, portanto, um teste é apenas uma função async.
import request from "supertest";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app.js";
describe("POST /posts", () => {
beforeEach(async () => {
await resetDatabase();
});
it("creates a post", async () => {
const res = await request(app)
.post("/posts")
.send({ title: "Hello" })
.expect(201);
expect(res.body.title).toBe("Hello");
});
});
Se você esquecer de usar o await, o teste passará antes mesmo de a requisição ser enviada e a falha surgirá posteriormente como uma “unhandled rejection”. Use await em await requisição, mesmo naquelas cuja única asserção seja .expect().
Testando a autenticação
A autenticação é apenas um header ou um cookie, portanto, ambos são fáceis de testar.
Para bearer tokens, configure o header Authorization. Assine um token com o mesmo secret de teste que a aplicação utiliza, em vez de chamar o provedor de identidade real.
const token = await signTestToken({ sub: "user_1", scope: "read:posts" });
await request(app).get("/posts").expect(401);
await request(app)
.get("/posts")
.set("Authorization", `Bearer ${token}`)
.expect(200);
Para session cookies, o request.agent(app) mantém um cookie jar entre as requisições, o que simula um navegador após o login.
const agent = request.agent(app);
await agent
.post("/login")
.send({ email: "[email protected]", password: "secret" })
.expect(204);
await agent.get("/me").expect(200);
Quando você já possui o valor de um cookie, configure-o diretamente. Os cookies são passados como um array ou como uma string separada por ponto e vírgula.
await request(app).get("/me").set("Cookie", ["session=abc123"]).expect(200);
Teste os casos negativos com tanto cuidado quanto o “happy path”: sem token, um token expirado, um token para outro audience e um token válido sem o scope necessário. Esses quatro testes protegem você muito mais do que um único caso de sucesso.
Configurando e limpando dados
O Supertest não impõe nenhuma regra sobre o banco de dados, o que significa que um banco de dados compartilhado causará vazamento de estado entre os testes, a menos que você o resete. Existem três estratégias comuns.
Resetar antes de cada teste. Limpe (truncate) as tabelas que a suíte utiliza em beforeEach. É simples e previsível, e o custo é aceitável para suítes pequenas.
beforeEach(async () => {
await db.query("TRUNCATE posts RESTART IDENTITY CASCADE");
await db.query("INSERT INTO posts (id, title) VALUES (1, 'Seeded')");
});
Uma transação por teste. Se a sua aplicação e o seu teste compartilharem a mesma conexão, envolva cada teste em uma transação e faça o rollback em afterEach. Isso é rápido e deixa o banco de dados intacto, mas só funciona quando a aplicação usa o mesmo cliente, o que nem sempre acontece via HTTP.
Um banco de dados novo por arquivo de teste. Inicie um banco de dados em memória ou em container para o arquivo, execute as migrações e descarte-o ao final. Isso oferece o isolamento mais forte e é a abordagem na qual a maioria das suítes de integração se estabiliza, ao custo de um primeiro teste mais lento.
Independentemente da escolha, feche o pool de conexões em afterAll. Um pool aberto mantém o processo do Node.js ativo e transforma uma suíte que passou em um job de CI travado.
Testando os caminhos de erro (unhappy paths)
Uma rota não está testada até que suas falhas sejam testadas. O status code faz parte do contrato, portanto, faça a asserção dele explicitamente.
await request(app).get("/posts/999").expect(404);
await request(app).post("/posts").send({}).expect(422);
await request(app).get("/admin").expect(403);
Uma falha de validação deve informar qual campo falhou, e não apenas que algo deu errado.
const res = await request(app)
.post("/posts")
.send({ title: "" })
.expect(422);
expect(res.body).toEqual({
error: "validation_error",
fields: { title: "required" },
});
As distinções são importantes. 400 é uma requisição malformada, 401 significa não autenticado, 403 significa autenticado, mas sem permissão, 404 significa que o recurso não existe, e 422 significa que o corpo foi processado, mas falhou na validação. Assertar o código errado é um bug no teste que esconde um bug na aplicação.
Upload de arquivos e multipart
O Supertest constrói requisições multipart utilizando attach para arquivos e field para os campos de formulário correspondentes. Você pode passar um buffer ou um caminho; ao passar um buffer, forneça um nome de arquivo para que o servidor receba um nome coerente.
await request(app)
.post("/users/1/avatar")
.field("caption", "Profile picture")
.attach("avatar", Buffer.from("fake-image"), "avatar.png")
.expect(201);
Teste também as rejeições: arquivo ausente, arquivo acima do limite de tamanho e tipos MIME não permitidos. Uploads são um dos lugares mais comuns onde falhas de validação costumam se esconder.
Testando paginação, filtragem e ordenação
As query strings fazem parte do contrato da API, portanto, merecem testes. query as torna legíveis, e validar o comprimento e a ordenação do corpo da resposta ajuda a capturar bugs de “off-by-one” e de valores padrão.
test("GET /posts paginates", async () => {
await seedPosts(25);
const page1 = await request(app)
.get("/posts")
.query({ page: 1, perPage: 10 })
.expect(200);
expect(page1.body).toHaveLength(10);
expect(page1.body[0].id).toBe(1);
const page3 = await request(app)
.get("/posts")
.query({ page: 3, perPage: 10 })
.expect(200);
expect(page3.body).toHaveLength(5);
});
Teste os limites, não apenas o meio: a primeira página, a última página, uma página além do fim e um perPage inválido que deveria ser limitado ou rejeitado.
await request(app)
.get("/posts")
.query({ page: 999 })
.expect(200)
.expect((res) => {
if (res.body.length !== 0) throw new Error("expected an empty page");
});
await request(app).get("/posts").query({ perPage: 10_000 }).expect(400);
Filtragem e ordenação são igualmente testáveis, e é onde a falta de um índice ou um ORDER BY incorreto aparece como um bug sutil.
const res = await request(app)
.get("/posts")
.query({ status: "published", sort: "-createdAt" })
.expect(200);
expect(
res.body.every((p: { status: string }) => p.status === "published"),
).toBe(true);
Redirecionamentos, cookies e outros detalhes de HTTP
Nem toda resposta é um corpo JSON. Códigos de status como 301, 302 e 304, e headers como Location, Set-Cookie e Cache-Control, são frequentemente todo o comportamento sob teste.
Por padrão, o supertest não segue redirecionamentos, que é exatamente o que você deseja quando está validando o redirecionamento em si.
const res = await request(app).get("/old-posts").expect(301);
expect(res.headers.location).toBe("/posts");
Se a cadeia de redirecionamento é o que importa, redirects(1) segue um salto e resolve com a resposta final.
await request(app).get("/old-posts").redirects(1).expect(200);
Cookies ficam visíveis em set-cookie, e o jar do agent permite validar que um login definiu os atributos corretos sem a necessidade de decodificar o valor.
const res = await request.agent(app)
.post("/login")
.send({ email: "[email protected]", password: "secret" })
.expect(204);
const cookie = res.headers["set-cookie"][0];
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
Requisições condicionais, compressão e headers de cache valem a pena ser testados quando você depende deles, pois um proxy ou CDN alterará alegremente o comportamento se os headers estiverem incorretos.
Acelerando a suíte
Uma suíte de Supertest geralmente é rápida, mas alguns hábitos ajudam a mantê-la assim conforme ela cresce.
Reutilize setups onerosos em beforeAll e resete apenas as partes mutáveis por teste. Iniciar um container ou migrar um schema uma vez por arquivo, em vez de uma vez por teste, pode reduzir minutos de uma suíte grande.
Execute os arquivos de teste em paralelo. Vitest e Jest fazem isso por padrão e, como cada requisição do Supertest usa uma porta efêmera, não há colisões para resolver. A única coisa que você deve garantir é que os arquivos não compartilhem linhas do banco de dados.
Pule trabalhos irrelevantes em testes que apenas realizam leitura. Se uma rota precisa apenas de um usuário e um post, não faça o seed de todo o conjunto de fixtures. Fixtures menores são mais rápidas de criar e mais fáceis de analisar.
# run one file while iterating
pnpm exec vitest run test/posts.test.ts
# watch the file you are editing
pnpm exec vitest test/posts.test.ts
Finalmente, mantenha os testes unitários para a lógica pura e deixe os testes HTTP cobrirem a integração. Uma suíte que passa cada cálculo por uma requisição completa é lenta e não traz confiança extra.
Organizando a suíte de testes
Espelhe a estrutura da fonte para que um teste com falha aponte para um arquivo que você consiga encontrar. Se o app tiver src/routes/posts.ts, coloque test/posts.test.ts ao lado dele na árvore de testes.
Mantenha a configuração compartilhada em um pequeno número de helpers:
- Um
test/app.tsque constrói o app com a configuração de teste. - Um
test/db.tsque executa as migrações, limpa (truncate) e fecha o banco de dados. - Um
test/factories.tscom funções que criam usuários, posts e tokens. - Um
test/tokens.tsque assina um token com o secret de teste.
// test/factories.ts
export async function createUser(overrides: Partial<User> = {}) {
return db.user.create({
data: { email: "[email protected]", role: "member", ...overrides },
});
}
Factories mantêm os testes legíveis porque o valor interessante é a sobrescrita (override). Um teste que diz createUser({ role: "admin" }) comunica sua intenção de uma forma que uma parede de campos literais não consegue.
Mantendo os testes independentes
Cada teste deve passar individualmente e em qualquer ordem. Essa propriedade é o que permite que um runner execute arquivos em paralelo e evita que uma única falha desencadeie outras doze falhas enganosas.
Os inimigos da independência são os estados mutáveis compartilhados: um contador no nível do módulo, uma linha semeada que outro teste deleta, um clock mockado que nunca é restaurado, ou um banco de dados que é configurado apenas uma vez. Resete as peças das quais cada teste depende e nunca dependa de um teste anterior para ter criado algo.
Quando uma fixture for genuinamente custosa — como um banco de dados migrado ou um container em execução — crie-a apenas uma vez em beforeAll e resete as partes mutáveis em beforeEach. A distinção está entre a configuração que é apenas de leitura e a configuração que sofre alterações.
Melhores práticas
- Exporte o app de
app.tse mantenhalisten()emserver.ts. - Use await em cada requisição; a ausência de um
awaité um passe silencioso. - Valide o status code e o content type antes do corpo da resposta.
- Teste os caminhos negativos — 400, 401, 403, 404, 422 — e não apenas o caminho feliz.
- Assine tokens de teste com um secret de teste em vez de chamar o provedor real.
- Resete os dados que cada teste manipula e feche o pool em
afterAll. - Mantenha apenas um comportamento por teste, para que uma falha identifique exatamente o que quebrou.
- Prefira o
expectdo runner para asserções de corpo e.expect()para a linha de status. - Execute a suíte de testes contra a mesma stack de middleware utilizada em produção.
Erros comuns
- Chamar
listen()no módulo importado, fazendo com que cada arquivo de teste abra uma porta. - Esquecer o
await, o que faz com que um teste passe antes mesmo de a requisição ser enviada. - Compartilhar uma linha do banco de dados entre testes e depender da ordem de execução.
- Testar o framework — como validar que o Express faz o parse de JSON — em vez de testar o seu próprio código.
- Validar um status 200 quando a rota deveria retornar um 404.
- Deixar o pool do banco de dados aberto, impedindo que o processo seja encerrado.
- Fazer mocks do banco de dados de forma tão excessiva que o teste apenas prova que o mock funciona.
- Verificar o corpo da resposta com uma comparação de string quando um matcher estrutural seria mais claro.
- Ignorar headers como
Location,Set-Cookiee diretivas de cache.
Próximos passos
O Supertest cobre a fronteira HTTP e se integra perfeitamente a todo o ecossistema ao seu redor. Leia sobre Vitest para conhecer o runner que executará esses testes, ou Jest caso seu projeto já utilize Jest. O guia de Express explica o objeto app que você está passando para o Supertest, e a seção de REST aborda os códigos de status e a semântica que suas asserções codificam. Assim que a API estiver coberta, o guia de End-to-End Testing mostra como validar as mesmas jornadas através de um navegador real.