Angular - How to test asynchronous code

Daniel Kreider
Daniel Kreider
Published on September 4th at 8:56am
  • Angular Testing
  • RxJS

The quick introduction to testing an Observable or Promise in your Angular project

Want to go straight to the code examples? Click here.

The Angular framework has a gang of dandy-cool features. 😎

One of those is that it comes loaded with the RxJS library - giving you the benefits of reactive programming for browser based applications.

rxjs%20dependency

It's common for a new Angular developer to wrestle with RxJS.

But once they've mastered it, they commonly conclude that RxJS is the coolest thing since sliced bread... until they're told to write tests for an Observable or Promise. 😅

For example...

Imagine we have a login function that's part of our login component.

  login(username: string, password: string): void {
    this.authService.login(username, password).subscribe(result => {
      this.router.navigateByUrl("/");
});

How do you test this function?

A reasonable test would call the login function with a valid username and password and then verify that we've been routed to the home page.

Like this.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { LoginComponent } from './login.component';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let routerSpy = jasmine.createSpyObj('Router', ['navigateByUrl']);

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ LoginComponent ],
      providers: [
        {
          provide: Router, useValue: routerSpy
        }
      ]
    })
    .compileComponents();
  });

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

  it('should login', () => {    
    component.login("username", "password");    
    const navArgs = routerSpy.navigateByUrl.calls.first().args[0];
    expect(navArgs).toEqual("/")
  });
});

But this test give us a null error, saying that our router spy was never called. 😼

type%20error

The reason we get this error is because our expectation was called before the login function was finished.

So how do we fix this weird problem?

A quick way to wrap and test asynchronous code

Taking the failing test from before, all we have to do is use the fakeAsync and tick methods to fix our asynchronous conflicts.

Here's the new code.

  it('should login', fakeAsync(() => {    
    component.login("username", "password"); 
    tick();

    const navArgs = routerSpy.navigateByUrl.calls.first().args[0];
    expect(navArgs).toEqual("/")
}));

The new methods can be imported from @angular/core/testing like this.

import { fakeAsync, tick } from '@angular/core/testing';

And BANG! Our test is now passing! 😎

What did we just do?

To improve our understanding we'll investigate a few of the testing API's that come with Angular.

Angular's testing API for testing asynchronous operations

The Angular testing API comes with a handful of functions that are required when testing asynchronous code that includes things like observables and promises.

Below are the 3 key methods you'll need to know. You'll have to know and understand these to be able to effectively test your Angular application.

This is to simulate the asynchronous passage of time for any asynchronous code inside a fakeAsync zone.

For example, if your asynchronous function takes a second to return a value, you can use the tick function to simulate the passage of a second like this...

tick(1000);

...and then carry on with your testing.

This will wrap a function and execute it in the fakeAsync zone.

What does that mean?

It means we're given a zone where we can run asynchronous code, and control time using the tick function.

Here's how we use it.

describe('this test', () => {
  it('looks async but is synchronous', fakeAsync((): void => {
       let flag = false;
       setTimeout(() => {
         flag = true;
       }, 100);
       expect(flag).toBe(false);
       tick(50);
       expect(flag).toBe(false);
       tick(50);
       expect(flag).toBe(true);
     }));
});

This function creates an asynchronous test zone that will automatically complete when all asynchronous operations inside its test zone have completed.

Real-world examples

Example: Testing asynchronous search function

describe('ListComponent', () => {
  let spectator: Spectator<ListComponent>;
  const createComponent = createComponentFactory({
    component: ListComponent,
    imports: [ReactiveFormsModule],
    mocks: [UsersService]
  });

  it('should search users', fakeAsync(() => {
    spectator = createComponent({ detectChanges: false });
    const userService = spectator.get(UsersService);

    // Simulate HTTP request with mock data
    userService.search.andCallFake(() => timer(100).pipe(mapTo([{ id: 1 }, { id: 2 }])));
    // Run ngOnInit
    spectator.detectChanges();

    // Search..
    spectator.typeInElement('Netanel', 'input');

    // Advance the clock by 100 milis to run debounceTime(100)
    tick(100);
    spectator.detectChanges();
    expect(spectator.query('.loading')).toExist();

    // Advance the clock by 100 milis to run userService.search()
    tick(100);
    spectator.detectChanges();

    expect(userService.search).toHaveBeenCalledWith('Netanel');
    expect(spectator.query('.loading')).not.toExist();
    expect(spectator.queryAll('li').length).toEqual(2);
  }));
});

Example: DOM testing asynchronous operations

describe('IntervalComponent', () => {
  let spectator: Spectator<IntervalComponent>;
  const createComponent = createComponentFactory(IntervalComponent);

  it('should increment the number', fakeAsync(() => {
    spectator = createComponent({ detectChanges: false });
    // Initial number
    spectator.detectChanges();
    expect(spectator.query('p')).toHaveText('0');

    // Advance the clock by 1000 milliseconds
    tick(1000);
    spectator.detectChanges();
    expect(spectator.query('p')).toHaveText('50');

    // Advance the clock by 2000 milliseconds (1000 + 1000)
    tick(1000);
    spectator.detectChanges();
    expect(spectator.query('p')).toHaveText('100');
  }));
});

Example: Testing an asynchronous message

describe('TestComponent', () => {
  let spectator: Spectator<TestComponent>;
  const createComponent = createComponentFactory(TestComponent);

  beforeEach(() => (spectator = createComponent()));

  it('should show the message on submit and remove it after 2 seconds', fakeAsync(() => {
    expect(spectator.query('p')).not.toExist();
    spectator.click('button');
    expect(spectator.query('p')).toExist();

    // Advance the virtual clock by 2 seconds
    tick(2000);
    spectator.detectChanges();
    expect(spectator.query('p')).not.toExist();
  }));
});

Example: Testing an asynchronous service that returns a list of users

it('should resolve the promise and show the users list', fakeAsync(() => {
  const usersService = spectator.get(UsersService);
  usersService.getUsers.and.callFake(() => Promise.resolve([{ id: 1 }, { id: 2 }]));

  // Run ngOnInit
  spectator.detectChanges();

  // Resolve all Promises
  flushMicrotasks();

  spectator.detectChanges();
  expect(spectator.queryAll('li').length).toBe(2);
}));

Example: Testing an asynchronous login function

  it('should navigate to the home page on successful login', fakeAsync(() => {
    let authService = TestBed.inject(AuthService);

    component.loginForm.setValue({username: "bob", password: "123456"});
    fixture.detectChanges();

    let spy = spyOn(authService, 'login').and.returnValue(of(true));

    component.login();

    tick();

    fixture.detectChanges();
    const navArgs = router.navigateByUrl.calls.first().args[0];
    expect(navArgs).toEqual("/");
  }));

What's next?

Testing functions that return an observable or promise can be mighty hard - like herding a bunch of cats.

But once you master the fundamentals, especially the API's that come with the Angular testing framework, you'll become an Angular testing whiz.

Questions? Comments? Don't hesitate to reach out.

signature

Angular Consultant