cyberangles guide

Angular Testing: Unit Testing with Jasmine and Karma

Testing is a critical pillar of building robust, maintainable Angular applications. As applications grow in complexity, manual testing becomes error-prone and time-consuming. **Unit testing**—the practice of testing individual components, services, pipes, and directives in isolation—ensures that each part of your codebase works as expected. In the Angular ecosystem, two tools dominate unit testing: **Jasmine** (a behavior-driven testing framework) and **Karma** (a test runner). This blog will guide you through the fundamentals of unit testing in Angular using Jasmine and Karma. You’ll learn how to set up your testing environment, write meaningful tests for components, services, pipes, and directives, handle asynchronous operations, and follow best practices to keep your tests scalable and reliable.

Table of Contents

  1. What is Unit Testing in Angular?
  2. Jasmine vs. Karma: Understanding the Tools
  3. Setting Up Your Angular Testing Environment
  4. Jasmine Basics: Syntax and Core Concepts
  5. Writing Your First Unit Test: A Component Example
  6. Testing Angular Entities in Depth
  7. Advanced Testing: Spies, Async Operations, and More
  8. Running Tests and Generating Coverage Reports
  9. Best Practices for Angular Unit Testing
  10. References

1. What is Unit Testing in Angular?

Unit testing is a software testing technique where individual “units” of code—such as components, services, pipes, or directives—are tested in isolation. The goal is to validate that each unit works as intended, independent of other parts of the application.

In Angular, unit tests ensure:

  • Components render correctly and respond to user interactions.
  • Services handle data logic and dependencies properly.
  • Pipes transform data as expected.
  • Directives modify the DOM or component behavior correctly.

By catching bugs early in the development cycle, unit tests reduce technical debt and make refactoring safer.

2. Jasmine vs. Karma: Understanding the Tools

Angular’s testing ecosystem relies on two primary tools:

Jasmine: The Testing Framework

Jasmine is a behavior-driven development (BDD) framework for writing test cases. It provides:

  • A clean, readable syntax (e.g., describe(), it(), expect()).
  • Matchers to assert conditions (e.g., toBe(), toEqual(), toHaveBeenCalled()).
  • Utilities for mocking (spies) and handling async code.

Karma: The Test Runner

Karma is a test runner that executes Jasmine tests in real browsers (or headless browsers like Chrome Headless). It:

  • Launches browsers and runs tests automatically.
  • Watches for code changes and re-runs tests (via --watch).
  • Generates test coverage reports.
  • Integrates seamlessly with Angular CLI.

Together, Jasmine defines what to test, and Karma handles how to run the tests.

3. Setting Up Your Angular Testing Environment

Angular CLI (Command Line Interface) automatically configures Jasmine and Karma when you create a new project. To verify:

  1. Create a new Angular app (if you don’t have one):

    ng new angular-testing-demo  
    cd angular-testing-demo  
  2. Check for testing dependencies in package.json:

    • jasmine-core, @types/jasmine: Jasmine and TypeScript types.
    • karma, karma-chrome-launcher, karma-jasmine: Karma and plugins.
    • @angular-devkit/build-angular: Includes test utilities.
  3. Test configuration files:

    • karma.conf.js: Configures Karma (browsers, reporters, etc.).
    • angular.json: Defines test settings (e.g., test architect target).

No manual setup is required—Angular CLI handles it all!

4. Jasmine Basics: Syntax and Core Concepts

Jasmine tests are organized into suites and specs:

Suites (describe())

A suite groups related tests. Use describe() with a string description and a callback:

describe('CounterComponent', () => {  
  // Tests go here  
});  

Specs (it())

A spec is an individual test. Use it() with a description and a test function:

it('should initialize count to 0', () => {  
  // Assertions here  
});  

Hooks (beforeEach(), afterEach())

Hooks run code before/after specs to avoid repetition. beforeEach() is commonly used to set up test fixtures:

beforeEach(() => {  
  // Initialize test data or dependencies  
});  

Assertions (expect())

Use expect() with matchers to validate outcomes. Common matchers:

  • toBe(value): Strict equality (===).
  • toEqual(value): Deep equality (for objects/arrays).
  • toBeTrue()/toBeFalse(): Check boolean values.
  • toHaveBeenCalled(): Verify a spy was called.

Example:

const count = 5;  
expect(count).toBe(5);  
expect({ name: 'Alice' }).toEqual({ name: 'Alice' });  

5. Writing Your First Unit Test: A Component Example

Let’s create a simple CounterComponent and test it.

Step 1: Generate the Component

ng generate component counter  

Step 2: Implement the Component

src/app/counter/counter.component.ts:

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

@Component({  
  selector: 'app-counter',  
  template: `  
    <p>Count: {{ count }}</p>  
    <button (click)="increment()">Increment</button>  
    <button (click)="decrement()">Decrement</button>  
  `,  
})  
export class CounterComponent {  
  count = 0;  

  increment(): void {  
    this.count++;  
  }  

  decrement(): void {  
    this.count--;  
  }  
}  

Step 3: Write the Unit Test

Angular CLI generates a spec file (counter.component.spec.ts) by default. Update it:

import { ComponentFixture, TestBed } from '@angular/core/testing';  
import { CounterComponent } from './counter.component';  

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

  // Setup: Configure testing module and create component  
  beforeEach(async () => {  
    await TestBed.configureTestingModule({  
      declarations: [CounterComponent], // Declare the component under test  
    }).compileComponents();  

    fixture = TestBed.createComponent(CounterComponent);  
    component = fixture.componentInstance; // Get component instance  
    fixture.detectChanges(); // Trigger initial data binding  
  });  

  // Test 1: Component should create  
  it('should create', () => {  
    expect(component).toBeTruthy();  
  });  

  // Test 2: Initial count should be 0  
  it('should initialize count to 0', () => {  
    expect(component.count).toBe(0);  
  });  

  // Test 3: increment() should increase count by 1  
  it('should increment count when increment() is called', () => {  
    component.increment();  
    expect(component.count).toBe(1);  
  });  

  // Test 4: decrement() should decrease count by 1  
  it('should decrement count when decrement() is called', () => {  
    component.count = 5; // Set initial value  
    component.decrement();  
    expect(component.count).toBe(4);  
  });  

  // Test 5: Template should display current count  
  it('should display count in template', () => {  
    const compiled = fixture.nativeElement as HTMLElement;  
    expect(compiled.querySelector('p')?.textContent).toContain('Count: 0');  

    // Update count and re-render  
    component.increment();  
    fixture.detectChanges(); // Refresh template  
    expect(compiled.querySelector('p')?.textContent).toContain('Count: 1');  
  });  
});  

Explanation

  • TestBed: Angular’s testing utility to configure a module for testing.
  • ComponentFixture: Wraps the component and provides access to the DOM.
  • fixture.detectChanges(): Triggers change detection to update the template.

6. Testing Angular Entities in Depth

6.1 Testing Components

Components often have:

  • Inputs (@Input()) and Outputs (@Output()).
  • Template logic (e.g., *ngIf, *ngFor).
  • User interactions (clicks, form submissions).

Example: Testing Input/Output

Suppose CounterComponent has an @Input() initialCount and @Output() countChanged:

// counter.component.ts (updated)  
@Input() initialCount = 0;  
@Output() countChanged = new EventEmitter<number>();  

ngOnInit(): void {  
  this.count = this.initialCount;  
}  

increment(): void {  
  this.count++;  
  this.countChanged.emit(this.count);  
}  

Test Input/Output:

// In counter.component.spec.ts  
it('should initialize count from initialCount input', () => {  
  component.initialCount = 10;  
  component.ngOnInit(); // Manually trigger ngOnInit  
  expect(component.count).toBe(10);  
});  

it('should emit countChanged when incremented', () => {  
  spyOn(component.countChanged, 'emit'); // Spy on the output  
  component.increment();  
  expect(component.countChanged.emit).toHaveBeenCalledWith(1);  
});  

6.2 Testing Services

Services often handle business logic, API calls, or state management. Test them by mocking dependencies (e.g., HttpClient).

Example: Testing a Data Service with HttpClient

Create a UserService that fetches user data:

// user.service.ts  
import { Injectable } from '@angular/core';  
import { HttpClient } from '@angular/common/http';  
import { Observable } from 'rxjs';  

@Injectable({ providedIn: 'root' })  
export class UserService {  
  constructor(private http: HttpClient) {}  

  getUsers(): Observable<any[]> {  
    return this.http.get<any[]>('https://api.example.com/users');  
  }  
}  

Test with HttpTestingController:

import { TestBed } from '@angular/core/testing';  
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';  
import { UserService } from './user.service';  

describe('UserService', () => {  
  let service: UserService;  
  let httpMock: HttpTestingController;  

  beforeEach(() => {  
    TestBed.configureTestingModule({  
      imports: [HttpClientTestingModule], // Mock HttpClient  
      providers: [UserService],  
    });  
    service = TestBed.inject(UserService);  
    httpMock = TestBed.inject(HttpTestingController);  
  });  

  afterEach(() => {  
    httpMock.verify(); // Ensure no outstanding requests  
  });  

  it('should fetch users via GET', () => {  
    const mockUsers = [{ id: 1, name: 'Alice' }];  

    service.getUsers().subscribe((users) => {  
      expect(users).toEqual(mockUsers);  
    });  

    // Expect a GET request to the URL  
    const req = httpMock.expectOne('https://api.example.com/users');  
    expect(req.request.method).toBe('GET');  

    // Respond with mock data  
    req.flush(mockUsers);  
  });  
});  

Key Points:

  • HttpClientTestingModule mocks HttpClient.
  • HttpTestingController verifies HTTP requests and returns mock responses.

6.3 Testing Pipes

Pipes transform data (e.g., DatePipe, UpperCasePipe). Test their transform() method.

Example: Testing a ReversePipe

// reverse.pipe.ts  
import { Pipe, PipeTransform } from '@angular/core';  

@Pipe({ name: 'reverse' })  
export class ReversePipe implements PipeTransform {  
  transform(value: string): string {  
    return value.split('').reverse().join('');  
  }  
}  

Test the Pipe:

import { ReversePipe } from './reverse.pipe';  

describe('ReversePipe', () => {  
  const pipe = new ReversePipe();  

  it('should reverse a string', () => {  
    expect(pipe.transform('hello')).toBe('olleh');  
    expect(pipe.transform('')).toBe(''); // Edge case: empty string  
  });  
});  

6.4 Testing Directives

Directives modify DOM behavior (e.g., NgClass, custom HighlightDirective). Test DOM changes.

Example: Testing a HighlightDirective

// highlight.directive.ts  
import { Directive, ElementRef, HostListener } from '@angular/core';  

@Directive({ selector: '[appHighlight]' })  
export class HighlightDirective {  
  constructor(private el: ElementRef) {}  

  @HostListener('mouseenter') onMouseEnter() {  
    this.highlight('yellow');  
  }  

  @HostListener('mouseleave') onMouseLeave() {  
    this.highlight('');  
  }  

  private highlight(color: string): void {  
    this.el.nativeElement.style.backgroundColor = color;  
  }  
}  

Test the Directive:

import { ComponentFixture, TestBed } from '@angular/core/testing';  
import { HighlightDirective } from './highlight.directive';  
import { Component } from '@angular/core';  

// Create a test component to host the directive  
@Component({  
  template: `<div appHighlight></div>`,  
})  
class TestComponent {}  

describe('HighlightDirective', () => {  
  let fixture: ComponentFixture<TestComponent>;  
  let div: HTMLElement;  

  beforeEach(async () => {  
    await TestBed.configureTestingModule({  
      declarations: [TestComponent, HighlightDirective],  
    }).compileComponents();  

    fixture = TestBed.createComponent(TestComponent);  
    div = fixture.nativeElement.querySelector('div')!;  
  });  

  it('should highlight on mouseenter', () => {  
    div.dispatchEvent(new Event('mouseenter'));  
    expect(div.style.backgroundColor).toBe('yellow');  
  });  

  it('should remove highlight on mouseleave', () => {  
    div.dispatchEvent(new Event('mouseenter'));  
    div.dispatchEvent(new Event('mouseleave'));  
    expect(div.style.backgroundColor).toBe('');  
  });  
});  

7. Advanced Testing: Spies, Async Operations, and More

7.1 Using Jasmine Spies to Mock Dependencies

Spies let you mock functions and track calls. Use spyOn() to replace a method with a mock.

Example: Mocking a Service Dependency
Suppose CounterComponent uses a LogService to log actions:

// log.service.ts  
@Injectable()  
export class LogService {  
  log(message: string): void {  
    console.log(message); // Real implementation  
  }  
}  

// counter.component.ts (updated)  
constructor(private logService: LogService) {}  

increment(): void {  
  this.count++;  
  this.logService.log(`Count incremented to ${this.count}`);  
}  

Test with a Spy:

// In counter.component.spec.ts  
beforeEach(async () => {  
  await TestBed.configureTestingModule({  
    declarations: [CounterComponent],  
    providers: [LogService], // Provide the real service (or a mock)  
  }).compileComponents();  
});  

it('should log increment action', () => {  
  const logService = TestBed.inject(LogService);  
  const spy = spyOn(logService, 'log'); // Spy on log()  

  component.increment();  
  expect(spy).toHaveBeenCalledWith('Count incremented to 1');  
});  

7.2 Testing Async Code with fakeAsync and tick

Angular often uses async operations (e.g., promises, observables). Use fakeAsync and tick to simulate time.

Example: Testing a Service with a Promise

// data.service.ts  
@Injectable()  
export class DataService {  
  fetchData(): Promise<string> {  
    return new Promise((resolve) => {  
      setTimeout(() => resolve('test data'), 1000); // Simulate API delay  
    });  
  }  
}  

Test with fakeAsync/tick:

import { fakeAsync, tick } from '@angular/core/testing';  
import { DataService } from './data.service';  

describe('DataService', () => {  
  let service: DataService;  

  beforeEach(() => {  
    service = new DataService();  
  });  

  it('should fetch data asynchronously', fakeAsync(() => {  
    let result: string;  
    service.fetchData().then((data) => (result = data));  

    tick(1000); // Simulate passage of 1 second  
    expect(result).toBe('test data');  
  }));  
});  

Alternative: async/await
For promises, you can also use async/await with fixture.whenStable():

it('should fetch data (async/await)', async () => {  
  const data = await service.fetchData();  
  expect(data).toBe('test data');  
});  

8. Running Tests and Generating Coverage Reports

Run Tests

Use the Angular CLI to run tests:

npm test  
# Or  
ng test  

Karma launches a browser (default: Chrome) and runs tests. Use --watch to re-run tests on code changes:

ng test --watch  

Generate Coverage Reports

To see test coverage (which code is tested), run:

ng test --no-watch --code-coverage  

A coverage/ folder is generated with an HTML report. Open coverage/angular-testing-demo/index.html in a browser to view:

  • Percentage of covered lines, branches, functions, and statements.
  • Which lines are untested (highlighted in red).

9. Best Practices for Angular Unit Testing

  1. Keep Tests Isolated: Each test should run independently. Avoid shared state between specs.
  2. Test Behavior, Not Implementation: Focus on what the code does, not how it does it.
  3. Use Descriptive Names: it('should increment count when button is clicked') is better than it('works').
  4. Mock External Dependencies: Use spies or mocks for services, APIs, or browser APIs (e.g., localStorage).
  5. Keep Tests Fast: Avoid slow operations (e.g., real API calls). Use mocks to keep tests snappy.
  6. Test Edge Cases: Empty inputs, error conditions, and boundary values (e.g., null, undefined).

10. References


By mastering unit testing with Jasmine and Karma, you’ll build more reliable, maintainable Angular applications. Start small, test critical paths, and gradually expand your test suite—your future self (and team) will thank you! 🚀