Testing API Effects page

Learn how to write unit tests for NgRx Effects.

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

Overview

  1. Verify LoginActions.loginSuccess Action dispatches when API request is successful.

  2. Verify LoginActions.loginFailure Action dispatches when API request fails.

  3. Verify LoginActions.logoutSuccess Action dispatches when API request is successful.

  4. Verify LoginActions.logoutFailure Action dispatches when API request fails.

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 that a specific Action gets dispatched depending on the circumstances when an Effect is triggered. In our case, we are working with Effects that depend on an API response. We can take advantage of spies to simulate different API responses.

Update login.effects.spec.ts

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

Setting Up our TestBed

When testing Effects, we will need to mock the Actions class since it plays a major role on how Effects work. NgRx provides a convenient way to do this through provideMockActions():

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 } from 'rxjs';
import { LoginEffects } from './login.effects';
import { Action } from '@ngrx/store';
import { provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from './login.actions';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
      ],
    });

    effects = TestBed.inject(LoginEffects);
  });

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

One way to mock Actions is to set actions$ to be an Observable that emits whatever Action we want for our tests:

// Note: This example code is not part of our application repo or solution

describe('login$', () => {
  beforeEach(() => {
    // Mock `actions$` with `Observable` that emits an Action
    // and its payload for upcoming tests
    actions$ = of(
      LoginActions.login({
        username: 'some-username',
        password: 'some-password',
      })
    );
  });

  it('should...', () => {
    effects.login$.subscribe(action => {
      // This `action` value will be whatever Action is dispatched
      // based on our mocked value for `actions$`
    });
  });
});

Mocking LoginService

Since our Effects use LoginService from ngx-learn-ngrx, we will need to also mock this Service:

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 } 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';

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;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

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

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

Verifying LoginEffects.login$ Effect Dispatches LoginActions.loginSuccess When API Request is Successful

We will create a spy to verify LoginService.login() is called with the expected arguments. Then we will verify that LoginActions.loginSuccess is dispatched with the proper payload:

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 } 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';

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;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

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

  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();
      });
    });
  });
});

Note that we are taking advantage of the done() callback to write an asynchronous test. This is common when writing tests where you subscribe to an Observable to perform a test.

Verifying LoginEffects.login$ Effect Dispatches LoginActions.loginFailure When API Request is NOT Successful

To verify different behaviors for LoginService, we can take advantage of spies to return a different return value:

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';

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;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

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

  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();
      });
    });
  });
});

Verifying LoginEffects.logout$ Effect Dispatches LoginActions.logoutSuccess When API Request is Successful

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';

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;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

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

  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();
      });
    });
  });
});

Verifying LoginEffects.logout$ Effect Dispatches LoginActions.logoutFailure When API Request is NOT Successful

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';

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;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

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

  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();
      });
    });
  });
});

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';

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;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [
        LoginEffects,
        provideMockActions(() => actions$),
        provideMockStore(),
        {
          provide: LoginService,
          useValue: mockLoginService,
        },
      ],
    });

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

  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();
      });
    });
  });
});

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-api-effects