DEV Community

Renuka Patil
Renuka Patil

Posted on

8.Angular Unit Testing: A Step-by-Step Approach

Before jump on to this make sure you have cleared your basics: Jasmin Testing Framework: A Comprehensive Guide

Unit testing is a crucial aspect of Angular development, ensuring the correctness and reliability of components, services, pipes, and other parts of the application. This guide will walk you through how to set up tests for Angular components and services, along with various techniques like mocking, isolated tests, and using TestBed for dependency injection.

Angular uses Jasmin and karma by default for unit testing.

Image description

1. Service Testing

Angular services contain business logic, and testing them ensures that your core logic functions as expected. Here, we'll go through a basic service example and its tests.

Example: CalculatorService

export class CalculatorService {
  add(n1: number, n2: number): number {
    return n1 + n2;
  }

  subtract(n1: number, n2: number): number {
    return n1 - n2;
  }
}
Enter fullscreen mode Exit fullscreen mode
Test Case:
describe('Calculator Service', () => {
  let calculator: CalculatorService;

  beforeEach(() => {
    calculator = new CalculatorService();
  });

  it('should add two numbers', () => {
    let result = calculator.add(2, 3);
    expect(result).toBe(5);
  });

  it('should subtract two numbers', () => {
    let result = calculator.subtract(5, 3);
    expect(result).toBe(2);
  });
});
Enter fullscreen mode Exit fullscreen mode

2. Testing Services with Dependency Injection

Services can depend on other services via dependency injection. Let's explore a scenario where CalculatorService depends on a LoggerService to log actions.

Example: LoggerService

export class LoggerService {
  msg: string[] = [];

  log(message: string): void {
    this.msg.push(message);
  }
}
Enter fullscreen mode Exit fullscreen mode

Updated CalculatorService with LoggerService:

export class CalculatorService {
  constructor(private loggerService: LoggerService) {}

  add(n1: number, n2: number): number {
    let result = n1 + n2;
    this.loggerService.log('Addition performed');
    return result;
  }

  subtract(n1: number, n2: number): number {
    let result = n1 - n2;
    this.loggerService.log('Subtraction performed');
    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Case with Mocking the LoggerService:

describe('Calculator Service with Logger', () => {
  let calculator: CalculatorService;
  let mockLoggerService: jasmine.SpyObj<LoggerService>;

  beforeEach(() => {
    mockLoggerService = jasmine.createSpyObj('LoggerService', ['log']);
    calculator = new CalculatorService(mockLoggerService);
  });

  it('should add two numbers and log the operation', () => {
    let result = calculator.add(2, 3);
    expect(result).toBe(5);
    expect(mockLoggerService.log).toHaveBeenCalledWith('Addition performed');
  });

  it('should subtract two numbers and log the operation', () => {
    let result = calculator.subtract(5, 3);
    expect(result).toBe(2);
    expect(mockLoggerService.log).toHaveBeenCalledWith('Subtraction performed');
  });
});
Enter fullscreen mode Exit fullscreen mode

3. Types of Mocking

Mocking helps isolate tests and avoid calling real services. There are three types of mocking in unit tests:

  1. Stub: Provides hardcoded responses to method calls.
  2. Dummy: Used to fill method parameters without returning meaningful data.
  3. Spy: Records the calls made to a method, useful for verifying interactions.

Using Jasmine’s createSpyObj for Spies:

let mockLoggerService = jasmine.createSpyObj('LoggerService', ['log']);
Enter fullscreen mode Exit fullscreen mode

This spy can track how many times the log method is called and with which arguments.


4. Isolated Test Case

An isolated test case tests a single unit in isolation without its dependencies. For example, testing CalculatorService without involving LoggerService:

describe('CalculatorService (Isolated Test)', () => {
  let calculator: CalculatorService;

  beforeEach(() => {
    calculator = new CalculatorService();
  });

  it('should add numbers correctly', () => {
    expect(calculator.add(2, 3)).toBe(5);
  });

  it('should subtract numbers correctly', () => {
    expect(calculator.subtract(5, 3)).toBe(2);
  });
});
Enter fullscreen mode Exit fullscreen mode

5. Using beforeEach for Setup

To avoid repetitive setup in each test, we can use beforeEach to initialize components or services before each test case.

describe('CalculatorService', () => {
  let calculator: CalculatorService;

  beforeEach(() => {
    calculator = new CalculatorService();
  });

  it('should add two numbers', () => {
    expect(calculator.add(2, 3)).toBe(5);
  });

  it('should subtract two numbers', () => {
    expect(calculator.subtract(5, 3)).toBe(2);
  });
});
Enter fullscreen mode Exit fullscreen mode

6. Testing Pipes

Pipes in Angular transform data. Let's test a custom pipe that converts numbers into strength descriptions.

Example Pipe:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({name: 'strength'})
export class StrengthPipe implements PipeTransform {
  transform(value: number): string {
    if (value < 10) {
      return `${value} (weak)`;
    } else if (value < 20) {
      return `${value} (strong)`;
    } else {
      return `${value} (strongest)`;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Case for Pipe:

describe('StrengthPipe', () => {
  let pipe: StrengthPipe;

  beforeEach(() => {
    pipe = new StrengthPipe();
  });

  it('should transform number to "weak" for values less than 10', () => {
    expect(pipe.transform(5)).toBe('5 (weak)');
  });

  it('should transform number to "strong" for values between 10 and 20', () => {
    expect(pipe.transform(15)).toBe('15 (strong)');
  });

  it('should transform number to "strongest" for values greater than 20', () => {
    expect(pipe.transform(25)).toBe('25 (strongest)');
  });
});
Enter fullscreen mode Exit fullscreen mode

7. Component Testing with Input and Output

When testing components that interact with user input or emit events, we need to handle the @Input and @Output properties.

Component with @Input and @Output:

@Component({
  selector: 'app-post',
  template: `
    <div>
      <p>{{ post.title }}</p>
      <button (click)="onDelete()">Delete</button>
    </div>
  `
})
export class PostComponent {
  @Input() post!: Post;
  @Output() delete = new EventEmitter<Post>();

  onDelete() {
    this.delete.emit(this.post);
  }
}
Enter fullscreen mode Exit fullscreen mode

Test Case for Component's Output:

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [PostComponent],
    });

    fixture = TestBed.createComponent(PostComponent);
    component = fixture.componentInstance;
  });

  it('should raise an event when delete is clicked', () => {
    const post: Post = { id: 1, title: 'Post 1', body: 'Body 1' };
    component.post = post;

    spyOn(component.delete, 'emit');

    component.onDelete();

    expect(component.delete.emit).toHaveBeenCalledWith(post);
  });
});
Enter fullscreen mode Exit fullscreen mode

8. Using TestBed to Test Components

TestBed helps to set up the testing environment by creating the Angular testing module and resolving dependencies.

Component Setup Using TestBed:

describe('PostComponent with TestBed', () => {
  let fixture: ComponentFixture<PostComponent>;
  let component: PostComponent;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [PostComponent],
    });

    fixture = TestBed.createComponent(PostComponent);
    component = fixture.componentInstance;
  });

  it('should create the component', () => {
    expect(component).toBeTruthy();
  });
});
Enter fullscreen mode Exit fullscreen mode

Accessing Template Elements:

To test template bindings or DOM manipulations, we use nativeElement or debugElement.

it('should display the post title', () => {
  const bannerElement: HTMLElement = fixture.nativeElement;
  const p = bannerElement.querySelector('p')!;
  expect(p.textContent).toEqual('Post 1');
});
Enter fullscreen mode Exit fullscreen mode

Summary

  1. Introduction to Jasmine & Karma: Jasmine is a behavior-driven development (BDD) framework for testing JavaScript, while Karma is a test runner that executes tests in real browsers. They work together to simplify testing in Angular applications.

  2. Testing Services: Unit testing services is explained with an emphasis on using Jasmine’s SpyObj to mock service dependencies, ensuring isolation in tests.

  3. Component Testing: Testing components involves simulating input and output interactions and using TestBed to create a testing module.

  4. Mocking & Spying: Mocking methods with spies allows you to track function calls and control method behavior in isolated tests.

Unit testing in Angular can seem overwhelming at first, but with practice, you will learn how to write tests that are clear, isolated, and maintainable. By using techniques like mocking, beforeEach, and TestBed, you can create robust tests that ensure your Angular applications behave as expected.

Top comments (0)