jsdom ou happy-dom? Vantagens, desvantagens e um benchmark que você consegue reproduzir

Todo projeto front-end com Vitest chega nessa pergunta em algum momento: environment: 'jsdom' ou environment: 'happy-dom'?
A resposta que circula por aí é "happy-dom é 2 a 4 vezes mais rápido". Fui atrás de onde vem esse número e, na maioria dos artigos, ele não tem código, não tem metodologia e não tem versão. Então resolvi medir.
Montei uma suíte com 240 testes de componentes React, rodei nos dois ambientes, em configurações diferentes do Vitest, várias vezes. Também testei 20 APIs e comportamentos do navegador nos dois pra ver o que cada um realmente implementa. O resultado tem uma surpresa: a configuração do Vitest pesa mais que a escolha da biblioteca.
O que os dois fazem
Nenhum dos dois é um navegador. Os dois são implementações do DOM em JavaScript puro, pra rodar no Node. Seu teste chama document.querySelector, element.click(), addEventListener, e alguém precisa responder. É isso que eles fazem.
- jsdom: o veterano. Existe há mais de 15 anos, é o ambiente de DOM tradicional do Jest e segue de perto as especificações do WHATWG. O foco é estar correto.
- happy-dom: mais novo, nasceu com o foco em velocidade pra testes. Implementa várias APIs que o jsdom não tem, às vezes de forma simplificada.
O que nenhum dos dois faz: layout. Não existe cálculo de posição e tamanho dos elementos. getBoundingClientRect() devolve tudo zero nos dois, e o README do jsdom diz isso com todas as letras: layout e navegação estão fora do escopo do projeto.
Versões usadas neste artigo: jsdom 30.1.1, happy-dom 20.14.5, Vitest 5.0.3, React 19.3, Testing Library 16.3, Node 22.
O benchmark
Metodologia
Pra ser útil, o benchmark precisava parecer um projeto real, não um loop de createElement. Então:
- 5 componentes React comuns: formulário de login com validação, lista de tarefas com filtro, tabela com 300 linhas, busca e ordenação, modal com portal e foco, e abas com navegação por teclado.
- 8 testes cobrindo esses componentes com Testing Library e
user-event(digitar, clicar, teclado). - 30 arquivos de teste com essa mesma suíte, total de 240 testes. Muitos arquivos é o cenário em que o custo de criar o ambiente aparece.
- 5 execuções de cada combinação, alternando a ordem entre jsdom e happy-dom a cada rodada.
- Máquina: 2 vCPUs (Intel Xeon 2.1 GHz), 8 GB de RAM, Linux.
Os 240 testes passaram nos dois ambientes, em todas as execuções. Isso importa: benchmark em que um lado falha não vale.
O código completo está no GitHub: Tautorn/jsdom-vs-happy-dom-bench. Pra rodar na sua máquina, é só npm install e npm run bench.
Um trecho dos testes, pra dar uma ideia:
it('filtra pela busca', async () => {
const user = userEvent.setup()
render(<DataTable rows={rows} />)
await user.type(screen.getByLabelText('buscar'), 'Pessoa 29')
expect(screen.getByText('10 resultados')).toBeInTheDocument()
})
Resultado
Mediana de 5 execuções, tempo total reportado pelo Vitest:
| Configuração do Vitest | jsdom | happy-dom | Diferença |
|---|---|---|---|
| Padrão (isolamento por arquivo) | 71,9 s | 44,4 s | happy-dom 1,6x mais rápido |
isolate: false | 28,5 s | 18,9 s | happy-dom 1,5x mais rápido |
A variação entre execuções foi pequena: no pior caso, menos de 1,3 segundo entre a mais rápida e a mais lenta.
Três coisas pra tirar daqui:
1. O happy-dom é mais rápido, mas não 4x. Na suíte com componentes de verdade, a diferença foi de 1,5x a 1,6x. Os números de "4x" que circulam geralmente vêm de microbenchmarks, não de testes reais.
2. Boa parte da diferença está na criação do ambiente. No modo padrão, o Vitest cria um ambiente novo pra cada arquivo de teste. O próprio Vitest reportou que criar o jsdom consumiu 28% do tempo da suíte, contra 19% do happy-dom. Com isolate: false, isso cai pra 2% e 1%.
3. A configuração pesa mais que a biblioteca. Olha de novo a tabela: jsdom com isolate: false (28,5 s) foi mais rápido que happy-dom no modo padrão (44,4 s). Se o seu problema é tempo de CI, mudar a configuração do Vitest rende mais do que trocar de biblioteca. E dá pra fazer as duas coisas.
// vitest.config.js
export default defineConfig({
test: {
environment: 'happy-dom',
isolate: false, // reaproveita o ambiente entre arquivos
},
})
isolate: false faz os arquivos de teste compartilharem o mesmo ambiente. Se algum teste deixa estado global pra trás (mock que não foi restaurado, localStorage sujo, timer pendurado), outro arquivo pode quebrar ou, pior, passar por acaso. Funciona bem em suítes disciplinadas com cleanup e vi.restoreAllMocks(). Teste antes de ligar no CI.
Microbenchmarks
Também medi algumas operações isoladas, fora do Vitest:
| Operação | jsdom | happy-dom |
|---|---|---|
| Importar a biblioteca | 537 ms | 262 ms |
Criar um window/document | 6,3 ms | 2,3 ms |
innerHTML de uma tabela com 2.000 linhas | 176 ms | 159 ms |
createElement + appendChild x1.000 | 5,3 ms | 9,6 ms |
Ler textContent da tabela | 1,5 ms | 2,9 ms |
Repara que o happy-dom não ganha em tudo. Criar elementos e ler texto foi mais rápido no jsdom. Onde o happy-dom ganha com folga é exatamente no que mais se repete numa suíte: carregar a biblioteca e criar o ambiente.
Dois microbenchmarks ficaram de fora porque o resultado não era confiável: querySelectorAll variou demais entre execuções no happy-dom, e o teste de eventos estava medindo, sem querer, a navegação do link no jsdom. Prefiro mostrar menos número do que número errado.
Compatibilidade: o que cada um implementa
Velocidade não adianta se o teste não roda. Testei 20 APIs e comportamentos nos dois ambientes:
| API / comportamento | jsdom | happy-dom |
|---|---|---|
getComputedStyle lendo CSS de <style> | ✅ | ✅ |
Seletor :has() | ✅ | ✅ |
Validação de formulário (checkValidity, validity) | ✅ | ✅ |
| Submit bloqueado por campo inválido | ✅ | ✅ |
valueAsNumber em type="number" | ✅ | ✅ |
| Custom Elements + Shadow DOM | ✅ | ✅ |
MutationObserver | ✅ | ✅ |
Range / Selection | ✅ | ✅ |
innerText | ❌ | ✅ |
dialog.showModal() | ❌ | ✅ |
matchMedia | ❌ | ✅ |
ResizeObserver | ❌ | ✅ |
IntersectionObserver | ❌ | ✅ |
element.animate() | ❌ | ✅ |
element.scrollIntoView() | ❌ | ✅ |
navigator.clipboard | ❌ | ✅ |
fetch no window | ❌ | ✅ |
Clique no <label> foca o input | ❌ | ❌ |
getBoundingClientRect com tamanho real | ❌ | ❌ |
canvas.getContext('2d') | ❌ | ❌ |
Isso derruba outro mito: o happy-dom não é "menos completo" em tudo. Em quantidade de APIs disponíveis, ele tem mais. matchMedia, ResizeObserver e IntersectionObserver são exatamente as APIs que todo projeto com jsdom precisa mockar na mão:
// O mock que todo projeto com jsdom tem em algum lugar
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
})
Mas cuidado com a leitura dessa tabela: existir não é funcionar igual ao navegador. Sem layout, o IntersectionObserver do happy-dom não tem como saber se um elemento está visível de verdade. O ResizeObserver não tem tamanho real pra reportar. A API está lá, o teste não quebra, mas o comportamento é simplificado.
O jsdom vai pelo caminho oposto: implementa menos, mas o que implementa segue a especificação de perto. Um bom exemplo apareceu enquanto eu montava o benchmark. Meu formulário tinha type="email", e o teste que digitava um e-mail inválido falhou no jsdom: o navegador bloqueia o submit de um campo type="email" inválido antes de chamar o seu onSubmit, e o jsdom fez exatamente a mesma coisa. Era o meu teste que estava errado, não o jsdom. (O happy-dom também bloqueou, no teste isolado.)
Segurança: um ponto a favor do jsdom
Em 2025 o happy-dom teve uma vulnerabilidade séria, a CVE-2025-61927: código JavaScript rodando dentro do ambiente conseguia escapar do contexto de VM e executar código no processo do Node. Afetava todas as versões até a 19, e a correção na v20 foi desligar a execução de JavaScript por padrão.
Pra testes de componentes isso quase não muda nada, porque o seu código de teste roda normalmente. O que muda é que scripts dentro de HTML (<script> num innerHTML, por exemplo) não executam mais, a menos que você ligue a opção enableJavaScriptEvaluation.
Duas recomendações práticas:
- Se usa happy-dom, esteja na v20 ou mais nova.
- Não use nenhum dos dois pra processar HTML de terceiros com scripts habilitados. O próprio README do jsdom avisa que rodar código não confiável com
runScriptsé, na prática, rodar código não confiável no Node.
Quando usar cada um
Use happy-dom quando:
- A suíte é grande, com muitos arquivos, e o tempo de CI incomoda.
- São testes de componente típicos: renderizar, clicar, digitar, verificar texto e atributo.
- Você está cansado de mockar
matchMedia,ResizeObservereIntersectionObserver. - Usa
dialog.showModal()ouinnerTextnos componentes.
Use jsdom quando:
- Precisa que o comportamento seja o mais próximo possível da especificação, como em bibliotecas de componentes, formulários complexos e acessibilidade.
- Já tem uma suíte grande estável em jsdom. Migrar só pela velocidade pode trazer falhas sutis, e
isolate: falsetalvez já resolva o seu problema de tempo. - O projeto usa Jest, onde o jsdom é o caminho padrão e mais testado.
- Faz scraping ou processamento de HTML no servidor, onde fidelidade importa mais que velocidade.
Não use nenhum dos dois quando o teste depende de layout, scroll real, CSS aplicado visualmente, canvas ou animação. Aí o lugar é o navegador de verdade: Vitest Browser Mode ou Playwright.
Dá pra misturar
Você não precisa escolher um só pro projeto inteiro. O Vitest permite trocar o ambiente por arquivo, com um comentário no topo:
// @vitest-environment jsdom
import { render } from '@testing-library/react'
// este arquivo roda em jsdom, o resto do projeto em happy-dom
Uma estratégia que funciona bem: happy-dom como padrão pela velocidade, e jsdom nos arquivos que testam algo onde a fidelidade importa.
Como migrar de jsdom pra happy-dom
Se decidir testar:
npm i -D happy-dom
// vitest.config.js
export default defineConfig({
test: { environment: 'happy-dom' },
})
E rode a suíte. O que costuma aparecer:
- Mocks que viraram desnecessários. Os de
matchMedia,ResizeObservereIntersectionObserverpodem ser removidos, mas confira se o teste depende do comportamento mockado. - Diferenças em texto e espaços.
innerTextetextContentpodem normalizar espaço de forma diferente. Prefira os matchers da Testing Library (toHaveTextContent) a comparar strings exatas. - Testes que passavam por acaso. Qualquer diferença de comportamento expõe teste frágil. Corrija o teste, não volte pro jsdom no automático.
Se algum arquivo específico não se adaptar, use o comentário // @vitest-environment jsdom nele e siga em frente.
Conclusão
- O happy-dom é mais rápido, cerca de 1,5x numa suíte real de componentes React. Não 4x.
- A maior parte do ganho vem de criar o ambiente, que se repete a cada arquivo de teste.
isolate: falserende mais que trocar de biblioteca: jsdom sem isolamento foi mais rápido que happy-dom com isolamento.- happy-dom tem mais APIs; jsdom é mais fiel ao que implementa. Escolha com base no que seus testes usam.
- Nenhum dos dois faz layout. Pra isso, navegador de verdade.
- Se usar happy-dom, use a v20+.
Minha escolha pra projeto novo com Vitest: happy-dom como padrão, isolate: false se a suíte for disciplinada, e jsdom por arquivo quando precisar. Pra projeto existente em jsdom, eu começaria testando o isolate: false antes de migrar qualquer coisa.
E desconfie de benchmark sem metodologia, inclusive o meu. Cada suíte é diferente. O código está aqui: rode na sua máquina, troque os componentes pelos seus e veja o que acontece.
