Skip to the solution
MediumFrontend9 min read

How do you mock services in Angular unit tests using Jasmine/Jest spies?

799 views

The problem

Question presented to candidate: "ProfileComponent injects a UserService that calls HttpClient and an analytics method. How would you unit test the component without real HTTP or analytics, using spies? Show it in the runner the project uses, and say how the Jasmine version would differ."

What a strong answer should cover:

  • Mocking works through dependency injection: replace the service token in TestBed.configureTestingModule({ providers }) with useValue (a spy object) or useClass (a fake class).
  • Vitest (the Angular 21+ CLI default): vi.fn(), mockReturnValue(of(...)), vi.spyOn(service, 'method'), toHaveBeenCalledWith; Jest's jest.fn()/jest.spyOn() have the same shape.
  • Jasmine: jasmine.createSpyObj('UserService', ['getUser']), .and.returnValue(...), spyOn(obj, 'm').and.callThrough().
  • Key difference: vi.spyOn calls the real method unless you mock it; Jasmine's spyOn stubs it unless you add and.callThrough().
  • Return Observables from mocked methods (of, throwError) so the component's subscribe logic is exercised, including the error path.
  • For HTTP specifically, provideHttpClientTesting() + HttpTestingController fakes the backend instead of the service.
  • Component-level providers are not replaced by TestBed providers; use TestBed.overrideComponent.

Clarifying questions expected:

  • "Which runner: Vitest, Jest, or Karma with Jasmine?"
  • "Is the service provided in root or in the component's own providers?"

Code / implementation expected: Yes β€” a component spec with a useValue spy mock, a spyOn on the real service, and an HttpTestingController variant.

  • testing
  • services

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 9 sections

Read the code

AngularMocking a service three ways: useValue spies, vi.spyOn, HttpTestingController (Vitest, ran with ng test on Angular 22.2.3)
import { Component, Injectable, inject, signal } from '@angular/core';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Observable, of, throwError } from 'rxjs';

type User = { id: number; name: string };

@Injectable({ providedIn: 'root' })
class UserService {
  private http = inject(HttpClient);
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`/api/users/${id}`);
  }
  track(event: string) {
    console.log('analytics:', event); // a side effect we do not want in tests
  }
}

@Component({
  selector: 'app-profile',
  template: `<button (click)="load()">Load</button><p>{{ text() }}</p>`,
})
class Profile {
  private users = inject(UserService);
  text = signal('nothing yet');
  load() {
    this.users.track('load-clicked');
    this.users.getUser(7).subscribe({
      next: (u) => this.text.set(`Hello ${u.name}`),
      error: () => this.text.set('Could not load user'),
    });
  }
}

async function clickLoad() {
  const fixture = TestBed.createComponent(Profile);
  await fixture.whenStable();
  fixture.nativeElement.querySelector('button').click();
  await fixture.whenStable();
  return fixture.nativeElement.querySelector('p').textContent as string;
}

describe('mocking UserService', () => {
  it('1. replace it: a mock object of vi.fn() provided with useValue', async () => {
    const mock = {
      getUser: vi.fn().mockReturnValue(of({ id: 7, name: 'Ada' })),
      track: vi.fn(),
    };
    TestBed.configureTestingModule({ providers: [{ provide: UserService, useValue: mock }] });

    expect(await clickLoad()).toBe('Hello Ada');
    expect(mock.getUser).toHaveBeenCalledWith(7);
    expect(mock.track).toHaveBeenCalledOnce();
  });

  it('2. spy on the real one: vi.spyOn after TestBed.inject', async () => {
    const service = TestBed.inject(UserService);
    const getUser = vi.spyOn(service, 'getUser').mockReturnValue(throwError(() => new Error('500')));
    const track = vi.spyOn(service, 'track').mockImplementation(() => {}); // silence the side effect

    expect(await clickLoad()).toBe('Could not load user');
    expect(getUser).toHaveBeenCalledWith(7);
    expect(track).toHaveBeenCalledWith('load-clicked');
  });

  it('3. fake the HTTP layer instead: HttpTestingController', async () => {
    TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
    vi.spyOn(TestBed.inject(UserService), 'track').mockImplementation(() => {});
    const http = TestBed.inject(HttpTestingController);

    const fixture = TestBed.createComponent(Profile);
    await fixture.whenStable();
    fixture.nativeElement.querySelector('button').click();
    const req = http.expectOne('/api/users/7');
    expect(req.request.method).toBe('GET');
    req.flush({ id: 7, name: 'Grace' });
    await fixture.whenStable();

    expect(fixture.nativeElement.querySelector('p').textContent).toBe('Hello Grace');
    http.verify(); // fails if any request was not expected
  });
});

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 33 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

What are @ViewChild and @ContentChild?

Continue