How to Write Unit Tests with Jasmine and Karma?

Khushbu Choksi
Simform Engineering
17 min readMay 31, 2023

Level up Angular development with Jasmine and Karma: Mastering unit testing to build reliable applications.

Before diving into the details of this topic, it’s essential for you to understand the significance of unit testing. If you’re new to unit testing or need a refresher that explains the importance of unit testing and why it should be an integral part of the development process, I recommend you refer to this comprehensive article.

In this guide, we will focus on the power of Jasmine and Karma, popular testing frameworks in Angular, to effectively write unit tests.

What are Jasmine and Karma?

Jasmine is a JavaScript behavior-driven testing framework that helps you write test cases in a human-readable way. Simply put, Jasmine allows us to write code (test case) that tests our functional code to achieve a specific requirement.

Karma is a test runner that executes the test we write with Jasmine. It also provides features like live reloading of test cases, code coverage reporting, and integration with continuous integration (CI) tools like Jenkins and Travis CI.

How to write a unit test with Jasmine?

To write a unit test, we need to create a spec.ts file or test case file. Generally, Angular generates a test case file on its own and provides a basic skeleton of the test case. It will follow the below-mentioned structure to write test cases.

  1. describe(): It is used to group related test cases. It accepts two arguments: a string that describes the group of specs(test cases), and a function that contains the specs(test cases) or nested describe statements. It is known as a test group or test suite.
  2. It(): It is used to define a single test spec. It also takes two arguments: a test spec description and a function that contains the expectation or assertions for the spec.
  3. Expect(): It is used to create an expectation or assertion in a test spec. It takes a single argument, which is the value that you want to test, and then a matcher function that tests the value against the expected value.

Let’s understand this structure with a basic example.

import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {

constructor() {}

add(a: number, b: number) {
return a + b;
}
}

Here, in the app component, we have written the add()method which takes two parameters aand b, and returns the sum of them. Let’s understand how to write test cases for this component.

Step — 1 Create a test group or test suite by writing describe() a method for the app component.

describe('AppComponent', () => {
// Here, we will write test cases
});

Step — 2 Create a test case that checks that the app component is initialized.

Following a standardized AAA(Arrange-Act-Assert) pattern is recommended to write a test case. Also, the test case should be independent.

import { AppComponent } from "./app.component";

describe('AppComponent', () => {

// Testcase that mentioned with it()
it('should component initialized', () => {
const component = new AppComponent();
expect(component).toBeTruthy();
});
});

Here, we have created a component instance and asserted that the instance variable’s value is truthy by using the toBeTruthy() matcher.

Jasmine provides various types of matchers to assert the data. Please check this to explore other matchers.

Step — 3 Write a test case for add() method.

import { AppComponent } from "./app.component";

describe('AppComponent', () => {

// Testcase that mentioned with it()
it('should component initialized', () => {
const component = new AppComponent();
expect(component).toBeTruthy();
});

// Testcase for add method
it('should test sum of two numbers', () => {

// Arrange
const component = new AppComponent();
const a = 5;
const b = 5;

// Act
const total = component.add(a, b);

// Assert
expect(total).toEqual(10);
});
});

To write a test case for add() method, we have created a new it() . Inside that, we have arranged the necessary things to perform add operation. The add operation is getting component instances and values for aand b . After that, we call the component.add(a, b) method and assert its results.

Step — 4 Run these test cases by using the following command:

npm run test 

// OR

ng test

When we hit this command, Angular sets up a testing environment, loads and executes the tests, and reports the results to the developer in the terminal.

Everything seems to be good, but have you noticed that we have initialized a component for each test spec? Here it seems to be okay because the component is too small. If we have multiple methods in a component, writing initialization code for every spec is quite tedious, isn’t it? To avoid this, we can use methods like beforeEach(), beforeAll(), afterEach(), afterAll()

Setup and Teardown:

  1. beforeEach(): Called before each test specification
  2. beforeAll(): Called once before all the specification
  3. afterEach(): Called after each test specification
  4. afterAll(): Called once after all the specifications.

On using beforeEach() in the above code, it will look something like this:

import { AppComponent } from "./app.component";

describe('AppComponent', () => {
let component: AppComponent;
beforeEach(() => {
component = new AppComponent();
});

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

it('should test sum of two numbers', () => {
component = new AppComponent();
const a = 5;
const b = 5;
const total = component.add(a, b);
expect(total).toEqual(10)
});
});

As we know, when we create the component through the Angular CLI, it generates four files for us, one of which is the spec.ts file with some initial content. For example, consider the app.component.spec.ts file, and let’s see its initial content.

You might have noticed that Angular generates a spec.ts file with TestBed which uses the configurationTestingModule, compilComponents, and createComponents methods initially. Let’s understand what TestBed is.

What is TestBed?

TestBed is a configured environment used for testing applications. It includes all necessary components and conditions for testing the system. Tests are performed by inputting data and evaluating output to ensure correct functionality. Thus, TestBed provides a controlled environment to identify defects, improve system quality, and meet requirements.

For example, here, a browser instance can serve as a TestBed, simulating real-world scenarios and providing test results for test cases.

TestBed.configureTestingModule:

It is a method provided by the Angular Testing library for configuring a test module before running unit tests in an Angular application.

When writing unit tests for Angular components, services, directives, and pipes, we can use TestBed to create a test module that provides the necessary dependencies for the component or service being tested.

As we know, a generated component is declared in the declarations array of NgModule decorator under the app.module.ts file. Similarly, we have used this in the app.component.spec.ts file for testing.

In Angular, the compileComponents method is used to compile components and their templates during the testing process. This ensures that the component's template is available for testing.

TestBed.createComponent method, which creates an instance of the AppComponent,returns the fixture which is an object that allows us to create and interact with a component during testing. It gives us access to the component’s properties and methods, as well as its associated DOM elements so that we can test its behavior. It’s an essential tool for writing effective unit tests in Angular.

Read more about TestBed: https://angular.io/api/core/testing/TestBed

So far, we have covered the fundamental aspects of unit testing. In real-world scenarios, we will apply this knowledge to write test specifications for various components, services, API calls, forms, and more. Before we delve into these topics, take a moment to review what I have implemented so far.

In a nutshell, this is a very basic CRUD application in which products are displayed, and we can perform edit and delete operations on the product and add a new product.

To implement this, I’ve used fake store API to get the products. Moreover, you can also look around the code base here.

Components:

So let’s begin with app.component.ts

//app.component.ts

import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {

constructor() {}

}

For the app-component I’ve implemented nothing, but in the HTML file, I’ve rendered the app-products and app-nav-bar components. So now, we will write the test case that checks if both components should be rendered on screen.

For unit testing, the test case should be independent, and it should be able to execute in an isolated manner. So we can’t directly use the app-nav-bar and app-products components. Instead, we will use the concept of Mocking.

Mocking:

A mock is an object that replaces a real object in a test environment. It is used to control the behavior of the component being tested, by simulating the behavior of its dependencies. A mock is a complete replacement for the real object, and its behavior is entirely controlled by the test.

For example, for the app-nav-bar and app-products components, we will create a mock in the app.component.spec.ts file, as shown below:

import { Component } from '@angular/core';

@Component({
selector: 'app-nav-bar',
template: `<div></div>`
})
class MockNavBarComponent { }

@Component({
selector: 'app-products',
template: `<div></div>`
})
class MockProductsComponent { }

After creating mocked components, we will also declare them inside TestBeb.configurationModule() Then, we will write a test case for it and assert that these components should be rendered on screen.

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RouterTestingModule],
declarations: [AppComponent, MockNavBarComponent, MockProductsComponent]
}).compileComponents();
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;

});

it('should have app-navbar', () => {
const navComponent = fixture.debugElement.query(By.directive(MockNavBarComponent));
expect(navComponent).toBeTruthy();
});

it('should have app-products', () => {
const productsComponent = fixture.debugElement.query(By.directive(MockProductsComponent));
expect(productsComponent).toBeTruthy();
});

In the above test case, fixture.debugElement.query function is used to search for an element in the debug element tree. The By.directive function is passed as a parameter to specify that we are looking for an element with the directive MockNavBarComponent and the result of the search is stored in the navComponent constant and toBeTruthy matcher is used to ensure that navComponent exists and is not null or undefined .

Now, let’s dive into the app-products component.

// product.component.ts

@Component({
selector: 'app-products',
templateUrl: './products.component.html',
styleUrls: ['./products.component.scss'],
})
export class ProductsComponent implements OnInit {
productData!: Product[];
showSpinner = false;

constructor(
private productService: ProductsService,
private dialog: MatDialog,
private snackbar: MatSnackBar
) {}

ngOnInit(): void {
this.getProducts();
}

getProducts() {
this.showSpinner = true;
this.productService.getProducts().subscribe({
next: (res) => {
this.productData = res;
this.showSpinner = false;
},
error: (err) => {
this.showSpinner = false;
this.snackbar.open('Something went wrong!...', '', {
duration: 3000
});
}
});
}

openDialog() { /* some code */ }
editProduct() { /* some code */ }
deleteProduct() { /* some code */ }

}

When the above component initializes, it calls the getProducts() method. Then, it displays the spinner and calls the getProducts() method of product service. This method calls the API; if API’s call is successful, then we assign the response to productData and hide the spinner, or else we display the message on the screen that displays ‘Something went wrong!…’ and then hide the spinner.

For this method, productService and snackbar are dependencies as getProducts() method and open() method respectively belong to them.

To write a test case for this, we will use the createSpyObj() method of Jasmine because our unit test case should be executed in isolation and independently. So let’s understand what this method does.

Spy:

It is a function that wraps a real function and allows us to observe its behavior. It is used to verify that a function was called with certain arguments or to monitor its behavior during execution. A spy is a partial replacement for the real function, and its behavior is primarily the same as the real function, with the addition of monitoring and verification.

For example, to create a spy on the getProducts() method, we have to use the following code:

describe('ProductsComponent', () => {
let component: ProductsComponent;
let fixture: ComponentFixture<ProductsComponent>;

let matSnackBar = jasmine.createSpyObj('MatSnackbar', ['open']);
let mockProductService = jasmine.createSpyObj('ProductsService', ['getProducts', 'deleteProduct']);

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ProductsComponent],
imports: [SharedModule],
providers: [
{ provide: MatSnackBar, useValue: matSnackBar},
{ provide: ProductsService, useValue: mockProductService}
]
}).compileComponents();

fixture = TestBed.createComponent(ProductsComponent);
component = fixture.componentInstance;

matSnackBar = TestBed.inject(MatSnackBar);
mockProductService = TestBed.inject(ProductsService);

fixture.detectChanges();
});

In the above code, we

  1. Create spy object mockProductService for getProducts() method.
  2. Use the value of mockProductService in providers.
  3. Inject the ProductService using TestBed.inject(ProductsService) .
  describe('should test get products initially',() => {

it('should get product data initially', () => {
// Arrange
const response = [
{
id: '1',
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
},
{
id: '2',
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
}
];
mockProductService.getProducts.and.returnValue(of(response));

// Act
component.getProducts();

// Assert
expect(mockProductService.getProducts).toHaveBeenCalled();
expect(component.productData).toEqual(response);
expect(component.showSpinner).toBeFalse();
});

it('should get product data initially on failure', () => {
const error = new Error('Error deleting product');
mockProductService.getProducts.and.returnValue((throwError(() => error)));

component.getProducts();

expect(mockProductService.getProducts).toHaveBeenCalled();
expect(component.showSpinner).toBeFalse();
expect(matSnackBar.open).toHaveBeenCalledWith('Something went wrong!...', '', {
duration: 3000
});
});
});

When a unit test case is written, writing a test case for all possible scenarios is always a good practice. So here, we will cover both success and failure scenarios for the getProducts() method.

For success, create a mock response and mimic the behavior of the getProducts() method of product service, and return the response that the actual method will return. So whenever component.getProducts(); is called, it will return this mocked response for this test case. And to assert it, we used the expect() method and assert that mockProductService.getProducts should be called, component.productData should be equal to the response that returns from the mockProductService.getProducts and the value of component.showSpinner should be false.

For failure, create a mock error response and mimic the behavior of the getProducts()method of service and return the error. So whenever component.getProducts(); is called, it will return this error for this test case. And to assert it, we use expect() methods and confirm that mockProductService.getProducts is called, the value of component.showSpinner isfalse, and matSnackBar.open method is called with the required parameters.

HTTP Service:

Let’s take a look at the product.service.ts file in which API is called.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from '../../../src/environments/environment';
import { Product } from '../models/product.model';

@Injectable({
providedIn: 'root',
})
export class ProductsService {
private baseAPI = environment.baseAPI;
constructor(private http: HttpClient) {}

getProducts() {
return this.http.get<Product[]>(`${this.baseAPI}products`);
}

saveProduct() { /* some code */ }

deleteProduct() { /* some code */ }

updateProduct() { /* some code */ }
}

In the above file, I have called various APIs of fakeStoreAPI to perform CRUD operations on the product.

import { TestBed } from '@angular/core/testing';

import { ProductsService } from './products.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('ProductsService', () => {
let service: ProductsService;
let httpController: HttpTestingController;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
service = TestBed.inject(ProductsService);
httpController = TestBed.inject(HttpTestingController);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

it('should test getProducts', () => {
const response = [
{
id: '1',
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category',
},
{
id: '2',
title: 'Test Product2',
description: 'Test description',
price: '19.99',
category: 'Test category',
}
];

service.getProducts().subscribe((res) => {
expect(res).toEqual(response);
});

const req = httpController.expectOne({
method: 'GET',
url: `https://fakestoreapi.com/products`,
});

req.flush(response);
});

});

To call the API in Angular, we inject the HttpClient in the service file and import HttpClientModule in the module file. Similarly, we have usedHttpClientTestingModule and HttpTestingController here.

For the test case, create a mock response that API will give us when called.

service.getProducts().subscribe((res) => {
expect(res).toEqual(response);
});

This line calls getProducts and subscribes to the returned observable. The subscribe method takes a callback function as its argument. The callback function will be called when the observable emits a value. In this case, the callback function asserts that the value emitted by the observable is equal to the expected results.

const req = httpController.expectOne({
method: 'GET',
url:https://fakestoreapi.com/products
});

This line creates a request object for an HTTP GET request to the URL https://fakestoreapi.com/products. The expectOne method returns a request object that can be used to assert that an HTTP request is made and that the response to the request matches the expected results.

req.flush(response);

This line flushes the response to the request object. This causes the request to be made and the response to be returned.

Add Product:

Now, let’s take a look at the add-product.component.ts file.

import { Component, Inject, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Product } from '../models/product.model';
import { ProductsService } from '../services/products.service';

@Component({
selector: 'app-add-product',
templateUrl: './add-product.component.html',
styleUrls: ['./add-product.component.scss'],
})
export class AddProductComponent implements OnInit {
productForm!: FormGroup;

constructor(
private productService: ProductsService,
private snackbar: MatSnackBar,
@Inject(MAT_DIALOG_DATA) private _data: Product,
private dialogRef: MatDialogRef<AddProductComponent>
) { }

public get data(): Product {
return this._data;
}

public set data(d: Product) {
this._data = d;
}

ngOnInit(): void {
const hasData = Object.keys(this.data).length;
this.productForm = new FormGroup({
title: new FormControl(hasData ? this.data.title : ''),
description: new FormControl(hasData ? this.data.description : ''),
price: new FormControl(hasData ? this.data.price : ''),
category: new FormControl(hasData ? this.data.category : ''),
});
}
}

Here, the ngOnInit() method is used to initialize the product form. To write a test case for this, we can write a code as shown below:

it('should init the form', () => {
expect(component.productForm).toBeDefined();
expect(component.productForm.value).toEqual({
title: '',
description: '',
price: '',
category: ''
})
});

This test case asserts that component.productForm should be defined and component.productForm.value should be empty when it loads initially.

To save the products, we can write test cases as below.

  describe('should test add product functionality', () => {
it('should call the saveProduct to add new product', () => {

// Arrange
const data: Product = {
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
};
const response: Product = {
id: '1',
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
};
component.productForm.setValue(data);
mockProductService.saveProduct.and.returnValue(of(response));

// Act
component.saveProduct();

// Assert
expect(mockProductService.saveProduct).toHaveBeenCalledWith(data);
expect(matSnackBar.open).toHaveBeenCalledWith('Added Successfully!...', '', {
duration: 3000
});
expect(dialogRef.close).toHaveBeenCalled();
});
})

To write a test case for adding the product, we started by mocking the payload data and prouctService.saveProduct() and stored them in the response object. Then, we assigned the data object to form value, as the component.saveProduct() method retrieves the form value to process it.

We then created a spy on the prouctService.saveProduct() method and configured it to return the observable of mocked response inside it.

After that, we called the component.saveProduct() method and asserted that prouctService.saveProduct() method should be called with the payload data, the matSnackBar.open method should be called with the required parameters, and the dialogRef.close method should be called.

We can also write the test case for failure scenarios, as shown below.

it('should test the saveProduct for failure while add a new product', () => {
const data: Product = {
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
};
const error = new Error('Error while add a new product');
mockProductService.saveProduct.and.returnValue((throwError(() => error)));
component.productForm.setValue(data);
component.saveProduct();
expect(mockProductService.saveProduct).toHaveBeenCalledWith(data);
expect(matSnackBar.open).toHaveBeenCalledWith('Something went wrong!...', '', {
duration: 3000
});
});

Similarly, we can also write test cases for editing a product test case for both success and failure.

describe('should test edit product functionality', () => {
it('should set the form controls to the correct values when data is provided', () => {
const data: Product = {
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
};
component.data = data;
component.ngOnInit();
expect(component.productForm.value).toEqual(data);
});

it('should call the saveProduct while editing the product', () => {
const data: Product = {
id: '1',
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
};
const response: Product = {
id: '1',
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
};

component.data = data;
mockProductService.updateProduct.and.returnValue(of(response));
component.productForm.patchValue(data);

component.saveProduct();

expect(mockProductService.updateProduct).toHaveBeenCalledWith(data);
expect(matSnackBar.open).toHaveBeenCalledWith('Updated Successfully!...', '', {
duration: 3000
});
expect(dialogRef.close).toHaveBeenCalled();
});

it('should test the saveProduct for failure while update a product', () => {
const data: Product = {
id: '1',
title: 'Test Product',
description: 'Test description',
price: '19.99',
category: 'Test category'
};
const error = new Error('Error while update a product');
component.data = data;

mockProductService.updateProduct.and.returnValue((throwError(() => error)));
component.productForm.patchValue(data);
component.saveProduct();
expect(mockProductService.updateProduct).toHaveBeenCalledWith(data);
expect(matSnackBar.open).toHaveBeenCalledWith('Something went wrong!...', '', {
duration: 3000
});
});
});

Code Coverage 📊

It is a measure of the amount of code that is covered in unit test cases.

  1. Line coverage: measures the percentage of lines of code executed by a test suite.
  2. Branch coverage: measures the percentage of branches in the code executed by a test suite. A branch is a conditional statement that can take two or more paths, and branch coverage ensures that each path is executed at least once.
  3. Function coverage: measures the percentage of functions executed by a test suite. This can help identify unused or untested functions that need to be removed or tested.

To check the code coverage of our project, we will hit the below command.

ng test no-watch code-coverage

When we hit the above command, Karma runs the tests using the testing framework specified in the karma.conf.js file (which is usually Jasmine). As the tests run, Karma reports the results back to the terminal and the browser window, and generates a code coverage report for us.

Also, it will create the coverage folder in the root of your project directory. The folder contains the index.html file inside it. To see the code coverage, open that file in the browser. It will look something like this:

Here, we’ve tried to achieve 100% code coverage, but in real scenarios, sometimes it may be difficult to achieve 100% coverage. Therefore, a good practice is to aim for 70–80% code coverage in any project.

Best Practices✨

Follow these best practices to write effective test cases:

  1. Write Independent: Unit tests should be independent of each other and of any external factors, such as the order in which they are run or the state of the system. This helps to prevent unexpected test failures and makes it easier to debug failing tests. ​
  2. Write Deterministic test: Unit tests should always produce the same results, given the same inputs. This makes it easier to understand why a test is failing and helps to prevent flaky tests.
  3. Write readable tests: Unit tests should be written in a way that makes them easy to understand, maintain and debug. This includes using descriptive test names, clear assertions, and well-organized code. ​
  4. Avoid business logic in the test: Unit tests should focus on testing the code under test and not the underlying business logic. This helps to ensure that the tests are focused and relevant and that changes to the business logic do not affect the tests. ​
  5. Any change to the code must pass the test: Unit tests should be run as part of the development process, and any changes to the code should not break existing tests. This helps to ensure that changes do not introduce new bugs.
  6. At least 70–80% code coverage: Code coverage is a measure of the amount of code that is executed by the tests. A coverage goal of 70–80% is a good starting point, as it ​ensures that a significant portion of the code is covered by tests. However, this should not be used as the only measure of code quality, as it is possible to have high coverage ​with low-quality tests.

Wrapping Up

Unit testing is an essential practice for ensuring the reliability and stability of Angular applications. By harnessing the power of Jasmine and Karma, you can write effective test cases, structure assertions using matchers, configure the testing environment with TestBed, and leverage mocking to achieve independent and isolated execution.

With these tools and best practices in hand, you can confidently validate your code’s functionality, enhance its testing workflow, and build robust Angular applications that meet the highest quality standards. So, dive into the world of unit testing with Jasmine and Karma, and unleash the full potential of your Angular projects. Happy testing!

Stay ahead of the curve — Follow Simform Engineering blogs for the latest trends and developments updates.

--

--

Khushbu Choksi
Simform Engineering

Tech 💻 lover on a mission to push boundaries 🚀 and create impact 💥 through coding. Always eager to learn and grow 🚀👨‍💻💻