Modern Angular
Mocking Services and HttpClient in Angular Vitest: vi.fn, Spies, and DI
Vitest is the default Angular test runner since v21 (stable, November 2025). The first thing you reach for after the migration is the spy, and the names changed: jasmine.createSpy becomes vi.fn, spyOn becomes vi.spyOn. But the idiomatic Angular mock was never the spy. It is a fake supplied through DI.
The spy names changed, the idea did not
The first test you open after moving to Vitest is usually the one with a spy in it, and the spy does not compile. jasmine.createSpy is gone, spyOn(...).and.returnValue(...) is gone, expect(spy).toHaveBeenCalledTimes is the same but spy.calls.count() is not. The reflex is to think the test framework got harder. It did not. Most of the surface is a one-to-one rename, and Angular 22's refactor-jasmine-vitest schematic does the mechanical pass for you.
The official migration guide names the conversions directly: it Replaces jasmine.createSpy with vi.fn, Converts spyOn calls to the equivalent vi.spyOn, and Replaces jasmine.objectContaining with expect.objectContaining. Read the table below as the map the schematic follows, and the next section as the one call the schematic cannot make for you.
| Jasmine | Vitest | Note |
|---|---|---|
jasmine.createSpy('name') | vi.fn() | A standalone mock function with call tracking. |
jasmine.createSpyObj('svc', ['load']) | { load: vi.fn() } | An object literal of vi.fn()s. No factory needed. |
spyOn(obj, 'method') | vi.spyOn(obj, 'method') | Different default: see the next section. |
.and.returnValue(v) | .mockReturnValue(v) | .mockReturnValueOnce(v) for a single call. |
.and.resolveTo(v) | .mockResolvedValue(v) | Returns a resolved Promise. |
.and.callFake(fn) | .mockImplementation(fn) | Replace the body with your own. |
.and.callThrough() | (default for vi.spyOn) | vi.spyOn already calls through. |
.and.stub() | .mockImplementation(() => {}) | Jasmine stubs by default; Vitest does not. |
spy.calls.count() | spy.mock.calls.length | Array of call argument arrays. |
spy.calls.argsFor(0) | spy.mock.calls[0] | Arguments of the first call. |
spy.calls.mostRecent().args | spy.mock.lastCall | Arguments of the last call. |
spy.calls.reset() | spy.mockClear() | Clear history, keep the implementation. |
jasmine.any(String) | expect.any(String) | Asymmetric matcher in assertions. |
The one rename that is a behavior change
spyOn to vi.spyOn looks like a pure rename. It is the one row in that table that changes what the test does. Jasmine's default spy strategy is and.stub(): a bare spyOn(service, 'save') replaces the method with a no-op that returns undefined. Vitest's vi.spyOn does the opposite by default. It wraps the real method and calls through to it, so the original code still runs unless you add .mockReturnValue or .mockImplementation.
That difference is silent and it bites. A Jasmine test that spied on save() to stop a real HTTP call from firing will, after a literal rename to vi.spyOn, let that call fire again. The test may still pass while hitting a real backend or throwing deep in a dependency the test never meant to exercise. This is the call the schematic cannot make: it converts the syntax, but it cannot know you relied on Jasmine's stub-by-default.
So when I review a spyOn to vi.spyOn diff, the only question I ask is: did this spy intend to suppress the real method? If yes, the conversion is incomplete until it has an explicit .mockReturnValue(...) or .mockImplementation(() => {}). I would not merge a bare vi.spyOn on a method with side effects.
import { vi } from 'vitest';
// 1. Standalone fake (was jasmine.createSpy): returns what you tell it.
const load = vi.fn().mockReturnValue(of([{ id: 1 }]));
// 2. Spy that SUPPRESSES the real method (Jasmine's default; Vitest is not).
const svc = TestBed.inject(OrdersService);
vi.spyOn(svc, 'save').mockResolvedValue({ ok: true });
// 3. Spy that only OBSERVES and lets the real method run (Vitest's default).
const audit = vi.spyOn(svc, 'audit');
svc.confirm();
expect(audit).toHaveBeenCalledWith('confirm');The Angular-idiomatic mock is a DI provider, not a spy
Spies are the answer when you want to watch or bend one method on an object you already have. They are the wrong tool for replacing a whole collaborator. In Angular the unit under test gets its collaborators through the injector, so the natural place to substitute a fake is the same place: the providers array. You hand TestBed a fake object and tell it to use that instead of the real service.
The fake is a plain object whose methods are vi.fn()s. No vi.spyOn on a real instance, no real service constructed, no transitive dependency dragged in. The provide/useValue pair is the same shape the Angular testing guide shows for stubbing a dependency, and it reads the same to anyone who has written an Angular test since 2017. vi.fn() just replaces jasmine.createSpy() inside it.
I prefer this over module-level mocking for any service the component or service under test injects, because it mocks at the boundary the application actually uses (the injector) rather than reaching past it to the module system. It also keeps the fake typed against the real interface, so a method rename breaks the test instead of silently passing.
import { vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
// A typed fake: every method is a vi.fn(). Typed against the real service,
// so a signature change in OrdersService fails this test instead of passing.
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('loads orders on init', () => {
const facade = TestBed.inject(OrdersFacade);
facade.init();
expect(ordersMock.load).toHaveBeenCalledOnce();
});HttpClient: the testing backend is unchanged
The most common worry after the runner swap is the HTTP tests, and it is the most misplaced. provideHttpClientTesting and HttpTestingController are Angular APIs, not Karma APIs. They never depended on the test runner, so they work under Vitest exactly as they did under Karma. Nothing about the request-matching layer changed.
For a plain HTTP test, the only provider you need is provideHttpClientTesting(). The moment you are testing client configuration (an interceptor, for example) you add provideHttpClient(...) as well, and order matters: the docs are explicit that provideHttpClient() must come before provideHttpClientTesting(), because the testing provider overwrites parts of the real one. The injected HttpTestingController gives you expectOne, match, expectNone, the request's flush and error, and verify to assert no request went unanswered.
I keep httpTesting.verify() in afterEach on every HTTP spec. It is the one assertion that catches a request the component fired that the test forgot to expect, which is exactly the kind of regression a mocked service would hide.
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(...) first, then the testing provider overrides the backend.
providers: [provideHttpClient(), provideHttpClientTesting()],
});
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => httpTesting.verify()); // no request left unanswered
it('GETs the order and maps it', () => {
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);
});When vi.mock is right, and why it fights DI
vi.mock replaces a module's exports across the whole file. That is the right tool when the thing you need to fake is not injected: a bare ES import your code calls directly, a third-party SDK with no Angular provider, a Date or crypto helper imported as a function. For those, DI has no seam, and vi.mock is the only seam there is.
It is the wrong tool for an injected Angular service, and the reason is mechanical, not stylistic. Vitest hoists every vi.mock call to the top of the file, above your imports, so the factory runs before any test setup and cannot see a TestBed value or a variable defined later (that is what vi.hoisted exists to work around). It replaces the class the module exports, but Angular does not resolve a service by importing its class at call time; it resolves the provider registered in the injector. So a vi.mock of the service file can leave the component still receiving the real provider, and you debug a mock that looks installed but never applied.
Given an injected service, I would refuse vi.mock and use the DI provider every time. I reach for vi.mock only when there is no injector seam to use, and even then I keep the mocked surface as small as the test needs, because a hoisted module mock is the easiest thing in a Vitest suite to leak into the next test.
import { vi } from 'vitest';
// RIGHT use: a third-party module the code imports directly, no DI seam.
// Hoisted above imports, so it is replaced before the code under test loads.
vi.mock('nanoid', () => ({ nanoid: () => 'test-id-123' }));
// WRONG use for an injected service: this replaces the class export, but the
// component still gets whatever provider the injector holds. Prefer:
TestBed.configureTestingModule({
providers: [{ provide: OrdersService, useValue: { load: vi.fn() } }],
});Clear, reset, restore: pick one and configure it
Spies leak between tests when nobody resets them, and Vitest gives you three verbs that are easy to confuse. mockClear wipes call history and leaves the implementation. mockReset wipes history and drops the implementation back to an empty vi.fn(). mockRestore does the reset and puts the original method back, so it only means something for spies created with vi.spyOn.
Rather than call these by hand in every afterEach, set the policy once in the test target options. restoreMocks: true runs the equivalent of vi.restoreAllMocks() after each test, which both resets call history and un-patches every vi.spyOn, so a spy in one spec cannot bleed into the next. That is the setting I want on by default, because a leaked spy is a flaky test waiting to be blamed on something else.
| Call | Clears history | Implementation after | Use when |
|---|---|---|---|
mockClear() | Yes | Kept | Reusing the same fake across its in one block. |
mockReset() | Yes | Empty vi.fn() | You want each test to define its own return value. |
mockRestore() | Yes | Original restored | You used vi.spyOn and want the real method back. |
restoreMocks: true (config) | Yes | Original restored | The global default; set it and stop hand-resetting. |
Reusable artifact
Jasmine spy to Vitest cheat sheet
jasmine.createSpy()becomesvi.fn();jasmine.createSpyObj('s', ['a','b'])becomes{ a: vi.fn(), b: vi.fn() }.spyOn(o, 'm')becomesvi.spyOn(o, 'm'), but add.mockImplementation(() => {})or.mockReturnValue(...)if the method has side effects: Jasmine stubs by default, Vitest calls through..and.returnValuebecomes.mockReturnValue;.and.resolveTobecomes.mockResolvedValue;.and.callFakebecomes.mockImplementation.spy.calls.count()becomesspy.mock.calls.length;spy.calls.argsFor(0)becomesspy.mock.calls[0];spy.calls.reset()becomesspy.mockClear().- Replace a whole injected service with
{ provide: Service, useValue: { method: vi.fn() } }, notvi.mockof the service module. - Keep
provideHttpClientTesting()andHttpTestingControlleras-is; they are runner-independent. AddprovideHttpClient()before it when testing client config. - Use
vi.mockonly for non-DI imports (third-party SDKs, bare function imports); it is hoisted above imports and bypasses the injector. - Set
restoreMocks: truein the test target so spies do not leak between tests.
FAQ
What replaces jasmine.createSpy and spyOn in Angular Vitest tests?
jasmine.createSpy() becomes vi.fn(), and spyOn(obj, 'method') becomes vi.spyOn(obj, 'method'). The methods rename too: .and.returnValue becomes .mockReturnValue, .and.callFake becomes .mockImplementation, and spy.calls.count() becomes spy.mock.calls.length. Angular 22's refactor-jasmine-vitest schematic applies most of these automatically.
Why does my test behave differently after changing spyOn to vi.spyOn?
Because the defaults differ. Jasmine's spyOn stubs the method (a no-op returning undefined) by default. Vitest's vi.spyOn calls through to the real method by default. If the original spy existed to stop a side effect like an HTTP call, add .mockReturnValue(...) or .mockImplementation(() => {}) after vi.spyOn, or the real method runs.
Should I mock an Angular service with vi.mock or a DI provider?
Use a DI provider for any service the unit under test injects: { provide: Service, useValue: { method: vi.fn() } }. vi.mock is hoisted above imports and replaces the module's class export, which the Angular injector does not read at call time, so it often leaves the real provider in place. Reserve vi.mock for non-DI imports such as third-party SDKs.
Does provideHttpClientTesting and HttpTestingController still work under Vitest?
Yes, unchanged. They are Angular APIs and never depended on Karma. Use provideHttpClientTesting() alone for a plain HTTP test, or provideHttpClient(...) before provideHttpClientTesting() when you also test client configuration. Inject HttpTestingController and use expectOne, flush, and verify exactly as before.
How do I stop Vitest mocks from leaking between tests?
Set restoreMocks: true in the test target options, which runs vi.restoreAllMocks() after each test: it clears call history and un-patches every vi.spyOn. For finer control, call mockClear() to keep the implementation, mockReset() to drop it, or mockRestore() to put the original method back.
Sources checked
- https://angular.dev/guide/testing
- https://angular.dev/guide/testing/services
- https://angular.dev/guide/http/testing
- https://angular.dev/guide/testing/migrating-to-vitest
- https://angular.dev/api/common/http/testing/HttpTestingController
- https://angular.dev/api/common/http/testing/provideHttpClientTesting
- https://vitest.dev/api/vi
- https://vitest.dev/api/mock
- https://vitest.dev/guide/mocking
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b
Angular Vitest Migration Kit
Migrating this suite to Vitest?
The Angular Vitest Migration Kit sizes your migration by risk, then packages the config, the fakeAsync cookbook, an AI-agent skill, and a runnable demo. The conversions in this article, done for you.
Verified on Angular 21.2.14 / Vitest 4.1.8. Includes the migration estimator, the PDF guide, copy-paste config, an AI-agent skill, and a runnable demo.
André Ramosavailable for remote roles, UTC−3Get in touch →