Skip to content
Angular ng testing 4 min read

Testing Services & DI

Services hold the business logic of an Angular application — HTTP calls, state, calculations, and orchestration between collaborators. Because they are plain classes wired together through dependency injection, they are the easiest and fastest part of your app to test in isolation. The key skill is configuring the testing injector with TestBed, pulling out the service under test with TestBed.inject, and swapping real collaborators for lightweight fakes so each test exercises one behavior at a time.

Resolving services from the testing injector

TestBed.configureTestingModule builds a throwaway injector for each test. You register providers there, then call TestBed.inject(Token) to obtain a fully constructed instance. Because the injector resolves the dependency graph for you, any inject() calls or constructor parameters inside the service are satisfied automatically.

import { TestBed } from '@angular/core/testing';
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class PricingService {
  applyDiscount(amount: number, percent: number): number {
    if (percent < 0 || percent > 100) {
      throw new Error('percent must be between 0 and 100');
    }
    return Math.round(amount * (1 - percent / 100) * 100) / 100;
  }
}

describe('PricingService', () => {
  let service: PricingService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(PricingService);
  });

  it('applies a percentage discount', () => {
    expect(service.applyDiscount(200, 10)).toBe(180);
  });

  it('rejects an out-of-range percent', () => {
    expect(() => service.applyDiscount(200, 150)).toThrow();
  });
});

A service marked providedIn: 'root' does not need to be listed in providers — the root injector already knows how to create it. You only add it explicitly when you want to override how it is constructed.

Tip: For a pure-logic service with no dependencies you can skip TestBed entirely and just write new PricingService(). Reach for TestBed once DI gets involved — it is what makes overrides clean.

Providing fakes for dependencies

Real services usually depend on other services. To keep a unit test focused, replace each collaborator with a fake that returns predictable data. Register the fake against the same token using { provide: Token, useValue / useClass }, and the injector hands your fake to the service under test.

import { Injectable, inject } from '@angular/core';

export interface User {
  id: number;
  name: string;
}

@Injectable({ providedIn: 'root' })
export class UserApi {
  getUser(id: number): Promise<User> {
    return fetch(`/api/users/${id}`).then((r) => r.json());
  }
}

@Injectable({ providedIn: 'root' })
export class GreetingService {
  private api = inject(UserApi);

  async welcome(id: number): Promise<string> {
    const user = await this.api.getUser(id);
    return `Welcome, ${user.name}!`;
  }
}

The test never touches the network. It substitutes UserApi with a fake whose getUser is a Jasmine spy:

import { TestBed } from '@angular/core/testing';

describe('GreetingService', () => {
  let service: GreetingService;
  let apiSpy: jasmine.SpyObj<UserApi>;

  beforeEach(() => {
    apiSpy = jasmine.createSpyObj<UserApi>('UserApi', ['getUser']);

    TestBed.configureTestingModule({
      providers: [
        GreetingService,
        { provide: UserApi, useValue: apiSpy },
      ],
    });

    service = TestBed.inject(GreetingService);
  });

  it('builds a greeting from the fetched user', async () => {
    apiSpy.getUser.and.resolveTo({ id: 1, name: 'Ada' });

    const message = await service.welcome(1);

    expect(message).toBe('Welcome, Ada!');
    expect(apiSpy.getUser).toHaveBeenCalledWith(1);
  });
});

Output:

GreetingService
  ✓ builds a greeting from the fetched user

Executed 1 of 1 SUCCESS (0.012 secs / 0.004 secs)

Choosing a substitution strategy

The provider syntax supports several ways to supply a fake. Pick based on how much control you need.

StrategyWhen to useExample
useValueA plain object or spy with fixed behavior{ provide: UserApi, useValue: apiSpy }
useClassA reusable fake implementation across many specs{ provide: UserApi, useClass: FakeUserApi }
useFactoryThe fake needs setup or other deps{ provide: Cfg, useFactory: () => ({ debug: true }) }
TestBed.overrideProviderTweak one provider after module setupTestBed.overrideProvider(UserApi, { useValue: apiSpy })

A useClass fake is handy when several tests share the same collaborator. Implement only the methods you actually call:

class FakeUserApi {
  getUser(id: number): Promise<User> {
    return Promise.resolve({ id, name: `User ${id}` });
  }
}

Isolating business logic

The goal of a service test is to verify decisions, not infrastructure. Keep formatting, branching, validation, and state transitions in the service, and push side effects (HTTP, storage, timers) into thin collaborators you can fake. When a method computes a value from inputs, assert the value directly. When it coordinates collaborators, assert the calls with toHaveBeenCalledWith and let the fake stand in for the real effect.

For services that wrap HttpClient, do not hand-roll a fake — use provideHttpClient(withInterceptorsFromDi()) together with provideHttpClientTesting() and the HttpTestingController so you exercise the real request-building code. For everything else, a spy keeps the test fast and deterministic.

Warning: If TestBed.inject(MyService) throws NullInjectorError: No provider for X, a dependency is missing from providers. Add the real provider or a fake for X — the testing injector does not fall back to the app’s root configuration unless the service declares providedIn: 'root'.

Best Practices

  • Inject the service with TestBed.inject inside beforeEach so every test starts from a fresh instance and injector.
  • Fake every external collaborator (HTTP, router, storage) so tests stay fast, offline, and deterministic.
  • Prefer jasmine.createSpyObj for one-off fakes and a useClass implementation when several specs share the same collaborator.
  • Assert both the returned value and the interactions (toHaveBeenCalledWith) so you catch wrong arguments, not just wrong results.
  • Skip TestBed for dependency-free pure logic — a direct new Service() is simpler and faster.
  • Test each branch and edge case (errors, empty input, boundary values) as its own focused spec.
Last updated June 14, 2026
Was this helpful?