---
title: "Mocking Services and HttpClient in Angular Vitest: vi.fn, Spies, and DI"
description: "The Jasmine-to-Vitest spy map for Angular tests (vi.fn, vi.spyOn, mockReturnValue), why a DI provider mock beats vi.mock for services, and how HttpTestingController still works with provideHttpClientTesting."
deck: "[Vitest is the default Angular test runner since v21](https://angular.dev/guide/testing) (stable, November 2025). The first thing you reach for after the migration is the spy, and the names changed: `jasmine.createSpy` becomes [`vi.fn`](https://vitest.dev/api/vi), `spyOn` becomes `vi.spyOn`. But the idiomatic Angular mock was never the spy. It is a fake supplied through [DI](https://angular.dev/guide/testing/services)."
author: "André Ramos"
url: "https://andreramos.dev/angular/mocking-angular-services-vitest/"
lang: "en"
type: "article"
---

# Mocking Services and HttpClient in Angular Vitest: vi.fn, Spies, and DI

> [Vitest is the default Angular test runner since v21](https://angular.dev/guide/testing) (stable, November 2025). The first thing you reach for after the migration is the spy, and the names changed: `jasmine.createSpy` becomes [`vi.fn`](https://vitest.dev/api/vi), `spyOn` becomes `vi.spyOn`. But the idiomatic Angular mock was never the spy. It is a fake supplied through [DI](https://angular.dev/guide/testing/services).

## 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.

*Same intent, three Vitest spies*

```ts
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.

*Fake service through DI, methods are vi.fn()*

```ts
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.

*HttpClient test under Vitest: same APIs as Karma*

```ts
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.

*vi.mock for a non-DI import; DI provider for the service*

```ts
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 `it`s 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()` becomes `vi.fn()`; `jasmine.createSpyObj('s', ['a','b'])` becomes `{ a: vi.fn(), b: vi.fn() }`.
- `spyOn(o, 'm')` becomes `vi.spyOn(o, 'm')`, but add `.mockImplementation(() => {})` or `.mockReturnValue(...)` if the method has side effects: Jasmine stubs by default, Vitest calls through.
- `.and.returnValue` becomes `.mockReturnValue`; `.and.resolveTo` becomes `.mockResolvedValue`; `.and.callFake` becomes `.mockImplementation`.
- `spy.calls.count()` becomes `spy.mock.calls.length`; `spy.calls.argsFor(0)` becomes `spy.mock.calls[0]`; `spy.calls.reset()` becomes `spy.mockClear()`.
- Replace a whole injected service with `{ provide: Service, useValue: { method: vi.fn() } }`, not `vi.mock` of the service module.
- Keep `provideHttpClientTesting()` and `HttpTestingController` as-is; they are runner-independent. Add `provideHttpClient()` before it when testing client config.
- Use `vi.mock` only for non-DI imports (third-party SDKs, bare function imports); it is hoisted above imports and bypasses the injector.
- Set `restoreMocks: true` in 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

## Related

- [Karma to Vitest in Angular: The Config That Actually Runs](https://andreramos.dev/angular/karma-to-vitest-angular-config/)
- [When to Move an Angular Test Suite to Vitest](https://andreramos.dev/angular/move-angular-test-suite-to-vitest/)
- [fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest](https://andreramos.dev/angular/angular-vitest-fakeasync-proxyzone-error/)
