Testing Redirect Effects page

Learn how to write unit tests for NgRx Effects that redirect the user.

Quick Start: You can checkout this branch to get your codebase ready to work on this section.

Overview

  1. Verify navigation to the dashboard page occurs when LoginActions.loginSuccess Action dispatches.

  2. Verify navigation to the login page occurs when LoginActions.logoutSuccess Action dispatches.

Running Tests

To run unit tests in your project, you can either use the test npm script, or the ng test command:

npm run test
# or
ng test --watch

The --watch switch will rerun your tests whenever a code file changes. You can skip it to just run all tests once.

Description

When testing Effects, we will verify side-effects are executed properly when an appropriate Action is dispatched. In our case, we are working with Effects that cause navigation, so we can take advantage of the RouterTestingModule.

Update login.effects.spec.ts

We will walk through updating src/app/store/login/login.effects.spec.ts to run tests for your Effects.

Updating our TestBed

When testing navigation in Angular, we can take advantage of the RouterTestingModule. Using the static withRoutes() method, we can prepare our tests to navigate to a mock login and dashboard page:

src/app/store/login/login.effects.spec.ts

// src/app/store/login/login.effects.spec.ts

import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { LoginEffects } from './login.effects';
import { Action } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from './login.actions';
import { Credentials, LoginService } from 'ngx-learn-ngrx';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';

@Component({
  selector: 'app-mock',
})
class MockComponent {}

const mockLoginService = {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  login: (credentials: Credentials) => {
    return of({ userId: 'some-user-id', token: 'some-token' });
  },
  logout: () => of(null),
} as LoginService;

describe('LoginEffects', () => {
  let actions$: Observable<Action>;
  let effects: LoginEffects;

  let loginService: LoginService;
  let router: Router;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: '', component: MockComponent },
          { path: 'dashboard', component: MockComponent },
        ]),
      ],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

    effects = TestBed.inject(LoginEffects);
    loginService = TestBed.inject(LoginService);
    router = TestBed.inject(Router);
  });

  describe('login$', () => {
    beforeEach(() => {
      actions$ = of(
        LoginActions.login({
          username: 'some-username',
          password: 'some-password',
        })
      );
    });

    it('should get dispatch LoginActions.loginSuccess on api success', done => {
      const spy = spyOn(loginService, 'login').and.callThrough();

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginSuccess({
            username: 'some-username',
            userId: 'some-user-id',
            token: 'some-token',
          })
        );

        done();
      });
    });

    it('should get dispatch LoginActions.loginFailure on api error', done => {
      const spy = spyOn(loginService, 'login').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });

  describe('logout$', () => {
    beforeEach(() => {
      actions$ = of(LoginActions.logout());
    });

    it('should get dispatch LoginActions.logoutSuccess on api success', done => {
      effects.logout$.subscribe(action => {
        expect(action).toEqual(LoginActions.logoutSuccess());
        done();
      });
    });

    it('should get dispatch LoginActions.logoutFailure on api error', done => {
      const spy = spyOn(loginService, 'logout').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.logout$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith();

        expect(action).toEqual(
          LoginActions.logoutFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });
});

Verifying Navigation to Dashboard Page When LoginActions.loginSuccess Action Dispatches

Here we can use a spy to verify Router is used to navigate to the dashboard page:

src/app/store/login/login.effects.spec.ts

// src/app/store/login/login.effects.spec.ts

import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { LoginEffects } from './login.effects';
import { Action } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from './login.actions';
import { Credentials, LoginService } from 'ngx-learn-ngrx';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';

@Component({
  selector: 'app-mock',
})
class MockComponent {}

const mockLoginService = {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  login: (credentials: Credentials) => {
    return of({ userId: 'some-user-id', token: 'some-token' });
  },
  logout: () => of(null),
} as LoginService;

describe('LoginEffects', () => {
  let actions$: Observable<Action>;
  let effects: LoginEffects;

  let loginService: LoginService;
  let router: Router;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: '', component: MockComponent },
          { path: 'dashboard', component: MockComponent },
        ]),
      ],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

    effects = TestBed.inject(LoginEffects);
    loginService = TestBed.inject(LoginService);
    router = TestBed.inject(Router);
  });

  describe('login$', () => {
    beforeEach(() => {
      actions$ = of(
        LoginActions.login({
          username: 'some-username',
          password: 'some-password',
        })
      );
    });

    it('should get dispatch LoginActions.loginSuccess on api success', done => {
      const spy = spyOn(loginService, 'login').and.callThrough();

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginSuccess({
            username: 'some-username',
            userId: 'some-user-id',
            token: 'some-token',
          })
        );

        done();
      });
    });

    it('should get dispatch LoginActions.loginFailure on api error', done => {
      const spy = spyOn(loginService, 'login').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });

  describe('loginSuccess$', () => {
    beforeEach(() => {
      actions$ = of(
        LoginActions.loginSuccess({
          userId: 'some-user-id',
          username: 'some-username',
          token: 'some-token',
        })
      );
    });

    it('should navigate to dashboard', done => {
      const spy = spyOn(router, 'navigate').and.callThrough();

      effects.loginSuccess$.subscribe(() => {
        expect(spy).toHaveBeenCalledOnceWith(['dashboard']);
        done();
      });
    });
  });

  describe('logout$', () => {
    beforeEach(() => {
      actions$ = of(LoginActions.logout());
    });

    it('should get dispatch LoginActions.logoutSuccess on api success', done => {
      effects.logout$.subscribe(action => {
        expect(action).toEqual(LoginActions.logoutSuccess());
        done();
      });
    });

    it('should get dispatch LoginActions.logoutFailure on api error', done => {
      const spy = spyOn(loginService, 'logout').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.logout$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith();

        expect(action).toEqual(
          LoginActions.logoutFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });
});

Verifying Navigation to Login Page When LoginActions.logoutSuccess Action Dispatches

Here we can use a spy to verify Router is used to navigate to the login page:

src/app/store/login/login.effects.spec.ts

// src/app/store/login/login.effects.spec.ts

import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { LoginEffects } from './login.effects';
import { Action } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from './login.actions';
import { Credentials, LoginService } from 'ngx-learn-ngrx';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';

@Component({
  selector: 'app-mock',
})
class MockComponent {}

const mockLoginService = {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  login: (credentials: Credentials) => {
    return of({ userId: 'some-user-id', token: 'some-token' });
  },
  logout: () => of(null),
} as LoginService;

describe('LoginEffects', () => {
  let actions$: Observable<Action>;
  let effects: LoginEffects;

  let loginService: LoginService;
  let router: Router;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: '', component: MockComponent },
          { path: 'dashboard', component: MockComponent },
        ]),
      ],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

    effects = TestBed.inject(LoginEffects);
    loginService = TestBed.inject(LoginService);
    router = TestBed.inject(Router);
  });

  describe('login$', () => {
    beforeEach(() => {
      actions$ = of(
        LoginActions.login({
          username: 'some-username',
          password: 'some-password',
        })
      );
    });

    it('should get dispatch LoginActions.loginSuccess on api success', done => {
      const spy = spyOn(loginService, 'login').and.callThrough();

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginSuccess({
            username: 'some-username',
            userId: 'some-user-id',
            token: 'some-token',
          })
        );

        done();
      });
    });

    it('should get dispatch LoginActions.loginFailure on api error', done => {
      const spy = spyOn(loginService, 'login').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });

  describe('loginSuccess$', () => {
    beforeEach(() => {
      actions$ = of(
        LoginActions.loginSuccess({
          userId: 'some-user-id',
          username: 'some-username',
          token: 'some-token',
        })
      );
    });

    it('should navigate to dashboard', done => {
      const spy = spyOn(router, 'navigate').and.callThrough();

      effects.loginSuccess$.subscribe(() => {
        expect(spy).toHaveBeenCalledOnceWith(['dashboard']);
        done();
      });
    });
  });

  describe('logout$', () => {
    beforeEach(() => {
      actions$ = of(LoginActions.logout());
    });

    it('should get dispatch LoginActions.logoutSuccess on api success', done => {
      effects.logout$.subscribe(action => {
        expect(action).toEqual(LoginActions.logoutSuccess());
        done();
      });
    });

    it('should get dispatch LoginActions.logoutFailure on api error', done => {
      const spy = spyOn(loginService, 'logout').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.logout$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith();

        expect(action).toEqual(
          LoginActions.logoutFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });

  describe('logoutSuccess$', () => {
    beforeEach(() => {
      actions$ = of(LoginActions.logoutSuccess());
    });

    it('should navigate to login', done => {
      const spy = spyOn(router, 'navigate').and.callThrough();

      effects.logoutSuccess$.subscribe(() => {
        expect(spy).toHaveBeenCalledOnceWith(['']);
        done();
      });
    });
  });
});

Final Result

At the end of this section, the following spec file(s) should be updated. After each spec file has been updated and all the tests have passed, this means that all the previous sections have been completed successfully:

src/app/store/login/login.effects.spec.ts

// src/app/store/login/login.effects.spec.ts

import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Observable, of, throwError } from 'rxjs';
import { LoginEffects } from './login.effects';
import { Action } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from './login.actions';
import { Credentials, LoginService } from 'ngx-learn-ngrx';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core';

@Component({
  selector: 'app-mock',
})
class MockComponent {}

const mockLoginService = {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  login: (credentials: Credentials) => {
    return of({ userId: 'some-user-id', token: 'some-token' });
  },
  logout: () => of(null),
} as LoginService;

describe('LoginEffects', () => {
  let actions$: Observable<Action>;
  let effects: LoginEffects;

  let loginService: LoginService;
  let router: Router;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: '', component: MockComponent },
          { path: 'dashboard', component: MockComponent },
        ]),
      ],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

    effects = TestBed.inject(LoginEffects);
    loginService = TestBed.inject(LoginService);
    router = TestBed.inject(Router);
  });

  describe('login$', () => {
    beforeEach(() => {
      actions$ = of(
        LoginActions.login({
          username: 'some-username',
          password: 'some-password',
        })
      );
    });

    it('should get dispatch LoginActions.loginSuccess on api success', done => {
      const spy = spyOn(loginService, 'login').and.callThrough();

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginSuccess({
            username: 'some-username',
            userId: 'some-user-id',
            token: 'some-token',
          })
        );

        done();
      });
    });

    it('should get dispatch LoginActions.loginFailure on api error', done => {
      const spy = spyOn(loginService, 'login').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.login$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith({
          username: 'some-username',
          password: 'some-password',
        });

        expect(action).toEqual(
          LoginActions.loginFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });

  describe('loginSuccess$', () => {
    beforeEach(() => {
      actions$ = of(
        LoginActions.loginSuccess({
          userId: 'some-user-id',
          username: 'some-username',
          token: 'some-token',
        })
      );
    });

    it('should navigate to dashboard', done => {
      const spy = spyOn(router, 'navigate').and.callThrough();

      effects.loginSuccess$.subscribe(() => {
        expect(spy).toHaveBeenCalledOnceWith(['dashboard']);
        done();
      });
    });
  });

  describe('logout$', () => {
    beforeEach(() => {
      actions$ = of(LoginActions.logout());
    });

    it('should get dispatch LoginActions.logoutSuccess on api success', done => {
      effects.logout$.subscribe(action => {
        expect(action).toEqual(LoginActions.logoutSuccess());
        done();
      });
    });

    it('should get dispatch LoginActions.logoutFailure on api error', done => {
      const spy = spyOn(loginService, 'logout').and.returnValue(
        throwError(() => new Error('some error message'))
      );

      effects.logout$.subscribe(action => {
        expect(spy).toHaveBeenCalledOnceWith();

        expect(action).toEqual(
          LoginActions.logoutFailure({
            errorMsg: 'some error message',
          })
        );

        done();
      });
    });
  });

  describe('logoutSuccess$', () => {
    beforeEach(() => {
      actions$ = of(LoginActions.logoutSuccess());
    });

    it('should navigate to login', done => {
      const spy = spyOn(router, 'navigate').and.callThrough();

      effects.logoutSuccess$.subscribe(() => {
        expect(spy).toHaveBeenCalledOnceWith(['']);
        done();
      });
    });
  });
});

Wrap-up: By the end of this section, your code should match this branch. You can also compare the code changes for our solution to this section on GitHub or you can use the following command in your terminal:

git diff origin/test-redirect-effects