Testing Angular Component using Jest

In this tutorial, we will go through the steps for testing Angular Component by using the Jest framework.

Since Angular is a widely used front-end application development framework, it is the responsibility of each developer to make sure that the components are implemented as per the requirements of the project.  

Unit Testing is one of the recommended approaches of guaranteeing the quality of the application. 

What is Unit Testing?

Unit testing is an approach of software testing where individual units of the code are tested against the test data. The purpose of unit testing is to validate that each unit of the software performs operations as per expectations. Unit testing involves the lowest cost because each test targets to a limited scope. Unit tests are fast and less time consuming. 

Understanding the Angular Testing Object Model

One of the best advantages of using Angular is that it provides the Testing Objects model. Now the question is why is this so important?  

The reason behind this is the execution of an  Angular application. The Angular application is executed using the NgModule environment configuration. This means that, all components and their dependencies e.g. standard module like FormsModule and other Angular Services,  are finally managed by NgModule; so naturally when we try to test the component, we need a similar environment to instantiate component with its dependencies. 

In Angular, to define the testing environment, we have the 'TestBed' class. This class is used to configure and initialize environment for unit testing. Figure 1 will give you an idea of the TestBed Class


Figure 1: The TestBed class

 As shown in  figure 1, the TestBed class provides an instance for Angular Components and Services with testing module so that the test can run successfully.          

ComponentFixture

This class is used for debugging and testing a component. This class contains the componentInstance. This property is used to provide an instance of the root component class. 

The nativeElement property of this class represents the native element at the root of the component. 

The detectChanges() method is used to trigger a change detection cycle for the component. This method will be executed when the test is used to trigger and event while testing the component. The destroy() method is triggered while the component is destructed.   

Using Jest to test Angular Component

Jest is JavaScript testing framework. It was created by Facebook engineers. We can use Jest with React, Angular, Vue, Node, etc. 

Jest has the following advantages:

  • Zero Config.
  • Tests are executed parallel in their own processes to maximize performance, so each test is isolated.
  • Since tests are executed in parallel, they are fast safe. 
  • Jest is well documented with a rich set of APIs.
  • It is easy to generate code coverage by using  --coverage switch. 
  • Jest uses headless JSDOM, the JSDOM is a JavaScript headless browser that is used to create realistic testing environment.      
Why are we using Jest for Unit Testing Angular Application instead of Jasmine and Karma?
The reason behind using Jest instead of Jasmine and karma is that unlike Jasmine and Karma, Jest does not need any browser to load the test in the browser and execute it. We can directly run tests parallel from the command prompt. 

One more advantage of using Jest framework is the simplicity of generating code coverage reports of Angular applications. 

Using Jest for Angular testing

We need following requirements to implementing testing:
  • Angular CLI
  • Node.js
  • Visual Studio Code
Step 1: Open the Node. js command prompt and run the following command on it:

npm install -g @angular/cli

This will install Angular CLI in the global scope. The Angular CLI provides ng command to create new Angular project.

Step 2: Create a new angular project using following command:

ng new my-ng-app

This command will create a new Angular Project. This project will be created with all required packages. By default, an Angular project uses Jasmine and Karma packages. We can see these packages installed in the project using package.json in devDependencies. The project contains karma.config.js. This file contains all require configurations for using Karma for testing. The project also contains tsconfig.spec.json file. This file defines TypeScript configuration for testing using Jasmine as shown in listing 1

  "extends": "./tsconfig.base.json",

  "compilerOptions": {

    "outDir": "./out-tsc/spec",

    "types": [

      "jasmine"

    ]

  },

  "files": [

    "src/test.ts",

    "src/polyfills.ts"

  ],

  "include": [

    "src/**/*.spec.ts",

    "src/**/*.d.ts"

  ]

}

Listing 1: The tsconfig.spec.json file     

The configuration in listing 1 is dependent on tsconfig.base.json. The tsconfig.base.json contains required configuration for TypeScript compilation. The tsconfig.spec.json contains types configuration for Jasmin by default. This means that the TypeScript compilation will include all .spec.ts files that contains Jasmine object model for testing. So now we need to replace this by using Jest.

Step 3: We need to run command shown in listing 2 to install Jest and Jest Preset Angular Packages.

npm install --save-dev jest jest-preset-angular

Listing 2: The jest package installation      

The jest-preset-angular package is the tool that makes it possible to run Angular Unit Tests using Jest.
This package includes ts-jest library. This library allows Jest to transpile the testing source code written in TypeScript in memory before running the test. This also performs snapshot serialization to enable snapshot testing for the Angular components.

Step 4: Once these packages are installed, we need to modify the tsconfig.spec.json file for using jest as shown in the listing 3     

  "extends": "./tsconfig.base.json",

  "compilerOptions": {

    "outDir": "./out-tsc/spec",

    "types": [

      "jest",

      "node"

    ],

    "esModuleInterop": true,

    "experimentalDecorators": true

  },

  "files": [

    "src/window-mock.ts",

    "src/polyfills.ts"

  ],

  "include": [

    "src/**/*.spec.ts",

    "src/**/*.d.ts"

  ]

}


Listing 3: The tsconfig.spec.json  file modification to use jest

The TypeScript files containing testing code using Jest object model will be transpiled using TypeScript configuration.

Step 5: Since Jest uses JSDOM to run the tests, we need to mock some of the properties of the global window object. To do that, in src folder add a new file and name it as window-mock.ts. In this file add a code as shown in listing 4 

// write the jest initialization for testing the angular w/o DOM

import 'jest-preset-angular'; 

 // HTML Template parsing using docType

Object.defineProperty(document, 'doctype', {

  value: '<!DOCTYPE html>'

});

 Object.defineProperty(document.body.style, 'transform', {

  value: () => {

    return {

      enumerable: true,

      configurable: true

    };

  }

Listing 4: The Window object mocking 

Listing 4 defines dotype and transform mock properties for the global objects. This will make sure that the HTML template of the component is loaded in JSDOM and will be parsed so that tests can be executed successfully.

Step 6: In the project, add a new file and name it as jest.config.js. In this file, we will add the Jest testing configuration as shown in the listing 5

const { pathsToModuleNameMapper } = require('ts-jest/utils');
// load all settings from the TypeScript configuration
const { compilerOptions } = require('./tsconfig.app.json');
module.exports  =   {  
    preset:   'jest-preset-angular', // load the adapater
    roots:  ['/src/'], // start searching for files from root
    testMatch:  ['**/+(*.)+(spec).+(ts|js)'], // test file extensions
    setupFilesAfterEnv:  ['/src/window-mock.ts'], // setup env file
    collectCoverage:  true, // code coverage
    coverageReporters:  ['html'], // generate the report in HTML
    coverageDirectory:   'coverage/my-ng-app', // folder for coverage
    moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { prefix: '/' })
};

Listing 5: The Jest Configuration

The code in listing 5 has the following specifications
  • The Jest configuration file reads compileOptions from the tsconfig.app.json. This file defines TypeScript compilation specifications.
  • The .spect.ts files will be read so that  Unit Tests developed using jest object model will be trsnapiled.
  • The JSDOM environment will be read from the window-mock.ts.
  • The coverage configuration will be set so that the coverage files will be generated in the coverage folder.
Step 7: In the app folder add a new file and name it as app.product.model.ts. In this file add the code as shown in the listing 6.
 
export class Product {
  constructor(
    public ProductRowId: number,
    public ProductId: string,
    public ProductName: string,
    public CategoryName: string,
    public Manufacturer: string,
    public Description: string,
    public BasePrice: number
  ){}
}
export const Categories = [
  'Electronics', 'Electrical', 'Food'
];

export const Manufacturers = [
  'HP', 'IBM', 'Bajaj', 'Phillipse', 'Parle', 'TATA'
];

 
Listing 6: The Product class and other constants

Step 8: In the app folder add a new file and name it as logic.ts. In this file add code as shown in listing 7

import {Product} from './app.product.model';
export class Logic {
    private products: Array;
    constructor(){
        this.products = new Array();
    }

    getProducts(): Array {
        this.products.push(new Product(1, 'Prd001', 'Laptop', 'Electronics', 'HP', 'Gaming', 120000));
        this.products.push(new Product(2, 'Prd002', 'Iron', 'Electrical', 'Bajaj', 'Cotton Friendly', 3000));
        this.products.push(new Product(3, 'Prd003', 'Biscuits', 'Food', 'Parle', 'Glucose', 10));
        return this.products;
    }
    addProduct(prd: Product): Array {
        this.products.push(prd);
        return this.products;
    }
}
Listing 7: The logic class

The code in the above listing performs read and write operations using Product class.

Step 9: In the app folder, add a new folder and name it as productcomponent. In this folder add a new file and name it as app.productform.component.ts. In this file add code as shown in listing 8


import { Component, OnInit } from '@angular/core';
import {Product,Manufacturers, Categories} from './../app.product.model';
import {Logic} from './../logic';

@Component({
  selector: 'app-productform-component',
  templateUrl: './app.productform.view.html'
})
// OnInit: Angular Component's lifecycle interface
export class ProductFormComponent implements OnInit {
  product: Product;
  products: Array;
  categories = Categories;
  manufacturers = Manufacturers;
  private logic: Logic;
  columnHeaders: Array;
  tax: number;

  constructor() {
    this.product = new Product(0, '', '', '', '', '', 0);
    this.products = new Array();
    this.logic = new Logic();
    this.columnHeaders = new Array();
    this.tax = 0;
  }
  
  ngOnInit(): void {
    this.products  =  this.logic.getProducts();
    console.log(JSON.stringify(this.products));
    // read properties from product object
    for (const p of Object.keys(this.product)) {
       this.columnHeaders.push(p);
    }
    console.log(JSON.stringify(this.columnHeaders));
  }
  clear(): void {
    this.product = new Product(0, '', '', '', '', '', 0);
  }
  save(): void {
    this.tax = this.product.BasePrice * 0.2;
    this.products = this.logic.addProduct(this.product);
    console.log(JSON.stringify(this.products));
  }
  getSelectedProduct(event): void {
     this.product = Object.assign({}, event);
  }
}

Listing 8:  The Product Form Component

The component in listing 8 uses the Product class, Categories and Manufacturers constants and the Logic class. 

The component class contains ngOnInit() method to read all products from the getProducts() method from Logic class. The ngOnInit() method also reads all public properties of the Product class. These properties are stored in columnHeaders array. This array will be used to generate the table dynamically. 

The clear() method re-declares the product instance. The save() method calculates the tax based on the BasePrice of the product. This method further adds the new product in the products array using addProduct() method from the Logic class. 

Step 10: In the productcomponent folder, add a new file and name it as app.productform.view.html. In this file add HTML mark as shown in listing 9


The Product Form Component

<table> <tbody><tr> <td> <div class="container"> <form name="frmProduct"> <div class="container"> <div class="form-group"> <label>Product Row Id</label> <input class="form-control" name="ProductRowId" [(ngModel)]="product.ProductRowId" type="text" /> </div> <div class="form-group"> <label>Product Id</label> <input class="form-control" name="ProductId" [(ngModel)]="product.ProductId" type="text" />
</div> <div class="form-group"> <label>Product Name</label> <input class="form-control" name="ProductName" [(ngModel)]="product.ProductName" type="text" />
</div> <div class="form-group"> <label>Category Name</label> <select class="form-control" name="CategoryName" [(ngModel)]="product.CategoryName="">
<option ngfor="let c of categories" value="">{{c}}</option> </select> </div> <div class="form-group"> <label>Manufacturer</label> <select class="form-control" name="Manufacturer" [(ngModel)]="product.Manufacturer>
<option ngfor="let m of Manufacturers" value="">{{m}}</option> </select> </div> </div> <div class="form-group"> <label>Description</label> <input class="form-control" name="Description" [(ngModel)]="product.Description" type="text" />
</div> <div class="form-group"> <label>Base Price</label> <input class="form-control" name="BasePrice" [(ngModel)]="product.BasePrice" type="text" />
<input class="form-control" disabled name="tax" type="text" [value]="tax" /> </div> <div class="form-group"> <input class="btn btn-warning" (click)="clear()" type="button" value="Clear" /> <input class="btn btn-success" (click)="save()" type="button" value="Save" /> </div> </form> </div> </td> </tr> </tbody></table>
Listing 9: The HTML template

The above markup has the databinding with the product property from the  Component class. The save button is bound with the save() method of the component class. 

Now lets write the test on the click event save button. The save() method of the component class calculates the tax based on the BasePrice of the product. We will write the test that will dispatch the click event and then calculate tax. The calculated tax will be displayed in the disabled input element having name as 'tax'.   

Step 11: In the  productformcomponent folder, add a new file and name it as app.productform.component.spec.ts. In this file, we will add logic for component testing. In this file add the code as shown in listing 10

// collect all required testing objects for Angular App
import { TestBed, ComponentFixture, async } from "@angular/core/testing";
// for Two-Way binding
import { FormsModule } from '@angular/forms';

// import component to be tested and its dependencies
import { Product} from './../app.product.model';
import { ProductFormComponent } from './app.productform.component';

// define the test suit
describe('ProductFormComponent', () => {
  // dfefine the required objects fot test
  let component: ProductFormComponent;
  // defining the Component Fixture to monitor changed in component
  // e.g. DataBinding changes
  let fixture: ComponentFixture;
  // define the HTML element
  let button: HTMLElement;

  // define the test env. so that the test will be
  // using Angular standard modules to execute test on component

  beforeEach(() => {
    // defin the TestBedConfiguration
    TestBed.configureTestingModule({
      declarations: [ProductFormComponent],
      imports: [FormsModule]
    }).compileComponents(); // the component will be compiled
                            // (includes HTML Tremplate)
  });
  // definition for all objects before test starts
  beforeEach(() => {
     // initiaze the fixture so that the component 'selector'
    // and its HTML template will be initialized
    fixture = TestBed.createComponent(ProductFormComponent);
    // read the component's instace to execute method in it
    component = fixture.componentInstance;
    // detect the first databinding changes
    fixture.detectChanges();
  });
  // the test case
  it('should calculate tax based on base price when save button is clicked', () => {
    // define the product instance
    const product = new Product(0, '', '', '', '', '', 0);
    console.log(`Conponent instance ${component}`);
    product.BasePrice = 4000;
    component.product = product;
    // receive the nativeElement for HTML Template DOM
    const element = fixture.nativeElement;
    // recive the button
    button = element.querySelector('.btn-success');
    // define an event
    // when the button dispatch the click event the
    // 'save()' method of the component will be called
    const eventType = button.dispatchEvent(new Event('click'));
    // detect any changed in HTML DOM against the dispatched event
    fixture.detectChanges();
    // asser the value in the disabled text element
    expect(element.querySelector('input[disabled]').value).toEqual('800');
  });
});

Listing 10: The code for testing the component

The code in the listing 10 is used to test the ProductFormComponent.  The code defines an instance of the ProductFormComponent using ComponentFixture class. The HtmlElement instance is defined to read Button element from the component's HTML template.  The TestBed.configureTestingModule() method is used to define the environment for the testing. This configuration declares ProductFormComponent so that it can be loaded in testing environment. 

The configuration imports FormsModule so that the Two-Way binding in the ProductFormComponent can be executed.  The TestBed.createComponent() method accepts the ProductFormComponent object as input parameter. This method returns an instance of the ComponentFixture class. This is used to initialize the component fixture so that the component and it's HTML template will be initialized and the initialized change detection is executed.  

The test case should calculate tax based on base price when save button is clicked uses the component instance and sets the BasePrice property of the Product class property in the component class to 4000. Furthermore, using the nativeElement property of the ComponentFixture class the HTML button element is read. The test case further dispatches the click event on this button. The testing process here is that when the click event is dispatched, the click event binding will be executed on HTML template of the component and this will invoke the save() method of the component class. This method will calculate the Tax based on baseprice of the product. This tax is bound with input text element. The following statement in the test case 

expect(element.querySelector('input[disabled]').value).toEqual('800');

..verifies the value displayed in textbox as 20% of the baseprice, if yes then the test is successful else it will fail.

Step 12: Run the test from the command prompt, as shown in the figure 2



Figure 2: Running the test

This will run the test and the result will be shown as in figure 3



Figure 3: The Test result            
  
In figure 3, we can see the test is successfully executed. 

Step 13: On visiting the project folder, you can see a coverage folder is created.  In this folder you can find the app folder is created. The app folder contains productcomponent folder.  This folder contains index.html. Open this file in a browser, this will show the code coverage report for the ProductFormComponent as shown in figure 4  



Figure 4: The coverage report 

If you click on app.productform.component.ts, the detailed report of the code coverage is displayed as shown in figure 5.



Figure 5: The actual code  in coverage 

The figure 5 shows the save() method code coverage.

Conclusion: The Jest Framework is useful to test an Angular component without the browser. The headless JSDOM provides an in-memory DOM testing for the angular component.





About The Author

Mahesh Sabnis is a Microsoft MVP having over 18 years of experience in IT education and development. He is a Microsoft Certified Trainer (MCT) since 2005 and has conducted various Corporate Training programs for .NET Technologies (all versions). He also blogs regularly at DotNetCurry.com. Follow him on twitter @maheshdotnet

No comments: