Testando componentes no Angular com Vitest: TestBed, jsdom e browser mode

Depois que você decidiu migrar, a surpresa é o quão pouco seus testes de componente mudam: Vitest é o runner padrão desde o Angular v21, mas TestBed e ComponentFixture são a mesma API. O que muda é o DOM por baixo. O runner usa jsdom, que não tem layout engine, então alguns specs precisam do browser mode do Vitest e de um browser real no lugar.

O TestBed não mudou, o runner mudou

O medo quando um projeto troca de test runner é que todo spec de componente precise de reescrita. Com testes de componente, na maioria das vezes não é assim. O builder de Vitest do Angular roda o mesmo TestBed, o mesmo TestBed.createComponent e o mesmo ComponentFixture que você já usa. O corpo do spec que cria um componente, lê componentInstance, consulta nativeElement e verifica um texto é idêntico no Vitest.

O que o ng new mudou foi o aparato em volta desse corpo, não o corpo. No Angular v21 o test target é o builder @angular/build:unit-test, describe/it/expect vêm do vitest/globals, e os testes rodam num DOM simulado. A API de testar componente é a parte que continuou igual.

Então a pergunta útil não é "como reescrevo meus testes de componente", e sim "quais dos meus testes de componente tocam em algo que o DOM simulado não consegue fazer". Essa lista é bem mais curta, e é dela que este artigo trata.

Um teste de componente, sem mudança no Vitestts
import { TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
  it('incrementa a contagem no clique', async () => {
    const fixture = TestBed.createComponent(CounterComponent);
    await fixture.whenStable();

    const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
    button.click();
    await fixture.whenStable();

    expect(fixture.nativeElement.querySelector('.count').textContent).toContain('1');
  });
});

O DOM por baixo dos seus testes é jsdom, não o Chrome

Tem um fato só que já explica a maioria das surpresas. Por padrão, o runner do Vitest não abre um browser: ele roda seu componente no jsdom, uma implementação do DOM em JavaScript. A doc do Angular diz direto: "To simulate the browser's DOM, Vitest uses a library called jsdom." O CLI também detecta o happy-dom sozinho e usa ele se você instalar, mas num projeto recém-criado com ng new o ambiente é o jsdom.

O jsdom dá conta de uma boa parte dos testes de componente: estrutura, conteúdo de texto, atributos, classes, handlers de evento, inputs e outputs, e a ligação entre template e classe. Nada disso precisa de layout engine. O problema só começa quando um teste faz ao DOM uma pergunta que o jsdom não sabe responder.

E o jsdom é honesto sobre o limite. Ele mesmo se descreve assim: "does not have the capability to render visual content, and will act like a headless browser by default". Não há layout de CSS, então getBoundingClientRect() e offsetTop devolvem zeros. Um <canvas> se comporta como uma <div>, a menos que você adicione o pacote canvas como peer dependency. Um teste que mede, faz scroll ou lê geometria computada está testando um browser que o jsdom não finge ser.

O que o teste de componente precisajsdomPor quê
Estrutura renderizada, texto, classes, atributosFuncionaÁrvore de DOM sem layout.
Clique, input, foco, eventos de outputFuncionaEventos disparam sem um renderer.
Inputs, outputs, ligação template/classeFuncionaSem geometria de browser envolvida.
getBoundingClientRect, offsetTop, tamanhosDevolve zerosSem layout engine para computar geometria.
CSS de verdade, media queries, estilos computadosNão aplicaO jsdom faz parse do CSS mas não o posiciona.
Desenho e leitura de pixel em <canvas>Age como uma <div>Precisa do pacote canvas como peer, e mesmo assim não é uma GPU.
Posição de scroll, geometria de IntersectionObserverPouco confiávelDepende do layout que o jsdom não roda.

Quando o jsdom não basta: o browser mode

Para os specs que falham na tabela acima, a resposta do Angular é o browser mode do Vitest: o mesmo teste com TestBed, mas renderizado num browser real conduzido por Playwright ou WebdriverIO. O guia de migração apresenta isso como a opção para "tests that rely on browser-specific APIs (like rendering) or for debugging". Você ativa por projeto instalando um provider e adicionando a opção browsers no target test do angular.json, ou passando --browsers na linha de comando.

O provider é uma dependência de verdade. O @vitest/browser-playwright te dá chromium, firefox e webkit; o @vitest/browser-webdriverio dirige o chrome e os outros via WebDriver. Os testes rodam com janela visível por padrão e mudam para headless quando a variável de ambiente CI está setada, que é o comportamento que você quer: olhar localmente, rodar quieto no pipeline.

Eu não viraria a suíte inteira para browser mode. Um browser real é mais lento para subir e mais pesado no CI que o jsdom, e a maioria dos specs de componente não ganha nada com ele. O critério que aplico é estreito: recorro ao browser mode só para os specs que leem geometria real ou desenham num canvas, e deixo o resto no jsdom. A ideia é ter dois ambientes por intenção, não um ambiente por medo do outro.

Ativar o browser mode num projeto (provider Playwright)bash
npm install --save-dev @vitest/browser-playwright
npx playwright install chromium

# No angular.json, dentro das options do target test:
#   "browsers": ["chromium"]
# Ou por rodada, sem editar o angular.json:
ng test --browsers=chromium

Change detection no teste: detectChanges, OnPush e zoneless

A outra coisa que vale saber é como o teste avisa o Angular de que o estado mudou. O reflexo antigo é o fixture.detectChanges(). Ele ainda funciona, e para uma suíte grande que já existe a doc do Angular é explícita ao dizer que converter para outra coisa provavelmente "not worth the effort". Mas não é o padrão para onde o time aponta os testes novos.

A forma recomendada é await fixture.whenStable(). O raciocínio no guia de zoneless é que o detectChanges() "forces change detection to run when Angular might otherwise have not scheduled change detection", então o teste deixa de se parecer com produção. Deixar o Angular decidir quando sincronizar, e esperar a estabilidade, fica mais perto de como o componente se comporta quando vai para produção. Os exemplos deste artigo usam whenStable() por isso.

Se você testa um componente em modo zoneless, adicione provideZonelessChangeDetection() ao TestBed.configureTestingModule. Deixe esse provider de fora enquanto o zone.js ainda está nos seus polyfills: pedir zoneless com o zone.js ainda presente é o que faz o Angular lançar NG0908: In this configuration Angular requires Zone.js. Com OnPush, que é a estratégia padrão no Angular 22, a disciplina é a mesma: notifique o Angular da mudança (uma escrita de signal, um input, um evento) e espere a estabilidade, em vez de apelar para um detectChanges() manual que esconde uma notificação que faltou.

Um teste de componente zoneless e OnPushts
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CartBadgeComponent } from './cart-badge.component';

describe('CartBadgeComponent (zoneless, OnPush)', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideZonelessChangeDetection()],
    });
  });

  it('mostra a contagem de itens vinda do signal input', async () => {
    const fixture = TestBed.createComponent(CartBadgeComponent);
    fixture.componentRef.setInput('count', 3);
    await fixture.whenStable();

    expect(fixture.nativeElement.textContent).toContain('3');
  });
});

O que muda vindo do Karma

No Karma os testes rodavam num Chrome real que o launcher subia. Esse é o hábito a desaprender: por padrão, o Vitest não sobe. Um spec que passava no Karma porque o Chrome por acaso posicionou o DOM, mediu um elemento ou renderizou um canvas pode falhar no jsdom por um motivo que não tem nada a ver com o seu componente. Aí a saída não é desconfiar da migração, e sim reconhecer aquele spec como candidato a browser mode e mover só ele.

Os helpers de tempo são o outro hábito do Karma que não sobrevive à mudança. fakeAsync, tick, flush e waitForAsync se apoiam numa Zone que o builder do Vitest não monta em volta de cada teste, então lançam o erro de ProxyZone. Isso é um trabalho à parte, com seus dois caminhos, tratado em fakeAsync e o erro ProxyZone. Para o lado de config da mudança, e por que o test target é uma linha, veja De Karma para Vitest: a config que realmente roda. E antes de tudo isso, se vale migrar uma suíte específica é uma decisão por si só.

A API de testar componente sobreviveu à mudança quase intacta. O trabalho aqui não é reescrever testes, e sim separá-los pelo que o DOM simulado consegue responder e mandar a lista curta de specs de geometria e canvas para um browser real.

Artefato para reaproveitar

jsdom vs browser mode: qual ambiente para este teste de componente

  • Comece pelo jsdom. Estrutura, texto, classes, atributos, eventos, inputs, outputs e a ligação template/classe rodam ali sem precisar de browser.
  • Mova um spec para o browser mode quando ele lê geometria real: getBoundingClientRect, offsetTop, posição de scroll ou IntersectionObserver baseado em layout, tudo que o jsdom devolve como zeros.
  • Mova um spec para o browser mode quando ele desenha ou lê de um <canvas>, ou depende de CSS computado e media queries que o jsdom faz parse mas nunca posiciona.
  • Mantenha o browser mode por spec, não por suíte: instale o @vitest/browser-playwright, defina browsers no angular.json, e deixe rodar headless no CI enquanto o resto fica no jsdom.
  • Em qualquer ambiente, notifique o Angular da mudança e use await fixture.whenStable() em vez de forçar detectChanges(); adicione provideZonelessChangeDetection() ao TestBed para componentes zoneless ou OnPush.

Perguntas frequentes

O TestBed ainda funciona com Vitest no Angular?

Funciona. O builder de Vitest do Angular roda a mesma API de TestBed, TestBed.createComponent e ComponentFixture de antes. Um spec de componente que cria um fixture, consulta nativeElement e verifica texto ou eventos não muda quando você troca o runner para Vitest. O que muda é o ambiente de DOM por baixo e os helpers de tempo, não a API de testar componente.

Qual ambiente de DOM o runner Vitest do Angular usa por padrão?

jsdom. A doc do Angular diz que "to simulate the browser's DOM, Vitest uses a library called jsdom". O CLI detecta o happy-dom sozinho e usa ele se você instalar, mas um projeto recém-criado com ng new roda os testes em jsdom, que não tem layout engine e não renderiza conteúdo visual.

Quando preciso do browser mode do Vitest em vez do jsdom para um componente Angular?

Quando o teste depende de algo que o jsdom não faz: layout e geometria reais (getBoundingClientRect, offsetTop, scroll, IntersectionObserver), CSS aplicado e media queries, ou desenho em canvas. O jsdom devolve zeros para layout e trata <canvas> como uma <div>. Para esses specs, instale um provider como o @vitest/browser-playwright e defina a opção browsers para eles rodarem num browser real.

Devo chamar fixture.detectChanges() ou await fixture.whenStable() num teste Vitest?

O fixture.detectChanges() ainda funciona, e a doc do Angular diz que converter uma suíte grande existente para fora dele provavelmente não compensa. Para testes novos, prefira await fixture.whenStable(): o detectChanges() força o change detection quando o Angular podia não ter agendado, então o teste se distancia do comportamento de produção. Para componentes zoneless ou OnPush, notifique o Angular da mudança e espere a estabilidade.

Como testo um componente Angular zoneless ou OnPush no Vitest?

Adicione provideZonelessChangeDetection() ao TestBed.configureTestingModule, dirija o componente por notificações reais (uma escrita de signal, um input via setInput, um evento) e use await fixture.whenStable(). Se o zone.js ainda está nos seus polyfills e você pede zoneless sem o provider, o Angular lança NG0908: In this configuration Angular requires Zone.js.

Fontes consultadas

Modern Angular Playbook

Este artigo é um play.

O Playbook do Angular Moderno reúne o diagnóstico, a matriz de adoção, onze jogadas e o plano de 30 dias. Grátis, em inglês e português.

Você recebe os dois PDFs por email, pela lista do Dojo IA.

Abrir playbook →

André Ramosdisponível para vagas remotas, UTC−3Entre em contato →

Leia também

Guia · 6 min

Quando migrar uma suíte Angular para Vitest

Vitest virou o test runner padrão do Angular CLI, mas migrar uma suíte existente ainda é experimental: quais specs migrar primeiro, o que o fakeAsync quebra, e como manter o CI confiável.

Ler artigo →

Guia · 7 min

De Karma para Vitest no Angular: a config que realmente roda

O que o test target de Vitest do Angular v21 realmente é (uma linha), de onde vêm polyfills e styles, o install que tira o Karma, e as poucas opções que valem adicionar.

Ler artigo →

Guia · 9 min

fakeAsync e o erro ProxyZone: consertando testes Angular no Vitest

Por que fakeAsync, tick e waitForAsync lançam o erro ProxyZone no runner Vitest do Angular, e as duas correções: o patch experimental de zone testing, ou a reescrita para fake timers do Vitest.

Ler artigo →