Component-level services in Angular and testing

By default, services in Angular are provided at the root module level, as configured by the @Injectable decorator:

@Injectable({
  providedIn: "root",
})
export class SampleService {
  // ...
}

This way, the same instance of the service will be injected into any component depending on it:

@Component({
  selector: "app-sample",
  templateUrl: "./sample.component.html",
  styleUrls: ["./sample.component.scss"],
})
export class SampleComponent {
  constructor(private sampleService: SampleService) {}

  public invokeSample() {
    this.sampleService.sampleMethod();
  }
}

If a component needs a separate instance of the service for itself and its children, it can change the scope by declaring a service provider in its @Component decorator:

@Component({
  selector: "app-sample",
  templateUrl: "./sample.component.html",
  styleUrls: ["./sample.component.scss"],
  providers: [SampleService],
})
export class SampleComponent {
  // ...
}

However, this change also affects dependency injection in tests. If you provide a mock service at the testbed level, it won't get used because the component-level provider has a higher priority:

beforeEach(async () => {
  mockService = jasmine.createSpyObj<SampleService>("SampleService", [
    "sampleMethod",
  ]);

  await TestBed.configureTestingModule({
    declarations: [SampleComponent],
    providers: [{ provide: SampleService, useValue: mockService }],
  }).compileComponents();
});

beforeEach(() => {
  fixture = TestBed.createComponent(SampleComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
});

it("should not use provided mock service", () => {
  component.invokeSample();

  expect(mockService.sampleMethod).not.toHaveBeenCalled();
});

To override the component-level provider declaration in a test, a new component must be derived from the one under test without the local provider declaration:

@Component({
  selector: "app-sample",
  templateUrl: "./sample.component.html",
  styleUrls: ["./sample.component.scss"],
})
class TestSampleComponent extends SampleComponent {
  constructor(sampleService: SampleService) {
    super(sampleService);
  }
}

For this component, the mock service declared at the testbed level will be injected:

beforeEach(async () => {
  mockService = jasmine.createSpyObj<SampleService>("SampleService", [
    "sampleMethod",
  ]);

  await TestBed.configureTestingModule({
    declarations: [TestSampleComponent],
    providers: [{ provide: SampleService, useValue: mockService }],
  }).compileComponents();
});

beforeEach(() => {
  fixture = TestBed.createComponent(TestSampleComponent);
  component = fixture.componentInstance;
  fixture.detectChanges();
});

it("should use provided mock service", () => {
  component.invokeSample();

  expect(mockService.sampleMethod).toHaveBeenCalled();
});

You can find a working sample with tests in my GitHub repository.

Dependency injection in Angular is very flexible. Services can be scoped to the component level so that each component instance gets a separate service instance. When writing unit tests for such components this behavior can be overridden in a derived component created for the test only so that a mock service can still be injected.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License