'Angular - How to Unit Test a Subscription on a Service?

I've been trying to find a way to test a subscription that is on a mocked service within Angular using Jest. But I can't find a way to mock the Observable and test the assignment of the isMobile property.

When I try to spyOn and then mock the service to return a value, it is never updated. And I would like to be able to test the code with different responses from the service.

Code below.

Component

import { Component, Input, OnInit } from '@angular/core';
import { BREAKPOINT, BreakpointService } from '../../services';
 
@Component({
  selector: 'auxiliary-bar',
  templateUrl: './auxiliary-bar.component.html',
  styleUrls: ['./auxiliary-bar.component.scss'],
})
export class AuxiliaryBarComponent implements OnInit { 
  public isMobile = false;
 
  constructor(private breakpointService: BreakpointService) {}
 
  ngOnInit(): void {
    this.breakpointService.onBreakpoint$.subscribe(
      (breakpoint) => (this.isMobile = breakpoint <= BREAKPOINT.SM),
    );
  }
}

Breakpoint Service

import { Injectable, NgZone, OnDestroy } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';

export enum BREAKPOINT {
  XS,
  SM,
  MD,
  LG,
  XL,
}

@Injectable({
  providedIn: 'root',
})
export class BreakpointService implements OnDestroy {
  private breakpointSubject: Subject<BREAKPOINT> = new Subject();
  private observer!: ResizeObserver;
  private sizes = [
    {
      id: BREAKPOINT.SM,
      width: 0,
    },
    {
      id: BREAKPOINT.SM,
      width: 320,
    },
    {
      id: BREAKPOINT.MD,
      width: 768,
    },
    {
      id: BREAKPOINT.LG,
      width: 1024,
    },
    {
      id: BREAKPOINT.XL,
      width: 1200,
    },
  ];

  constructor(private zone: NgZone) {
    this.observer = new ResizeObserver((entries) => {
      this.zone.run(() => {
        const matchedSize = this.sizes
          .slice(1)
          .reverse()
          .find(
            (size) => window.matchMedia(`(min-width: ${size.width}px)`).matches,
          );
        this.breakpointSubject.next(
          matchedSize ? matchedSize.id : BREAKPOINT.XS,
        );
      });
    });
    this.observer.observe(document.body);
  }

  ngOnDestroy(): void {
    this.observer.unobserve(document.body);
  }

  public get onBreakpoint$(): Observable<BREAKPOINT> {
    return this.breakpointSubject.asObservable().pipe(distinctUntilChanged());
  }
}

Attempted Test Suite

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MockProvider } from 'ng-mocks';
import { Observable, of } from 'rxjs';

import { BreakpointService } from '../../services';
import { AuxiliaryBarComponent } from './auxiliary-bar.component';

describe('AuxiliaryBarComponent', () => {
  let component: AuxiliaryBarComponent;
  let fixture: ComponentFixture<AuxiliaryBarComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [
        AuxiliaryBarComponent,
      ],
      providers: [
        MockProvider(BreakpointService, {
          onBreakpoint$: new Observable(),
        }),
      ],
    }).compileComponents();
  });

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

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should', () => {
    const breakpointService = TestBed.inject(BreakpointService);
    const spy = jest
      .spyOn(breakpointService, 'onBreakpoint$', 'get')
      .mockReturnValue(of(3));
    fixture.detectChanges();
    expect(breakpointService.onBreakpoint$.subscribe).toHaveBeenCalled();
  });
)};


Solution 1:[1]

I'm pretty sure it's too late because the component is already instantiated and therefore ngInit is already called by the time your it starts. If I'm right, then a fix might be as easy as inserting component.ngOnInit() before your last fixture.detectChanges() to re-trigger it.

As an aside, your component shouldn't subscribe if you can help it. Instead, if it works for your needs then I would change the component's ngOnInit to

public isMobile$: Observable<boolean>;
ngOnInit(): void {
  this.isMobile$ = this.breakpointService.onBreakpoint$.pipe(
    map(breakpoint => breakpoint <= BREAKPOINT.SM)
  );
}

and then in your template, wherever you used to have isMobile, use (isMobile$ | async) instead.

But if you must subscribe within a service or component (or maybe also do this for safety and good programming practice), your service should have an ngOnDestroy that calls this.breakpointSubject?complete();.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 JSmart523