Abandonando o Jest em favor do test runner nativo do Node
Node test runner vs Jest: recursos estáveis, watch mode, snapshots, fake timers, coverage, suporte TypeScript e limites ao migrar.
O test runner nativo do Node pode substituir o Jest na maioria das suítes server-side. O runner em si está estável desde o Node 20.0.0, e watch mode, snapshot testing e fake timers já existem hoje, ainda que guias de migração mais antigos continuem listando esses recursos como ausentes.
A frustração que motiva essa migração é familiar. Seu serviço é ESM puro, mas o comando de teste arrasta consigo um pipeline de transformação, um arquivo de configuração e uma árvore de dependências que quebra a cada major upgrade — tudo isso apenas para executar funções e verificar resultados. A questão em aberto não é se o node:test existe, mas quais partes dele são estáveis o suficiente para entrar no CI. O que segue percorre a superfície de recursos em ordem, indica o nível de estabilidade de cada peça conforme a documentação do test runner do Node e expõe o que você perde em relação ao Jest e ao Vitest.
Principais Conclusões
- O test runner do Node está estável desde a v20.0.0, mas a cobertura ainda exige a flag experimental
--experimental-test-coverage, e o watch mode também está marcado como experimental. - O snapshot testing chegou na v22.3.0 e tornou-se estável na v23.4.0; os fake timers via
mock.timersestão estáveis desde a v23.1.0 e conseguem mockarDate. - Os exports de ES modules são congelados, então
mock.methodnão consegue substituir um named export; exporte um objeto no lugar, ou use o experimentalmock.module()por trás de--experimental-test-module-mocks. - Arquivos de teste
.tsrodam sem loader porque o type stripping vem habilitado por padrão, estável desde a v24.12.0. - O que você abre mão ao deixar o Jest não são recursos, e sim ergonomia: o vocabulário de matchers, o ambiente jsdom e helpers de stub de uma linha como
mockResolvedValue.
O Que Você Ganha com Zero Dependências?
A base sem dependências é node:test para a estrutura e node:assert para as asserções, executados com node --test. Você tem describe/it (aliases de suite/test), hooks before/after/beforeEach/afterEach, subtests, skip e todo, além de um código de saída diferente de zero em caso de falha.
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('add', () => {
it('sums two numbers', () => {
assert.strictEqual(1 + 2, 3);
});
});
node --test
A estabilidade é por recurso, não por módulo, e é justamente essa distinção que importa antes de você comprometer um pipeline. Veja onde cada peça está:
| Recurso | Flag / API | Status | Versão |
|---|---|---|---|
| Núcleo do runner | node --test | Estável | Estável desde a v20.0.0 |
| Watch mode | --watch | Experimental | Adicionado na v19.2.0 |
| Snapshots | t.assert.snapshot() | Estável | Adicionado na v22.3.0, estável na v23.4.0 |
| Fake timers | mock.timers | Estável | Estável desde a v23.1.0 |
| Cobertura | --experimental-test-coverage | Experimental | - |
| Mock de módulos | mock.module() | Em estágio inicial | Adicionado na v22.3.0 / v20.18.0 |
| Tags de teste | --experimental-test-tag-filter | Em estágio inicial | Adicionado na v26.2.0, com backport para a v24.19.0 |
| Type stripping de TypeScript | habilitado por padrão | Estável | Estável desde a v24.12.0 |
Executando e Filtrando com o Test Runner do Node
Sem argumentos, o node --test descobre arquivos que correspondem a **/*.test.{cjs,mjs,js}, **/*-test.{cjs,mjs,js}, **/*_test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js} e **/test/**/*.{cjs,mjs,js}, além dos mesmos seis padrões com {cts,mts,ts}, a menos que você desative o type stripping com --no-strip-types. Você também pode passar globs explícitos como argumentos.
A filtragem mapeia diretamente para os hábitos vindos do Jest:
node --test --test-name-pattern="parses headers" # like jest -t
node --test --test-skip-pattern="integration" # inverse filter
node --test --test-only # honor { only: true }
--test-only é a peça que usuários de Jest sentem falta primeiro: marcar um teste com { only: true } não faz nada a menos que a flag seja passada. As tags de teste chegaram com --experimental-test-tag-filter na v26.2.0 e receberam backport para a linha LTS na v24.19.0, ambas em estabilidade de estágio inicial. A sintaxe do filtro não é idêntica nas duas linhas: a v26 aceita expressões booleanas e wildcards, enquanto a 24.x faz correspondência com nomes literais de tags. De todo modo, estágio inicial é verde demais para servir de gate em um pipeline.
Watch Mode
O watch mode existe e é invocado com node --test --watch. Ele monitora seus arquivos de teste e os módulos que eles importam, e então reexecuta o que quer que uma alteração afete. A documentação ainda marca o watch mode como Stability 1, Experimental, adicionado na v19.2.0. Na prática, isso significa que ele é adequado como loop de desenvolvimento local e deve ficar fora dos scripts de CI, que de qualquer forma não precisam dele.
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch"
}
}
A Cobertura Ainda Está Atrás de uma Flag
A cobertura de código ainda exige --experimental-test-coverage, então um pipeline com gate de cobertura está optando por uma superfície instável. Delimite o que é medido com os globs --test-coverage-include e --test-coverage-exclude, e gere saída legível por máquina para o CI com o reporter lcov:
node --test --experimental-test-coverage \
--test-coverage-include='src/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info
Também é possível impor limiares, via --test-coverage-lines, --test-coverage-branches e --test-coverage-functions, ou através das opções equivalentes lineCoverage, branchCoverage e functionCoverage da API programática run(). Os demais reporters embutidos são spec (o padrão), tap, dot e junit.
Mocking: Spies, Timers e a Barreira dos Exports Congelados
O objeto mock de node:test cobre spies (mock.fn), stubs de métodos (mock.method) e fake timers (mock.timers). Não existe mockResolvedValue; você faz stub de resultados assíncronos com um mockImplementation async. As asserções leem de mock.callCount() e mock.calls[n].arguments em vez de matchers:
import { test } from 'node:test';
import assert from 'node:assert';
test('spy records calls', (t) => {
const fn = t.mock.fn();
fn('a');
assert.strictEqual(fn.mock.callCount(), 1);
assert.deepStrictEqual(fn.mock.calls[0].arguments, ['a']);
});
Os fake timers estão estáveis desde a v23.1.0 e mockam setTimeout, setInterval, setImmediate e Date, avançando com tick() ou runAll(). Há uma lacuna que vale conhecer: se você extrair um timer de um módulo por desestruturação, como em import { setTimeout } from 'node:timers', o mock não se aplicará a ele.
test('advances mocked time and Date together', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100 });
const fn = t.mock.fn();
setTimeout(fn, 200);
t.mock.timers.tick(200);
assert.strictEqual(fn.mock.callCount(), 1);
assert.strictEqual(Date.now(), 300);
});
A restrição real é o mock de módulos. Os exports de ES modules são congelados, então mock.method não consegue substituir um named export; a solução duradoura é exportar um objeto e mockar o método nele:
// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }
// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
fetchUser(id) { /* ... */ },
};
O Node oferece, sim, uma alternativa oficial, mock.module(), que mocka módulos ESM, CJS, JSON e builtin, mas ela fica atrás de --experimental-test-module-mocks, em estabilidade de estágio inicial. Use-a para experimentar, não para sustentar uma suíte de CI.
TypeScript Sem Loader
O Node executa arquivos de teste .ts, .mts e .cts diretamente através do type stripping, que vem habilitado por padrão (desde a v23.6.0 e a v22.18.0) e é estável desde a v24.12.0, ou seja, estável na linha LTS 24.x. O test runner corresponde automaticamente aos padrões de arquivos TypeScript, a menos que você passe --no-strip-types. A antiga receita de acoplar um loader como o tsx, descrita no post de migração da era Node 20 de Mehul Kar, já é história no que diz respeito à execução de testes — embora o stripping apenas remova tipos, de modo que enum e outras sintaxes de TS com efeito em runtime ainda exijam uma transformação.
O Que Você Abre Mão em Relação ao Jest e ao Vitest?
O trade-off honesto é de ergonomia, não de capacidade. Três perdas são reais. Primeira, o ecossistema de matchers: o expect do Jest oferece toHaveBeenNthCalledWith e centenas de matchers da comunidade, enquanto o node:assert deixa você compondo asserções a partir de deepStrictEqual e mock.calls. O ponto de extensão é o assert.register(), adicionado nas v23.7.0 e v22.14.0, que permite definir asserções customizadas no contexto de teste. Segunda, ambientes semelhantes ao navegador: não há equivalente a jsdom ou happy-dom, então testes de componentes que tocam o DOM devem permanecer no Vitest ou no Jest. Terceira, conveniência nos stubs: nada de mockResolvedValue, nada de test.each (um loop for...of resolve), e o stub por chamada passa por mockImplementationOnce em vez de helpers encadeados. O guia de migração de Erick Wendel mapeia essas traduções par a par, embora sua seção sobre fake timers seja anterior à chegada da API mock.timers e se leia como uma proposta em rascunho.
Onde Isso Deixa uma Suíte em Migração?
Para um serviço Node, uma CLI ou uma biblioteca que nunca toca o DOM, o runner nativo cobre o núcleo estável do que o Jest fazia, com zero dependências e sem camada de transformação; as bordas experimentais remanescentes são cobertura, watch mode, mock de módulos e tags. Um caminho de baixo risco é converter um pacote, manter o gate de cobertura no ferramental existente até a flag ser removida e reescrever as asserções pesadas em matchers conforme você as encontrar. Rode node --test contra um único arquivo convertido e veja quanto do seu diretório de configuração você consegue apagar.
Perguntas Frequentes
O node --test executa arquivos de teste em paralelo?
Sim. O isolamento por processo é o padrão, então cada arquivo de teste ganha seu próprio processo filho, e --test-concurrency define quantos deles podem rodar ao mesmo tempo. Dentro de um único arquivo, os testes ainda rodam um após o outro, a menos que você defina uma opção de concorrência em test ou describe. Se suas suítes compartilham um banco de dados, uma porta ou estado global, --test-concurrency=1 mantém a execução em um arquivo por vez.
Posso rodar Jest e node:test lado a lado durante uma migração?
Sim. Os runners são independentes, então você pode manter scripts npm separados e migrar arquivo por arquivo. O detalhe é a sobreposição na descoberta: ambos correspondem a arquivos como *.test.js por padrão, então delimite cada runner com globs explícitos, diretórios separados ou a configuração testMatch do Jest, para evitar que arquivos convertidos rodem duas vezes ou que arquivos não convertidos falhem sob node --test.
O node:test funciona com projetos CommonJS?
Sim. O runner é agnóstico quanto ao sistema de módulos: require('node:test') e require('node:assert') funcionam em arquivos CommonJS, e os padrões de descoberta padrão incluem explicitamente .cjs ao lado de .mjs e .js. O único requisito é o esquema node:, de modo que require('test') ou import test from 'test' falha. Uma base de código mista pode rodar arquivos de teste ESM e CJS na mesma invocação de node --test.
Qual versão do Node devo mirar para adotar o node:test no CI?
O Node 24 LTS cobre o núcleo estável: o runner (estável desde a v20.0.0), snapshot testing, fake timers com mock.timers e type stripping de TypeScript por padrão. Cobertura e watch mode permanecem experimentais em todas as linhas de release. Dois recursos mais novos do test runner chegaram à 24.x por backport em vez de ficarem apenas na linha current — tags de teste na v24.19.0 e randomização da ordem de execução na v24.16.0 —, mas ambos estão em estágio inicial, então ainda não construa gates de CI sobre eles.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k