Table of Contents
- What is Unit Testing in Angular?
- Jasmine vs. Karma: Understanding the Tools
- Setting Up Your Angular Testing Environment
- Jasmine Basics: Syntax and Core Concepts
- Writing Your First Unit Test: A Component Example
- Testing Angular Entities in Depth
- 6.1 Testing Components
- 6.2 Testing Services
- 6.3 Testing Pipes
- 6.4 Testing Directives
- Advanced Testing: Spies, Async Operations, and More
- Running Tests and Generating Coverage Reports
- Best Practices for Angular Unit Testing
- 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:
-
Create a new Angular app (if you don’t have one):
ng new angular-testing-demo cd angular-testing-demo -
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.
-
Test configuration files:
karma.conf.js: Configures Karma (browsers, reporters, etc.).angular.json: Defines test settings (e.g.,testarchitect 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:
HttpClientTestingModulemocksHttpClient.HttpTestingControllerverifies 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
- Keep Tests Isolated: Each test should run independently. Avoid shared state between specs.
- Test Behavior, Not Implementation: Focus on what the code does, not how it does it.
- Use Descriptive Names:
it('should increment count when button is clicked')is better thanit('works'). - Mock External Dependencies: Use spies or mocks for services, APIs, or browser APIs (e.g.,
localStorage). - Keep Tests Fast: Avoid slow operations (e.g., real API calls). Use mocks to keep tests snappy.
- Test Edge Cases: Empty inputs, error conditions, and boundary values (e.g.,
null,undefined).
10. References
- Angular Testing Documentation
- Jasmine Official Docs
- Karma Official Docs
- Angular Testing Library (alternative to TestBed for component testing)
- HttpClient Testing Guide
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! 🚀