Search code examples
angularunit-testingrxjsjasmineangular-reactive-forms

Unit test valueChanges observable pipeline


Scenario

  • A LoginPageComponent expect a user input. This input is a key (phrase) of 6 characters.
  • As soon the user typed 6 characters, a loading state will be set to busy. When the loading completes the state will either be success or failed.
  • On failed state, an error message appears.
  • When the key is valid, the user will be directed to the dashboard.

What do I want to test?

  • Loading state is busy while loading
  • Loading state is error when failed
  • AuthService is only called with 6 character long keys

What is my problem?

Time.
How do I simulate an input (which is aware of the debounceTime) that serves my needs? Also the AuthService needs some asynchronous time to check the key, so I can't directly Assert. I can't also subscribe to the observable chain, because it is not public.

Code

export class LoginPage implements OnInit {
  loadingState = LoaderState.None;
  message: string;
  form: FormGroup = this.formBuilder.group({ key: '' });

  constructor(
    private authService: AuthService,
    private formBuilder: FormBuilder
  ) {}

  ngOnInit(): void {
    this.form.get('key')?.valueChanges.pipe(
        tap(() => (this.loadingState = LoaderState.None)),
        debounceTime(350),
        filter((key: string) => key.length === 8),
        tap(() => (this.loadingState = LoaderState.Loading)),
        switchMap((key: string) =>
          of(key).pipe(
            switchMap(() => this.authService.authenticate(key)),
            catchError((error) => this.handleErrorStatusCode(error))
          )
        ),
        tap(() => (this.loadingState = LoaderState.Done))
      )
      .subscribe((_) => {
        console.log('success'); //TODO: Navigate
      });
  }

  private handleErrorStatusCode(error: any): Observable<never> {
    this.loadingState = LoaderState.Failed;
    // Set error logic...
    return EMPTY;
  }
}

Solution

  • I finally got it. I was thinking to much about the new TestScheduler and marble testing. But this was no the way to go. Instead fakeAsync from Zone.js fits very well:

    describe('LoginPage', () => {
      let component: LoginPage;
      let mockAuthService: any;
      let fixture: ComponentFixture<LoginPage>;
    
      beforeEach(async () => {
        mockAuthService = jasmine.createSpyObj(['authenticate']);
        await TestBed.configureTestingModule({
          declarations: [LoginPage],
          imports: [ReactiveFormsModule],
          providers: [{ provide: AuthService, useValue: mockAuthService }]
        }).compileComponents();
      });
    
      beforeEach(() => {
        fixture = TestBed.createComponent(LoginPage);
        component = fixture.componentInstance;
        fixture.detectChanges();
      });
    
      it('should create', () => {
        expect(component).toBeTruthy();
      });
    
      it('is in busy state while loading', fakeAsync(() => {
        mockAuthService.authenticate.and.returnValue(of('result').pipe(delay(100)));
    
        component.form.patchValue({ token: '123456' });
        tick(250);
        discardPeriodicTasks();
    
        expect(component.loadingState).toBe(LoaderState.Loading);
      }));
    
      it('it is in error state when auth service denies', fakeAsync(() => {
        mockAuthService.authenticate.and.returnValue(throwError({ status: 401 }));
    
        component.form.patchValue({ token: '123456' });
        tick(250);
        expect(component.loadingState).toBe(LoaderState.Failed);
        expect(component.message).toBeDefined();
      }));
    
      it('is in success state when auth service accept the key', fakeAsync(() => {
        mockAuthService.authenticate.and.returnValue(of('result'));
    
        component.form.patchValue({ key: '123456' });
        tick(250);
        expect(component.loadingState).toBe(LoaderState.Done);
      }));
    
      it('resets state on input', fakeAsync(() => {
        mockAuthService.authenticate.and.returnValue(of('token'));
    
        component.form.patchValue({ key: '123456' });
        tick(250);
        expect(component.loadingState).toBe(LoaderState.Done);
    
        component.form.patchValue({ key: '12345' });
        tick(250);
        expect(component.loadingState).toBe(LoaderState.Idle);
      }));
    
      it('should not have error message after construction', () => {
        expect(component.message).toBeNull();
      });
    
      it('is in idle state after construction', () => {
        expect(component.loadingState).toBe(LoaderState.Idle);
      });
    });
    

    With the tick() method time manipulation was no problem!