How to test subscription inside method

864 views Asked by At

Consider this angular component:

export class CheckoutComponent {
  constructor(private paymentService: PaymentService, private paymentModalService: PaymentModalService) {}

  public onCheckout(): void {
    const paymentStatus$: Observable<string> = this.paymentService.getStatus();

    const paymentRequired$ = paymentStatus$.pipe(map(paymentStatus => paymentStatus === 'INCOMPLETE'));

    paymentRequired$.subscribe(() => {
      this.paymentModalService.open();
    });
  }
}

I'm trying to write a jasmine test to confirm that paymentModalService.open() was called:

import { cold } from 'jasmine-marbles';

describe('CheckoutComponent', () => {
  let component: CheckoutComponent;
  let mockPaymentService: jasmine.SpyObj<PaymentService>;
  let mockPaymentModalService: jasmine.SpyObj<PaymentModalService>;

  beforeEach(() => {
    mockPaymentService = jasmine.createSpyObj(['getStatus']);
    mockPaymentModalService = jasmine.createSpyObj(['open']);
    component = new CheckoutComponent(mockPaymentService, mockPaymentModalService);
  });

  it('should open payment modal if required', () => {
    mockPaymentService.getStatus.and.returnValue(cold('a', { a: 'INCOMPLETE' }));
    component.onCheckout();

    expect(mockPaymentModalService.open).toHaveBeenCalled(); // FAIL: was never called
  });
});

However the open method was apparently never called.

Is there any way to wait for the cold test observable to complete emit before asserting that the method has been called?

I've tried using fakeAsync and tick but it does not either seem to help. I realize it works with of() instead of a TestColdObservable, but I would like more granular control of observable behavior. I don't really intend to complete the getStatus() observable.

3

There are 3 answers

1
nomadoda On BEST ANSWER

Reading documentation on component marble tests I found that I need to flush the test scheduler like so:

import { cold, getTestScheduler } from 'jasmine-marbles';

it('should open payment modal if required', () => {
  mockPaymentService.getStatus.and.returnValue(cold('a', { a: 'INCOMPLETE' }));
  component.onCheckout();
  getTestScheduler().flush(); // <=== prompt the scheduler to execute all of its queued actions

  expect(mockPaymentModalService.open).toHaveBeenCalled(); // SUCCESS
});
1
vaira On

Your main question is:

I would like more granular control of observable behavior. I don't really intend to complete the getStatus() observable.

If you do not want to complete it then it essentially is not a cold observable, its a hot observable, that would mean you can create a subject and pass

   var sub = new Subject();
    mockPaymentService.getStatus.and.returnValue(sub.asObservable());
    // run method and let it subscribe 
    component.onCheckout();
    
// fire it
    sub.next('a', { a: 'INCOMPLETE' });
 expect(mockPaymentModalService.open).toHaveBeenCalled();

I would suggest you go through the document of how to write component test-cases: Angular guide to writing test cases

Problem: In your current method of testing you won't be able to run component life-cyle, and instead would be running them manually and skip on real-life test cases. It is fine for service but not components.

0
Picci On

Let me suggest you something, which is not really to the point of your question but can help anyways.

My suggestion is to move logic from the component to the service.

Looking at your code it seems that the CheckoutComponent needs an observable, paymentRequired$, which notifies when a payment is required.

Let's assume this.paymentService.getStatus() is a call to a back end API, which returns the status of payments. In this case, what I would do in the service would be something like this

export class PaymentService {
  constructor(http, ...)

  // Private Subjects - using Subject to notify allows multicasting
  private _paymentRequired$ = new Subject<boolean>();

  // Public API Streams
  public paymentRequired$ = this._paymentRequired$.asObservable();

  // Public API methods
  public getStatus() {
    this.http.get(....).pipe(
      tap({
        next: resp => this._paymentRequired$.next(resp === 'INCOMPLETE'),
        error: err => {// manage the error case}
      })
    )
  }
}

If you move the logic to the service like this, then the CheckoutComponent can simply subscribe to the paymentService.paymentRequired$ observable in its ngOnInit method, and the the test can deal only with service and not the component.

In particular the test would have to instanciate somehow the service, subscribe to the paymentRequired$ observable, invoke getStatus() and check if paymentRequired$ has notified.