Testing Actions page

Learn how to write unit tests for NgRx Actions.

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

Overview

  1. Verify LoginActions.logout Action dispatches when calling DashboardComponent’s logout().

  2. Verify LoginActions.login Action dispatches with form payload when calling LoginComponent’s login().

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.

Introduction

Each implementation section will be paired with a testing section. These testing sections will go over the basics of how to test the implementation and use of NgRx. Before continuing, you should have an intermediate understanding of the following:

  1. Angular TestBeds - Although TestBeds aren’t required for testing Angular applications, and there are ways to test NgRx without a TestBed, we use TestBeds throughout this course.

  2. Jasmine Unit Tests - Throughout this course, we will be using Jasmine to test our application. Although the syntax will differ slightly between different testing tools such as Mocha and Jest, the concepts used throughout this course will apply to whatever solution you use in your future projects.

Description

In this section, we will write unit tests involving Actions. When testing Actions, we don’t typically test Actions directly. Instead, we test their use in Components, Effects, and Reducers. Throughout this course we will cover all these situations. For this section, we will verify that Actions are dispatched when expected by checking their use in Components.

Update dashboard.component.spec.ts

We will walk through updating src/app/dashboard/dashboard.component.spec.ts to run tests for your Actions.

Setting Up our TestBed

When unit testing in general, we should use stubs to isolate parts of our application. Luckily NgRx makes this process simple by providing a way to create a mock Store:

  1. MockStore - MockStore extends the Store class and provides stubs for its methods.

  2. provideMockStore() - Generates a MockStore instance given a configuration.

src/app/dashboard/dashboard.component.spec.ts

// src/app/dashboard/dashboard.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as fromLogin from '../store/login/login.reducer';

describe('DashboardComponent', () => {
  let component: DashboardComponent;
  let fixture: ComponentFixture<DashboardComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [DashboardComponent],
      providers: [provideMockStore({})],
    }).compileComponents();

    fixture = TestBed.createComponent(DashboardComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('logout()', () => {
    it('should dispatch logout action', () => {
      // TODO: Spy on dispatching action

      component.logout();

      // TODO: Verify that LoginActions.logout action was dispatched
    });
  });

  describe('username$', () => {
    it('should get username from login state', () => {
      // TODO: Verify username comes from login state
    });
  });

  describe('userId$', () => {
    it('should get userId from login state', () => {
      // TODO: Verify userId comes from login state
    });
  });
});

Verify LoginActions.logout Action Dispatches Properly

In src/app/dashboard/dashboard.component.spec.ts, there is a describe block with a TODO: "Spy on dispatching action". We will create a spy to track when our MockStore dispatches an Action:

src/app/dashboard/dashboard.component.spec.ts

// src/app/dashboard/dashboard.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as fromLogin from '../store/login/login.reducer';

describe('DashboardComponent', () => {
  let component: DashboardComponent;
  let fixture: ComponentFixture<DashboardComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [DashboardComponent],
      providers: [provideMockStore({})],
    }).compileComponents();

    fixture = TestBed.createComponent(DashboardComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('logout()', () => {
    it('should dispatch logout action', () => {
      const spy = spyOn(store, 'dispatch');

      component.logout();

      // TODO: Verify that LoginActions.logout action was dispatched
    });
  });

  describe('username$', () => {
    it('should get username from login state', () => {
      // TODO: Verify username comes from login state
    });
  });

  describe('userId$', () => {
    it('should get userId from login state', () => {
      // TODO: Verify userId comes from login state
    });
  });
});

Next we will verify that the expected Action was dispatched after logout() is called:

src/app/dashboard/dashboard.component.spec.ts

// src/app/dashboard/dashboard.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from '../store/login/login.actions';
import * as fromLogin from '../store/login/login.reducer';

describe('DashboardComponent', () => {
  let component: DashboardComponent;
  let fixture: ComponentFixture<DashboardComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [DashboardComponent],
      providers: [provideMockStore({})],
    }).compileComponents();

    fixture = TestBed.createComponent(DashboardComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('logout()', () => {
    it('should dispatch logout action', () => {
      const spy = spyOn(store, 'dispatch');

      component.logout();

      expect(spy).toHaveBeenCalledOnceWith(LoginActions.logout());
    });
  });

  describe('username$', () => {
    it('should get username from login state', () => {
      // TODO: Verify username comes from login state
    });
  });

  describe('userId$', () => {
    it('should get userId from login state', () => {
      // TODO: Verify userId comes from login state
    });
  });
});

Note that there are 2 more pending TODOs in src/app/dashboard/dashboard.component.spec.ts that will be resolved in upcoming sections. For now we’ll only be testing our Actions.

Update login.component.spec.ts

We will walk through updating src/app/login/login.component.spec.ts to run tests for your Actions.

Setting Up our TestBed

src/app/login/login.component.spec.ts

// src/app/login/login.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { LoginComponent } from './login.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as fromLogin from '../store/login/login.reducer';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [LoginComponent],
      providers: [provideMockStore({}), FormBuilder],
      imports: [ReactiveFormsModule],
    }).compileComponents();

    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('submit()', () => {
    it('should mark form as touched', () => {
      expect(component['form'].touched).toBe(false);
      component.submit();
      expect(component['form'].touched).toBe(true);
    });

    describe('when form is valid', () => {
      const mock = {
        username: 'some-username',
        password: 'some-password',
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should have a valid form', () => {
        // Verify that form is truly valid for upcoming tests
        expect(component['form'].valid).toBe(true);
      });

      it('should dispatch LoginActions.login', () => {
        // TODO: Spy on dispatching action

        component.submit();

        // TODO: Verify that LoginActions.login action was dispatched
      });
    });

    describe('when form is NOT valid', () => {
      const mock = {
        username: 'some-username',
        password: '', // password is required
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should NOT have a valid form', () => {
        // Verify that form is truly invalid for upcoming tests
        expect(component['form'].valid).toBe(false);
      });

      it('should NOT dispatch LoginActions.login', () => {
        // TODO: Spy on dispatching action

        component.submit();

        // TODO: Verify that no action was dispatched
      });
    });
  });
});

Verify LoginActions.login Action Dispatches Properly With Form Payload

Unlike the LoginActions.logout Action, LoginActions.login Action requires a payload when dispatched. In our test, we will pass the state of our form:

src/app/login/login.component.spec.ts

// src/app/login/login.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { LoginComponent } from './login.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from '../store/login/login.actions';
import * as fromLogin from '../store/login/login.reducer';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [LoginComponent],
      providers: [provideMockStore({}), FormBuilder],
      imports: [ReactiveFormsModule],
    }).compileComponents();

    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('submit()', () => {
    it('should mark form as touched', () => {
      expect(component['form'].touched).toBe(false);
      component.submit();
      expect(component['form'].touched).toBe(true);
    });

    describe('when form is valid', () => {
      const mock = {
        username: 'some-username',
        password: 'some-password',
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should have a valid form', () => {
        // Verify that form is truly valid for upcoming tests
        expect(component['form'].valid).toBe(true);
      });

      it('should dispatch LoginActions.login', () => {
        const spy = spyOn(store, 'dispatch');

        component.submit();

        expect(spy).toHaveBeenCalledOnceWith(LoginActions.login(mock));
      });
    });

    describe('when form is NOT valid', () => {
      const mock = {
        username: 'some-username',
        password: '', // password is required
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should NOT have a valid form', () => {
        // Verify that form is truly invalid for upcoming tests
        expect(component['form'].valid).toBe(false);
      });

      it('should NOT dispatch LoginActions.login', () => {
        // TODO: Spy on dispatching action

        component.submit();

        // TODO: Verify that no action was dispatched
      });
    });
  });
});

Verify LoginActions.login Action Does NOT Dispatch When Necessary

src/app/login/login.component.spec.ts

// src/app/login/login.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { LoginComponent } from './login.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from '../store/login/login.actions';
import * as fromLogin from '../store/login/login.reducer';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [LoginComponent],
      providers: [provideMockStore({}), FormBuilder],
      imports: [ReactiveFormsModule],
    }).compileComponents();

    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('submit()', () => {
    it('should mark form as touched', () => {
      expect(component['form'].touched).toBe(false);
      component.submit();
      expect(component['form'].touched).toBe(true);
    });

    describe('when form is valid', () => {
      const mock = {
        username: 'some-username',
        password: 'some-password',
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should have a valid form', () => {
        // Verify that form is truly valid for upcoming tests
        expect(component['form'].valid).toBe(true);
      });

      it('should dispatch LoginActions.login', () => {
        const spy = spyOn(store, 'dispatch');

        component.submit();

        expect(spy).toHaveBeenCalledOnceWith(LoginActions.login(mock));
      });
    });

    describe('when form is NOT valid', () => {
      const mock = {
        username: 'some-username',
        password: '', // password is required
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should NOT have a valid form', () => {
        // Verify that form is truly invalid for upcoming tests
        expect(component['form'].valid).toBe(false);
      });

      it('should NOT dispatch LoginActions.login', () => {
        const spy = spyOn(store, 'dispatch');

        component.submit();

        expect(spy).not.toHaveBeenCalled();
      });
    });
  });
});

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/dashboard/dashboard.component.spec.ts

// src/app/dashboard/dashboard.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from '../store/login/login.actions';
import * as fromLogin from '../store/login/login.reducer';

describe('DashboardComponent', () => {
  let component: DashboardComponent;
  let fixture: ComponentFixture<DashboardComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [DashboardComponent],
      providers: [provideMockStore({})],
    }).compileComponents();

    fixture = TestBed.createComponent(DashboardComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('logout()', () => {
    it('should dispatch logout action', () => {
      const spy = spyOn(store, 'dispatch');

      component.logout();

      expect(spy).toHaveBeenCalledOnceWith(LoginActions.logout());
    });
  });

  describe('username$', () => {
    it('should get username from login state', () => {
      // TODO: Verify username comes from login state
    });
  });

  describe('userId$', () => {
    it('should get userId from login state', () => {
      // TODO: Verify userId comes from login state
    });
  });
});

src/app/login/login.component.spec.ts

// src/app/login/login.component.spec.ts

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { LoginComponent } from './login.component';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import * as LoginActions from '../store/login/login.actions';
import * as fromLogin from '../store/login/login.reducer';

describe('LoginComponent', () => {
  let component: LoginComponent;
  let fixture: ComponentFixture<LoginComponent>;
  let store: MockStore<fromLogin.LoginPartialState>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [LoginComponent],
      providers: [provideMockStore({}), FormBuilder],
      imports: [ReactiveFormsModule],
    }).compileComponents();

    fixture = TestBed.createComponent(LoginComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
    store = TestBed.inject(MockStore);
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('submit()', () => {
    it('should mark form as touched', () => {
      expect(component['form'].touched).toBe(false);
      component.submit();
      expect(component['form'].touched).toBe(true);
    });

    describe('when form is valid', () => {
      const mock = {
        username: 'some-username',
        password: 'some-password',
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should have a valid form', () => {
        // Verify that form is truly valid for upcoming tests
        expect(component['form'].valid).toBe(true);
      });

      it('should dispatch LoginActions.login', () => {
        const spy = spyOn(store, 'dispatch');

        component.submit();

        expect(spy).toHaveBeenCalledOnceWith(LoginActions.login(mock));
      });
    });

    describe('when form is NOT valid', () => {
      const mock = {
        username: 'some-username',
        password: '', // password is required
      };

      beforeEach(() => {
        component['form'].setValue(mock);
      });

      it('should NOT have a valid form', () => {
        // Verify that form is truly invalid for upcoming tests
        expect(component['form'].valid).toBe(false);
      });

      it('should NOT dispatch LoginActions.login', () => {
        const spy = spyOn(store, 'dispatch');

        component.submit();

        expect(spy).not.toHaveBeenCalled();
      });
    });
  });
});

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-actions