Mockando serviços e HttpClient no Angular com Vitest: vi.fn, spies e DI

Vitest é o test runner padrão do Angular desde a v21 (estável, novembro/2025). A primeira coisa que você pega depois da migração é o spy, e os nomes mudaram: jasmine.createSpy virou vi.fn, spyOn virou vi.spyOn. Mas o mock idiomático no Angular nunca foi o spy. É um fake entregue pela DI.

Os nomes dos spies mudaram, a ideia não

O primeiro teste que você abre depois de migrar para Vitest costuma ser o que tem um spy dentro, e o spy não compila. O jasmine.createSpy sumiu, o spyOn(...).and.returnValue(...) sumiu, o expect(spy).toHaveBeenCalledTimes continua igual mas o spy.calls.count() não. O reflexo é achar que o framework de teste ficou mais difícil. Não ficou. A maior parte da superfície é um rename de um para um, e o schematic refactor-jasmine-vitest do Angular 22 faz a passada mecânica por você.

O guia oficial de migração nomeia as conversões direto: ele Replaces jasmine.createSpy with vi.fn, Converts spyOn calls to the equivalent vi.spyOn, e Replaces jasmine.objectContaining with expect.objectContaining. Leia a tabela abaixo como o mapa que o schematic segue, e a próxima seção como a única decisão que o schematic não consegue tomar por você.

JasmineVitestNota
jasmine.createSpy('name')vi.fn()Uma função mock isolada, com rastreio de chamadas.
jasmine.createSpyObj('svc', ['load']){ load: vi.fn() }Um objeto literal de vi.fn()s. Sem factory.
spyOn(obj, 'method')vi.spyOn(obj, 'method')Default diferente: veja a próxima seção.
.and.returnValue(v).mockReturnValue(v).mockReturnValueOnce(v) para uma chamada só.
.and.resolveTo(v).mockResolvedValue(v)Retorna uma Promise resolvida.
.and.callFake(fn).mockImplementation(fn)Troca o corpo pelo seu.
.and.callThrough()(default do vi.spyOn)O vi.spyOn já chama o método real.
.and.stub().mockImplementation(() => {})O Jasmine stuba por padrão; o Vitest não.
spy.calls.count()spy.mock.calls.lengthArray dos arrays de argumentos por chamada.
spy.calls.argsFor(0)spy.mock.calls[0]Argumentos da primeira chamada.
spy.calls.mostRecent().argsspy.mock.lastCallArgumentos da última chamada.
spy.calls.reset()spy.mockClear()Limpa o histórico, mantém a implementação.
jasmine.any(String)expect.any(String)Matcher assimétrico nas assertions.

O único rename que muda comportamento

Trocar spyOn por vi.spyOn parece um rename puro. É a única linha daquela tabela que muda o que o teste faz. A estratégia padrão de spy do Jasmine é and.stub(): um spyOn(service, 'save') simples troca o método por um no-op que retorna undefined. O vi.spyOn do Vitest faz o contrário por padrão. Ele envolve o método real e chama ele de verdade, então o código original continua rodando, a menos que você adicione .mockReturnValue ou .mockImplementation.

Essa diferença é silenciosa e morde. Um teste Jasmine que espionava o save() para impedir uma chamada HTTP real de disparar vai, depois de um rename literal para vi.spyOn, deixar essa chamada disparar de novo. O teste pode até passar enquanto bate num backend real ou estoura fundo dentro de uma dependência que o teste nunca quis exercitar. Essa é a decisão que o schematic não consegue tomar: ele converte a sintaxe, mas não tem como saber que você contava com o stub padrão do Jasmine.

Então, quando reviso um diff de spyOn para vi.spyOn, a única pergunta que faço é: esse spy queria suprimir o método real? Se a resposta é sim, a conversão está incompleta até ter um .mockReturnValue(...) ou .mockImplementation(() => {}) explícito. Eu não daria merge num vi.spyOn cru em cima de um método com efeito colateral.

Mesma intenção, três spies do Vitestts
import { vi } from 'vitest';

// 1. Fake isolado (era jasmine.createSpy): retorna o que você mandar.
const load = vi.fn().mockReturnValue(of([{ id: 1 }]));

// 2. Spy que SUPRIME o método real (default do Jasmine; do Vitest não).
const svc = TestBed.inject(OrdersService);
vi.spyOn(svc, 'save').mockResolvedValue({ ok: true });

// 3. Spy que só OBSERVA e deixa o método real rodar (default do Vitest).
const audit = vi.spyOn(svc, 'audit');
svc.confirm();
expect(audit).toHaveBeenCalledWith('confirm');

O mock idiomático no Angular é um provider de DI, não um spy

Spy é a resposta quando você quer observar ou dobrar um método de um objeto que você já tem em mãos. É a ferramenta errada para substituir um colaborador inteiro. No Angular, a unidade sob teste recebe os colaboradores pelo injector, então o lugar natural de colocar um fake é o mesmo: o array de providers. Você entrega ao TestBed um objeto fake e manda usar ele no lugar do serviço real.

O fake é um objeto comum cujos métodos são vi.fn()s. Você não roda vi.spyOn numa instância real, não constrói o serviço de verdade e não arrasta junto nenhuma dependência transitiva. O par provide/useValue é a mesma forma que o guia de testes do Angular mostra para stubar uma dependência, e soa familiar para quem escreve teste Angular desde 2017. O vi.fn() só entra no lugar do jasmine.createSpy() lá dentro.

Eu prefiro isso ao mock no nível do módulo para qualquer serviço que o componente ou o serviço sob teste injeta, porque mocka na fronteira que a aplicação de fato usa (o injector), em vez de passar por cima dela direto no sistema de módulos. E mantém o fake tipado contra a interface real, então um rename de método quebra o teste em vez de passar quieto.

Serviço fake pela DI, métodos são vi.fn()ts
import { vi } from 'vitest';
import { TestBed } from '@angular/core/testing';

// Um fake tipado: cada método é um vi.fn(). Tipado contra o serviço real,
// então uma mudança de assinatura em OrdersService falha aqui em vez de passar.
const ordersMock: Partial<Record<keyof OrdersService, ReturnType<typeof vi.fn>>> = {
  load: vi.fn().mockReturnValue(of([{ id: 1, total: 90 }])),
  save: vi.fn().mockResolvedValue({ ok: true }),
};

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [{ provide: OrdersService, useValue: ordersMock }],
  });
});

it('carrega pedidos no init', () => {
  const facade = TestBed.inject(OrdersFacade);
  facade.init();
  expect(ordersMock.load).toHaveBeenCalledOnce();
});

HttpClient: o backend de teste não mudou

A preocupação mais comum depois da troca de runner são os testes de HTTP, e é a mais deslocada. O provideHttpClientTesting e o HttpTestingController são APIs do Angular, não do Karma. Eles nunca dependeram do test runner, então funcionam no Vitest exatamente como funcionavam no Karma. Nada na camada que casa as requisições mudou.

Para um teste de HTTP simples, o único provider que você precisa é o provideHttpClientTesting(). No momento em que você testa configuração do cliente (um interceptor, por exemplo) você adiciona o provideHttpClient(...) também, e a ordem importa: a doc é explícita que o provideHttpClient() precisa vir antes do provideHttpClientTesting(), porque o provider de teste sobrescreve partes do real. O HttpTestingController injetado te dá expectOne, match, expectNone, o flush e o error da requisição, e o verify para garantir que nenhuma requisição ficou sem resposta.

Eu mantenho o httpTesting.verify() no afterEach de todo spec de HTTP. É a única assertion que pega uma requisição que o componente disparou e o teste esqueceu de esperar, que é justamente o tipo de regressão que um serviço mockado esconderia.

Teste de HttpClient no Vitest: as mesmas APIs do Karmats
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';

let httpTesting: HttpTestingController;

beforeEach(() => {
  TestBed.configureTestingModule({
    // provideHttpClient(...) primeiro, depois o provider de teste sobrescreve o backend.
    providers: [provideHttpClient(), provideHttpClientTesting()],
  });
  httpTesting = TestBed.inject(HttpTestingController);
});

afterEach(() => httpTesting.verify()); // nenhuma requisição sem resposta

it('faz GET do pedido e mapeia', () => {
  const service = TestBed.inject(OrdersService);
  let result: Order | undefined;
  service.load(1).subscribe((o) => (result = o));

  const req = httpTesting.expectOne('/api/orders/1');
  expect(req.request.method).toBe('GET');
  req.flush({ id: 1, total: 90 });

  expect(result?.total).toBe(90);
});

Quando o vi.mock é certo, e por que ele briga com a DI

O vi.mock substitui os exports de um módulo no arquivo inteiro. É a ferramenta certa quando aquilo que você precisa fingir não é injetado: um import ES cru que o seu código chama direto, um SDK de terceiros sem provider Angular, um helper de Date ou crypto importado como função. Para esses, a DI não tem costura, e o vi.mock é a única costura que existe.

É a ferramenta errada para um serviço Angular injetado, e o motivo é mecânico, não estético. O Vitest iça todo vi.mock para o topo do arquivo, acima dos seus imports, então a factory roda antes de qualquer setup de teste e não enxerga um valor do TestBed nem uma variável definida depois (é para isso que o vi.hoisted existe). Ele troca a classe que o módulo exporta, mas o Angular não resolve um serviço importando a classe dele na hora da chamada; ele resolve o provider registrado no injector. Então um vi.mock do arquivo do serviço pode deixar o componente ainda recebendo o provider real, e você depura um mock que parece instalado mas nunca foi aplicado.

Para um serviço injetado, eu recusaria o vi.mock e usaria o provider de DI toda vez. Só recorro ao vi.mock quando não há costura de injector para usar, e mesmo aí mantenho a superfície mockada tão pequena quanto o teste precisa, porque um mock de módulo içado é a coisa mais fácil de vazar para o próximo teste numa suíte Vitest.

vi.mock para import sem DI; provider de DI para o serviçots
import { vi } from 'vitest';

// Uso CERTO: um módulo de terceiros que o código importa direto, sem costura de DI.
// Içado acima dos imports, então é trocado antes do código sob teste carregar.
vi.mock('nanoid', () => ({ nanoid: () => 'test-id-123' }));

// Uso ERRADO para um serviço injetado: isso troca o export da classe, mas o
// componente segue recebendo o provider que o injector tem. Prefira:
TestBed.configureTestingModule({
  providers: [{ provide: OrdersService, useValue: { load: vi.fn() } }],
});

Clear, reset, restore: escolha um e configure

Spies vazam entre testes quando ninguém os reseta, e o Vitest te dá três verbos fáceis de confundir. O mockClear apaga o histórico de chamadas e mantém a implementação. O mockReset apaga o histórico e devolve a implementação para um vi.fn() vazio. O mockRestore faz o reset e recoloca o método original no lugar, então só significa alguma coisa para spies criados com vi.spyOn.

Em vez de chamar isso na mão em todo afterEach, defina a política uma vez nas opções do test target. O restoreMocks: true roda o equivalente a vi.restoreAllMocks() depois de cada teste, que tanto reseta o histórico quanto desfaz o patch de todo vi.spyOn, então um spy de um spec não consegue vazar para o próximo. Esse é o ajuste que eu quero ligado por padrão, porque um spy vazado é um teste instável esperando para ser culpado por outra coisa.

ChamadaLimpa históricoImplementação depoisUse quando
mockClear()SimMantidaReusar o mesmo fake entre its de um bloco.
mockReset()Simvi.fn() vazioCada teste define o próprio valor de retorno.
mockRestore()SimOriginal de voltaVocê usou vi.spyOn e quer o método real de volta.
restoreMocks: true (config)SimOriginal de voltaO padrão global; ligue e pare de resetar na mão.

Artefato para reaproveitar

Cheat sheet de spy Jasmine para Vitest

  • jasmine.createSpy() vira vi.fn(); jasmine.createSpyObj('s', ['a','b']) vira { a: vi.fn(), b: vi.fn() }.
  • spyOn(o, 'm') vira vi.spyOn(o, 'm'), mas adicione .mockImplementation(() => {}) ou .mockReturnValue(...) se o método tem efeito colateral: o Jasmine stuba por padrão, o Vitest chama o real.
  • .and.returnValue vira .mockReturnValue; .and.resolveTo vira .mockResolvedValue; .and.callFake vira .mockImplementation.
  • spy.calls.count() vira spy.mock.calls.length; spy.calls.argsFor(0) vira spy.mock.calls[0]; spy.calls.reset() vira spy.mockClear().
  • Substitua um serviço injetado inteiro com { provide: Service, useValue: { method: vi.fn() } }, não com vi.mock do módulo do serviço.
  • Mantenha provideHttpClientTesting() e HttpTestingController como estão; são independentes do runner. Adicione provideHttpClient() antes dele ao testar config do cliente.
  • Use vi.mock só para imports sem DI (SDKs de terceiros, imports de função crus); ele é içado acima dos imports e passa por cima do injector.
  • Ligue restoreMocks: true no test target para os spies não vazarem entre testes.

Perguntas frequentes

O que substitui jasmine.createSpy e spyOn nos testes Angular com Vitest?

O jasmine.createSpy() vira vi.fn(), e o spyOn(obj, 'method') vira vi.spyOn(obj, 'method'). Os métodos também mudam de nome: .and.returnValue vira .mockReturnValue, .and.callFake vira .mockImplementation, e o spy.calls.count() vira spy.mock.calls.length. O schematic refactor-jasmine-vitest do Angular 22 aplica a maioria disso automaticamente.

Por que meu teste se comporta diferente depois de trocar spyOn por vi.spyOn?

Porque os padrões diferem. O spyOn do Jasmine stuba o método (um no-op que retorna undefined) por padrão. O vi.spyOn do Vitest chama o método real por padrão. Se o spy original existia para impedir um efeito colateral como uma chamada HTTP, adicione .mockReturnValue(...) ou .mockImplementation(() => {}) depois do vi.spyOn, ou o método real roda.

Devo mockar um serviço Angular com vi.mock ou com um provider de DI?

Use um provider de DI para qualquer serviço que a unidade sob teste injeta: { provide: Service, useValue: { method: vi.fn() } }. O vi.mock é içado acima dos imports e troca o export de classe do módulo, que o injector do Angular não lê na hora da chamada, então ele costuma deixar o provider real no lugar. Reserve o vi.mock para imports sem DI, como SDKs de terceiros.

O provideHttpClientTesting e o HttpTestingController ainda funcionam no Vitest?

Sim, sem mudança. São APIs do Angular e nunca dependeram do Karma. Use provideHttpClientTesting() sozinho para um teste de HTTP simples, ou provideHttpClient(...) antes do provideHttpClientTesting() quando você também testa configuração do cliente. Injete o HttpTestingController e use expectOne, flush e verify exatamente como antes.

Como impedir os mocks do Vitest de vazar entre testes?

Ligue restoreMocks: true nas opções do test target, que roda vi.restoreAllMocks() depois de cada teste: limpa o histórico de chamadas e desfaz o patch de todo vi.spyOn. Para controle mais fino, chame mockClear() para manter a implementação, mockReset() para descartá-la, ou mockRestore() para recolocar o método original.

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 · 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 · 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 · 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 →