This comprehensive guide will walk you through the intricacies of Jest Angular Testing, specifically focusing on the latest stable and upcoming features, including those in Angular v18 and beyond. We’ll delve into best practices, common pitfalls, and advanced patterns to empower you to write robust, efficient, and maintainable tests for your Angular applications.
Introduction to Jest and Angular Testing
Testing is a critical part of modern software development, ensuring the reliability, stability, and maintainability of applications. In the Angular ecosystem, Jest has emerged as a preferred choice for unit and integration testing due to its speed and comprehensive features, gradually supplanting the traditional Karma/Jasmine setup.
1.1 Why Jest for Angular Testing?
Jest, originating from Facebook, offers a developer-friendly and performant testing experience. Its key advantages include:
- Speed: Jest leverages
jsdom, a JavaScript implementation of the DOM and parts of the Web API, instead of a real browser. This significantly reduces overhead and speeds up test execution. It also boasts built-in parallelization and a smart watch mode that only re-runs affected tests. - All-in-one Solution: Jest provides a complete testing platform, including an assertion library, mocking functionalities, and code coverage reporting, reducing the need for multiple external libraries.
- Developer Experience: Jest’s intuitive API and excellent error reporting contribute to a smoother and more enjoyable testing workflow.
- Community Adoption: Jest is widely adopted across the JavaScript landscape, including a growing presence in Angular projects, leading to a rich ecosystem of resources and community support.
1.2 Jest vs. Karma/Jasmine: A Modern Perspective
Historically, Angular applications used Karma as a test runner and Jasmine as a testing framework. While still supported, this combination often faced challenges with speed and complexity.
| Feature | Karma/Jasmine | Jest |
|---|---|---|
| Test Runner | Karma (browser-based) | Jest (Node.js with jsdom) |
| Speed | Slower due to real browser overhead | Faster due to jsdom and parallelization |
| Setup | Can be more complex to configure | Generally simpler, especially with jest-preset-angular |
| Features | Requires additional libraries for mocking/spying | Built-in assertion, mocking, spying, and code coverage |
| ESM Support | Relies on Angular’s build process | Improved ESM support with jest-preset-angular and Jest’s evolution |
| Watch Mode | Less efficient | Highly efficient, re-runs only affected tests |
Angular itself has recognized the shift, with recent versions (Angular v16+) deprecating Karma and introducing experimental Jest support directly within the Angular CLI. This indicates a strong future for Jest in Angular.
1.3 Setting up Jest in an Angular Project (Angular v18+)
Setting up Jest in an Angular project, especially with Angular v18, involves a few straightforward steps. We’ll use jest-preset-angular for a streamlined configuration.
1.3.1 Installing Dependencies
First, install the necessary packages as development dependencies:
npm install --save-dev jest @types/jest @jest/globals jest-preset-angular
jest: The core Jest testing framework.@types/jest: TypeScript type definitions for Jest.@jest/globals: Provides Jest global functions (likedescribe,it,expect).jest-preset-angular: A preset that simplifies Jest configuration for Angular projects, handling TypeScript compilation and Angular-specific setups.
1.3.2 Configuring tsconfig.spec.json
Update your tsconfig.spec.json to include Jest types. This file is typically located in your src/ directory.
// tsconfig.spec.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"module": "CommonJs",
"types": ["jest"] // Add 'jest' to the types array
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}
1.3.3 Creating setup-jest.ts
Create a setup-jest.ts file (e.g., in your src/ directory) and add the following import:
// src/setup-jest.ts
import 'jest-preset-angular/setup-jest';
This file is used to configure Jest with Angular-specific settings before your tests run.
1.3.4 Configuring jest.config.js
Create a jest.config.js file in your project’s root directory:
// jest.config.js
module.exports = {
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/src/setup-jest.ts'],
globalSetup: 'jest-preset-angular/global-setup',
testMatch: ['**/+(*.)+(spec).+(ts)'],
testEnvironment: 'jsdom',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\\\\\\\.(html)$',
useESM: true, // Enable ESM support for ts-jest
},
},
transform: {
'^.+\\\\\\\\.(ts|js|mjs|html)$': 'jest-preset-angular',
},
moduleFileExtensions: ['ts', 'js', 'html', 'json', 'mjs'],
moduleNameMapper: {
// You might need this for specific libraries that ship incorrect ESM
// tslib: 'tslib/tslib.es6.js',
},
// maxWorkers: '8', // Uncomment and adjust based on your system's cores for performance
};
Explanation of Key Properties:
preset: 'jest-preset-angular': Activates the Angular preset for Jest.setupFilesAfterEnv: Specifies files that run once before each test environment is set up.globalSetup: Handles global setup for the test environment.testMatch: Defines the pattern for test files (typically.spec.ts).testEnvironment: 'jsdom': Specifies the environment Jest runs in.jsdomemulates a browser DOM.globals: Configuration forts-jest, including thetsconfigfor compilation anduseESM: truefor improved ESM handling.transform: Tells Jest how to transform your files (e.g., TypeScript to JavaScript).moduleFileExtensions: Defines file extensions Jest should look for.moduleNameMapper: (Optional but useful for ESM issues) Maps module names to paths, helping resolve issues with certain libraries that might not export ESM correctly.
1.3.5 Updating package.json Scripts
Modify your package.json to include Jest test scripts:
// package.json
{
"name": "my-angular-app",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "jest", // Run all tests
"test:coverage": "jest --coverage" // Run tests with coverage report
},
"private": true,
"dependencies": {
// ...
},
"devDependencies": {
"@angular/cli": "^18.0.0",
"@angular/compiler-cli": "^18.0.0",
"@types/jest": "^29.x.x",
"@jest/globals": "^29.x.x",
"jest": "^29.x.x",
"jest-preset-angular": "^14.x.x",
"typescript": "~5.x.x"
}
}
If you prefer to run Jest directly through Node.js with experimental ESM modules, you can update your test script like this (though jest-preset-angular often handles this under the hood with newer versions):
"test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js --config jest.config.js"
1.3.6 Removing Karma-related Files
If you’re migrating from Karma, you should uninstall Karma and Jasmine-related packages and remove their configuration files:
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
rm karma.conf.js src/test.ts
Also, remove the testRunner configuration from your angular.json file if it still points to Karma.
Core Concepts of Angular Component Testing with Jest
Effective testing in Angular with Jest hinges on understanding how Angular components work and how to isolate and test their behavior efficiently.
2.1 Understanding Angular Components for Testing
An Angular component is a fundamental building block, encapsulating a specific part of the UI and its associated logic. Each component consists of:
- HTML Template: Defines the view structure, including Angular-specific syntax for data binding, directives, and event handling.
- TypeScript Class: Contains the component’s business logic (properties, methods, lifecycle hooks) and interacts with services.
- Metadata: Provided by the
@Componentdecorator, it gives Angular information about the component (selector, template, styles).
Angular Component Lifecycle: Components go through a series of lifecycle hooks, allowing developers to execute custom logic at specific stages:
ngOnChanges: Responds to changes in input properties.ngOnInit: Initializes the component after Angular sets input properties.ngDoCheck: Detects and responds to changes not caught byngOnChanges.ngAfterContentInit: Called after projected content has been initialized.ngAfterContentChecked: Called after every check of projected content.ngAfterViewInit: Called after a component’s view and child views are initialized.ngAfterViewChecked: Called after every check of a component’s view and child views.ngOnDestroy: Cleans up when the component is destroyed.
When testing, we often interact with these lifecycle hooks to verify behavior at different stages.
2.2 The Importance of Unit Testing
Unit tests focus on isolated, small pieces of code (units) to ensure they function as intended in isolation. For Angular components, this means testing their internal logic without external dependencies influencing the outcome.
Benefits of Unit Testing:
- Early Bug Detection: Catch issues at a granular level before they become larger, harder-to-debug problems.
- Improved Code Quality: Writing testable code often leads to better design, modularity, and maintainability.
- Regression Prevention: Ensures that new changes don’t break existing functionality.
- Documentation: Tests serve as living documentation, demonstrating how a unit of code is supposed to behave.
- Faster Feedback Loop: Quick execution times provide rapid feedback during development.
2.3 Mocking Dependencies: Why and How
In real-world Angular applications, components and services often have dependencies (e.g., other services, HTTP clients, third-party libraries). To truly unit test a piece of code, these external dependencies must be isolated or “mocked.” Mocking involves creating a simplified, controlled version of a dependency that mimics its behavior without executing its actual implementation.
2.3.1 Advantages of Mocking
- Isolation: Ensures that only the code under test is being evaluated, preventing failures from cascading from dependencies.
- Speed: Avoids slow operations like real API calls, database interactions, or complex computations.
- Control over Scenarios: Allows simulating various scenarios (success, error, edge cases) that might be difficult to reproduce with real dependencies.
- Consistent Results: Mocks behave predictably, leading to stable and consistent test results.
- Parallel Execution: Isolated tests can often run in parallel, further speeding up the test suite.
2.3.2 Disadvantages of Not Mocking
- Expanded Test Scope: Testing the real implementation forces you to declare and provide all nested components and their dependencies, leading to larger, more complex tests.
- Slow Execution: Real dependency trees must be resolved, significantly slowing down test runs.
- Unstable Tests (Flakiness): External factors (network issues, external service changes) can cause tests to fail intermittently.
- Difficult Debugging: Pinpointing the source of a failure becomes harder when errors can originate from any point in a deep dependency tree.
- Refactoring Challenges: Changes in a downstream dependency can break seemingly unrelated tests.
2.3.3 Common Mocking Techniques in Jest for Angular
Jest provides powerful utilities for mocking. For Angular, you’ll commonly mock services provided via dependency injection.
Jest’s
jest.fn()andjest.spyOn():jest.fn(): Creates a mock function that can be used to track calls, arguments, and return values.jest.spyOn(): Creates a spy on an existing object’s method, allowing you to track calls without changing the original implementation. You can also mock its return value or implementation.
// Example: Mocking a service using jest.fn() and jest.spyOn() class DataService { fetchData(): string { return 'Real Data'; } saveData(data: string): boolean { return true; } } describe('Component with DataService', () => { let component: MyComponent; let mockDataService: DataService; beforeEach(() => { // Mock the entire service class mockDataService = { fetchData: jest.fn(() => 'Mocked Data'), saveData: jest.fn((data: string) => true) }; // Or spy on an existing instance (if DataService was provided normally) // spyOn(mockDataService, 'fetchData').and.returnValue('Spied Data'); TestBed.configureTestingModule({ declarations: [MyComponent], providers: [{ provide: DataService, useValue: mockDataService }] }).compileComponents(); fixture = TestBed.createComponent(MyComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should use mocked data service', () => { component.loadData(); expect(mockDataService.fetchData).toHaveBeenCalled(); expect(component.data).toBe('Mocked Data'); }); });createSpyFromClass(fromjest-auto-spies): This external library can automatically create spies for all methods and properties of a class, simplifying the mocking process, especially for larger interfaces.// npm install --save-dev jest-auto-spies import { createSpyFromClass } from 'jest-auto-spies'; class DataService { fetchData(): Observable<string> { return of('Real Data'); } } describe('Component with DataService using jest-auto-spies', () => { let component: MyComponent; let mockDataService: jest.Mocked<DataService>; // Use Jest's Mocked utility type beforeEach(async () => { mockDataService = createSpyFromClass(DataService); mockDataService.fetchData.and.returnValue(of('Mocked Data from auto-spy')); await TestBed.configureTestingModule({ declarations: [MyComponent], providers: [{ provide: DataService, useValue: mockDataService }] }).compileComponents(); fixture = TestBed.createComponent(MyComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should use mocked data service from auto-spy', () => { component.loadData(); expect(mockDataService.fetchData).toHaveBeenCalled(); expect(component.data).toBe('Mocked Data from auto-spy'); }); });
2.4 TestBed and Component Fixtures
TestBed is Angular’s primary utility for configuring and initializing a testing module for components and services. It creates a testing environment that mirrors an actual NgModule.
TestBed.configureTestingModule(): Configures a testing module, similar toAppModule, where you declare components, import modules, and provide services for your tests.TestBed.configureTestingModule({ declarations: [MyComponent, ChildComponent], // Declare components, pipes, directives used in the template imports: [CommonModule, HttpClientTestingModule], // Import necessary Angular modules providers: [{ provide: MyService, useClass: MockMyService }], // Provide services (often mocked) schemas: [NO_ERRORS_SCHEMA] // Optional: Ignore unknown elements/attributes in templates });TestBed.createComponent<T>(Component): Creates an instance of the specified component and returns aComponentFixture<T>.ComponentFixture: A test harness for interacting with the created component.fixture.componentInstance: Access the instance of the component class.fixture.nativeElement: Access the root DOM element of the component.fixture.debugElement: Provides a debug-friendly wrapper aroundnativeElementfor querying and interacting with elements.fixture.detectChanges(): Triggers Angular’s change detection, updating the component’s view. This is crucial after changing component properties or mocking data.fixture.autoDetectChanges(true): Tells Angular to automatically run change detection. Use with caution as it can hide issues and slow down tests.fixture.destroy(): Cleans up the component and its associated resources after a test. Essential to prevent memory leaks in a test suite.
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MyComponent } from './my.component'; describe('MyComponent', () => { let component: MyComponent; let fixture: ComponentFixture<MyComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [MyComponent] }).compileComponents(); // Compile components before creating fixture fixture = TestBed.createComponent(MyComponent); component = fixture.componentInstance; fixture.detectChanges(); // Initial change detection }); it('should create the component', () => { expect(component).toBeTruthy(); }); it('should display initial title', () => { const compiled = fixture.nativeElement as HTMLElement; expect(compiled.querySelector('h1')?.textContent).toContain('Welcome'); }); });
2.5 Angular Testing Library (ATL) for User-Centric Tests
The Angular Testing Library (@testing-library/angular) promotes a user-centric testing philosophy. Instead of testing implementation details (like component class methods directly), it encourages testing how a user would interact with your component via the DOM. This leads to more robust tests that are less prone to breaking when internal implementation changes.
2.5.1 Setting up ATL with render
ATL replaces TestBed.createComponent with its render function.
// npm install --save-dev @testing-library/angular
import { render, screen, fireEvent } from '@testing-library/angular';
import { MyComponent } from './my.component';
import { MyService } from './my.service';
import { of } from 'rxjs';
describe('MyComponent with Angular Testing Library', () => {
it('should display a welcome message', async () => {
// Mock the service
const mockMyService = {
getData: jest.fn().mockReturnValue(of('Test User'))
};
await render(MyComponent, {
// Configuration for TestBed
imports: [],
providers: [
{ provide: MyService, useValue: mockMyService }
],
// Input properties for the component
componentProperties: {
title: 'Welcome to ATL'
}
});
expect(screen.getByText(/welcome to atl/i)).toBeInTheDocument();
// Simulate a click event
fireEvent.click(screen.getByRole('button', { name: /load user/i }));
expect(await screen.findByText(/test user/i)).toBeInTheDocument();
});
});
2.5.2 Working with declarations, providers, componentProperties
render accepts an options object that maps directly to TestBed configurations and component properties:
declarations: For declaring components, directives, and pipes used within the component’s template.imports: For importingNgModulesthat the component depends on (e.g.,FormsModule,HttpClientTestingModule).providers: For providing services, typically mocked versions.componentProperties: Sets the@Input()and@Output()properties on the component. This is the preferred way to set inputs when using ATL, rather than directly oncomponentInstance.excludeComponentDeclaration: true: Useful if you’re importing a module that already declares the component, preventing re-declaration errors.
import { render, screen, fireEvent } from '@testing-library/angular';
import { Component, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'app-child',
template: `
<button (click)="emitMessage()">Emit</button>
`
})
class ChildComponent {
@Input() value: string = '';
@Output() messageEmitted = new EventEmitter<string>();
emitMessage() {
this.messageEmitted.emit('Hello from child!');
}
}
@Component({
selector: 'app-parent',
template: `
<h1>Parent Component</h1>
<p>Value: {{ data }}</p>
<app-child [value]="data" (messageEmitted)="onMessage($event)"></app-child>
`
})
class ParentComponent {
data: string = 'Initial Data';
onMessage(message: string) {
this.data = message;
}
}
describe('ParentComponent with ATL', () => {
it('should render child component and handle output', async () => {
await render(ParentComponent, {
declarations: [ChildComponent], // Declare child component
componentProperties: {
data: 'Parent Initial' // Set @Input() 'data'
}
});
expect(screen.getByText('Parent Component')).toBeInTheDocument();
expect(screen.getByText('Value: Parent Initial')).toBeInTheDocument();
// Simulate clicking the button in the child component
fireEvent.click(screen.getByRole('button', { name: /emit/i }));
// Expect the parent's data to be updated by the child's output
expect(screen.getByText('Value: Hello from child!')).toBeInTheDocument();
});
});
Testing Angular Building Blocks
This section provides practical examples of how to unit test different parts of an Angular application using Jest.
3.1 Testing Services
Services typically contain business logic, data fetching, or interactions with other services. They are often tested without TestBed if they don’t depend on Angular’s DI heavily or have minimal component interactions.
3.1.1 Simple Service Testing
For services that don’t inject other services or have simple dependencies, you can instantiate them directly.
// src/app/services/calculator.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CalculatorService {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
}
// src/app/services/calculator.service.spec.ts
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
service = new CalculatorService();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should add two numbers', () => {
expect(service.add(2, 3)).toBe(5);
});
it('should subtract two numbers', () => {
expect(service.subtract(5, 2)).toBe(3);
});
});
3.1.2 Mocking HTTP Calls in Services
When a service makes HTTP calls (e.g., using HttpClient), you’ll want to mock HttpClient to prevent actual network requests during tests. HttpClientTestingModule and HttpTestingController are Angular’s utilities for this.
// src/app/services/user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
interface User {
id: number;
name: string;
}
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl = '/api/users';
constructor(private http: HttpClient) {}
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
}
// src/app/services/user.service.spec.ts
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],
providers: [UserService]
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure that no outstanding requests are pending
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should retrieve users from the API via GET', () => {
const dummyUsers = [{ id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Smith' }];
service.getUsers().subscribe(users => {
expect(users.length).toBe(2);
expect(users).toEqual(dummyUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(dummyUsers); // Provide the mock response
});
it('should retrieve a user by ID via GET', () => {
const dummyUser = { id: 1, name: 'John Doe' };
const userId = 1;
service.getUserById(userId).subscribe(user => {
expect(user).toEqual(dummyUser);
});
const req = httpMock.expectOne(`/api/users/${userId}`);
expect(req.request.method).toBe('GET');
req.flush(dummyUser);
});
});
3.2 Testing Components: Behavior vs. Implementation
When testing components, focus on their public behavior as a user would perceive it, rather than their internal implementation details. This makes tests more robust to refactoring.
3.2.1 Testing @Input() and @Output()
Test how components respond to input changes and emit output events.
// src/app/components/greeting/greeting.component.ts
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
@Component({
selector: 'app-greeting',
template: `
<h2>Hello, {{ name }}!</h2>
<button (click)="greet.emit('Hello from component!')">Say Hello</button>
`,
standalone: true // Example for standalone component
})
export class GreetingComponent implements OnInit, OnDestroy {
@Input() name: string = 'Guest';
@Output() greet = new EventEmitter<string>();
ngOnInit() {
// console.log('GreetingComponent initialized');
}
ngOnDestroy() {
// console.log('GreetingComponent destroyed');
}
}
// src/app/components/greeting/greeting.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
import { By } from '@angular/platform-browser';
import { render, screen, fireEvent } from '@testing-library/angular';
describe('GreetingComponent', () => {
let component: GreetingComponent;
let fixture: ComponentFixture<GreetingComponent>;
beforeEach(async () => {
// Configure TestBed for traditional component testing (without ATL)
await TestBed.configureTestingModule({
imports: [GreetingComponent] // Import standalone component
}).compileComponents();
fixture = TestBed.createComponent(GreetingComponent);
component = fixture.componentInstance;
fixture.detectChanges(); // Initial change detection
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display the default name if no input is provided', () => {
const h2Element: HTMLElement = fixture.nativeElement.querySelector('h2');
expect(h2Element.textContent).toContain('Hello, Guest!');
});
it('should display the input name', () => {
component.name = 'Alice';
fixture.detectChanges(); // Trigger change detection to reflect input change
const h2Element: HTMLElement = fixture.nativeElement.querySelector('h2');
expect(h2Element.textContent).toContain('Hello, Alice!');
});
it('should emit a greeting message on button click (traditional)', () => {
let emittedMessage: string | undefined;
component.greet.subscribe((message: string) => {
emittedMessage = message;
});
const button = fixture.debugElement.query(By.css('button')).nativeElement;
button.click();
expect(emittedMessage).toBe('Hello from component!');
});
it('should emit a greeting message on button click (ATL)', async () => {
const { fixture } = await render(GreetingComponent, {
imports: [GreetingComponent],
componentProperties: {
name: 'Bob'
}
});
const greetSpy = jest.spyOn(fixture.componentInstance.greet, 'emit');
fireEvent.click(screen.getByRole('button', { name: /say hello/i }));
expect(greetSpy).toHaveBeenCalledWith('Hello from component!');
});
});
3.2.2 Testing DOM Interactions and User Events
Use fixture.nativeElement or Angular Testing Library’s screen and fireEvent to simulate user interactions.
// Assuming GreetingComponent from above
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GreetingComponent } from './greeting.component';
import { By } from '@angular/platform-browser';
import { render, screen, fireEvent } from '@testing-library/angular';
describe('GreetingComponent DOM Interactions', () => {
it('should display "Hello, User!" when a user is loaded (traditional)', () => {
const component = TestBed.createComponent(GreetingComponent).componentInstance;
component.name = 'User';
TestBed.createComponent(GreetingComponent).detectChanges(); // Re-render to pick up name change
const compiled = TestBed.createComponent(GreetingComponent).nativeElement as HTMLElement;
expect(compiled.querySelector('h2')?.textContent).toContain('Hello, User!');
});
it('should display "Hello, User!" when a user is loaded (ATL)', async () => {
await render(GreetingComponent, {
imports: [GreetingComponent],
componentProperties: {
name: 'User'
}
});
expect(screen.getByText('Hello, User!')).toBeInTheDocument();
});
it('should trigger click event (ATL)', async () => {
const mockGreet = jest.fn();
await render(GreetingComponent, {
imports: [GreetingComponent],
componentProperties: {
greet: { emit: mockGreet } as any // Mock EventEmitter using `as any` or a proper mock
}
});
fireEvent.click(screen.getByRole('button', { name: /say hello/i }));
expect(mockGreet).toHaveBeenCalledWith('Hello from component!');
});
});
3.2.3 Testing Component Lifecycle Hooks
While you typically don’t explicitly “test” lifecycle hooks directly, you’ll often verify their effects. For ngOnInit and ngOnDestroy, you can spy on methods called within them.
// src/app/components/lifecycle-demo/lifecycle-demo.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
@Component({
selector: 'app-lifecycle-demo',
template: `<p>Lifecycle Demo</p>`,
standalone: true
})
export class LifecycleDemoComponent implements OnInit, OnDestroy {
logMessage(message: string) {
console.log(message);
}
ngOnInit(): void {
this.logMessage('LifecycleDemoComponent initialized');
}
ngOnDestroy(): void {
this.logMessage('LifecycleDemoComponent destroyed');
}
}
// src/app/components/lifecycle-demo/lifecycle-demo.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LifecycleDemoComponent } from './lifecycle-demo.component';
describe('LifecycleDemoComponent', () => {
let component: LifecycleDemoComponent;
let fixture: ComponentFixture<LifecycleDemoComponent>;
let logSpy: jest.SpyInstance;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [LifecycleDemoComponent]
}).compileComponents();
fixture = TestBed.createComponent(LifecycleDemoComponent);
component = fixture.componentInstance;
logSpy = jest.spyOn(component, 'logMessage');
});
it('should call ngOnInit when component is initialized', () => {
fixture.detectChanges(); // Triggers ngOnInit
expect(logSpy).toHaveBeenCalledWith('LifecycleDemoComponent initialized');
});
it('should call ngOnDestroy when component is destroyed', () => {
fixture.detectChanges(); // Ensure component is initialized first
fixture.destroy(); // Triggers ngOnDestroy
expect(logSpy).toHaveBeenCalledWith('LifecycleDemoComponent destroyed');
});
});
3.3 Testing Pipes
Pipes are pure functions that transform data. They are generally easy to test as they don’t have dependencies on TestBed.
// src/app/pipes/capitalize.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'capitalize',
standalone: true
})
export class CapitalizePipe implements PipeTransform {
transform(value: string | null | undefined): string {
if (!value) {
return '';
}
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
}
// src/app/pipes/capitalize.pipe.spec.ts
import { CapitalizePipe } from './capitalize.pipe';
describe('CapitalizePipe', () => {
let pipe: CapitalizePipe;
beforeEach(() => {
pipe = new CapitalizePipe();
});
it('should create an instance', () => {
expect(pipe).toBeTruthy();
});
it('should capitalize the first letter of a string', () => {
expect(pipe.transform('hello')).toBe('Hello');
expect(pipe.transform('WORLD')).toBe('World');
expect(pipe.transform('Angular')).toBe('Angular');
});
it('should handle empty string', () => {
expect(pipe.transform('')).toBe('');
});
it('should handle null or undefined input', () => {
expect(pipe.transform(null)).toBe('');
expect(pipe.transform(undefined)).toBe('');
});
});
3.4 Testing Directives
Directives modify the behavior or appearance of DOM elements. Testing them often involves creating a test host component to apply the directive to.
// src/app/directives/highlight.directive.ts
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true
})
export class HighlightDirective {
@Input('appHighlight') highlightColor: string = 'yellow';
constructor(private el: ElementRef) {}
@HostListener('mouseenter') onMouseEnter() {
this.highlight(this.highlightColor);
}
@HostListener('mouseleave') onMouseLeave() {
this.highlight('');
}
private highlight(color: string) {
this.el.nativeElement.style.backgroundColor = color;
}
}
// src/app/directives/highlight.directive.spec.ts
import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';
// Test Host Component to apply the directive
@Component({
template: `
<div appHighlight="blue"></div>
<div appHighlight></div>
<div></div>
`,
standalone: true,
imports: [HighlightDirective] // Import the directive
})
class TestHostComponent {}
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
let des: DebugElement[]; // DebugElements for directive instances
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent]
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges(); // Initial change detection
// Find all elements with the directive applied
des = fixture.debugElement.queryAll(By.directive(HighlightDirective));
});
it('should have two elements with the HighlightDirective', () => {
expect(des.length).toBe(2);
});
it('should set background color to blue on the first element', () => {
const divWithCustomColor = des[0].nativeElement as HTMLElement;
expect(divWithCustomColor.style.backgroundColor).toBe(''); // No highlight initially
// Simulate mouseenter event
des[0].triggerEventHandler('mouseenter', null);
fixture.detectChanges();
expect(divWithCustomColor.style.backgroundColor).toBe('blue');
// Simulate mouseleave event
des[0].triggerEventHandler('mouseleave', null);
fixture.detectChanges();
expect(divWithCustomColor.style.backgroundColor).toBe('');
});
it('should set background color to yellow on the second element (default color)', () => {
const divWithDefaultColor = des[1].nativeElement as HTMLElement;
expect(divWithDefaultColor.style.backgroundColor).toBe('');
des[1].triggerEventHandler('mouseenter', null);
fixture.detectChanges();
expect(divWithDefaultColor.style.backgroundColor).toBe('yellow');
des[1].triggerEventHandler('mouseleave', null);
fixture.detectChanges();
expect(divWithDefaultColor.style.backgroundColor).toBe('');
});
});
Advanced Jest Angular Testing Patterns
Optimizing Jest performance and handling modern Angular features are key to efficient testing in large applications.
4.1 Jest ESM Support: Optimizing Performance
The transition to ECMAScript Modules (ESM) in Node.js and Angular has significantly impacted Jest performance. Older Jest setups struggled with transpiling ESM modules in node_modules back to CommonJS (CJS).
Why it matters: Angular v12+ libraries primarily ship as ESM bundles. If Jest doesn’t handle ESM natively or efficiently, it has to transpile these modules, leading to considerable slowdowns.
jest-preset-angular and recent Jest versions have improved ESM support. The useESM: true configuration in jest.config.js and running Jest with node --experimental-vm-modules helps Jest understand and execute ESM files directly, reducing the need for costly transpilation.
Key performance improvements come from:
- Angular’s build system taking over: The official Angular CLI integration with Jest (currently experimental) aims to have Angular handle the build process, feeding Jest pre-compiled
.mjsfiles. This bypasses Jest’s own transpilation ofnode_modules. esbuild: Angular’s new build tool (esbuild) is significantly faster than Webpack, and its integration in future Angular testing solutions will further boost performance.
4.2 Understanding and Addressing Jest Performance Pitfalls
Even with improved ESM support, you might encounter slow tests. Common culprits include:
4.2.1 transformIgnorePatterns and moduleNameMapper
transformIgnorePatterns: This Jest configuration tells Jest which files not to transpile. By default, it ignoresnode_modules. However, if somenode_modulespackages ship in a format Jest doesn’t natively understand (e.g., modern ESM that needs a specific transform), you might need to remove them fromtransformIgnorePatternsso Jest processes them. Conversely, if Jest is trying to transpile something it shouldn’t, adding it here can help.// jest.config.js module.exports = { // ... transformIgnorePatterns: [ 'node_modules/(?!.*\\\\\\\\.mjs$)', // This pattern might allow .mjs files in node_modules to be transformed // Or if you specifically need to transform a lib: // 'node_modules/(?!@some-lib|other-lib)', ], // ... };moduleNameMapper: Used to map module imports to different files or stubs. This is particularly useful for replacing specific files (e.g.,tslib/tslib.es6.jsif there are issues withtslib’s default import) or mocking non-JavaScript assets.// jest.config.js module.exports = { // ... moduleNameMapper: { '^lodash-es$': 'lodash', // Example: Map lodash-es to commonjs lodash '\\\\\\\\.(css|less|sass|scss)$': 'identity-obj-proxy', // Ignore CSS imports '\\\\\\\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js', }, // ... };
4.2.2 Sharing Context in Tests: beforeEach vs. Global Mocks
A common pitfall leading to slow or flaky tests is accidentally sharing mutable state between test cases.
Problem: If you declare mocks or test variables globally (outside
describeorbeforeEach), they persist across all tests in that file. Changes in one test can affect subsequent tests.Solution 1 (Preferred): Reset context in
beforeEach:- Initialize all mocks and test setup within
beforeEach. This ensures each test starts with a fresh, isolated state.
// Good: Isolated context describe('MyService', () => { let service: MyService; let mockDependency: any; beforeEach(() => { mockDependency = { /* fresh mock */ }; service = new MyService(mockDependency); }); it('test case 1', () => { /* uses fresh service and mockDependency */ }); it('test case 2', () => { /* uses another fresh service and mockDependency */ }); });- Initialize all mocks and test setup within
Solution 2 (Less Ideal): Reset mocks explicitly:
- If creating mocks is very expensive and can’t be done
beforeEach, you can create them once withbeforeAlland then usemockClear()ormockReset()inbeforeEachto clear call history or reset their implementations. However, this is more error-prone as you might forget to reset something.
// Less ideal: Requires manual resetting const expensiveMock = jest.fn(); // Globally defined describe('MyService (with expensive mock)', () => { beforeEach(() => { expensiveMock.mockClear(); // Only clears call history, not implementation // or expensiveMock.mockReset(); // Clears all mocks and resets implementation }); it('should use expensive mock correctly', () => { /* ... */ }); });- If creating mocks is very expensive and can’t be done
4.3 Testing Angular Signals (Angular v17.1+)
Angular Signals (introduced in v16, stabilized in v17.1 for inputs) provide a new reactive primitive for managing state. Testing components that use signals requires understanding how they integrate with Jest.
Testing Signal Inputs: Angular 17.1 introduced signal inputs, which are essentially reactive @Input() properties. You can test them similarly to regular inputs.
// src/app/components/signal-input-demo/signal-input-demo.component.ts
import { Component, Input, signal, computed } from '@angular/core';
@Component({
selector: 'app-signal-input-demo',
template: `
<h2>Signal Input Demo</h2>
<p>Message: {{ message() }}</p>
<p>Length: {{ messageLength() }}</p>
<button (click)="changeMessage()">Change Message</button>
`,
standalone: true
})
export class SignalInputDemoComponent {
@Input({ required: true }) set initialMessage(value: string) {
this.message.set(value);
}
message = signal<string>('');
messageLength = computed(() => this.message().length);
changeMessage() {
this.message.set('Updated Signal Message');
}
}
// src/app/components/signal-input-demo/signal-input-demo.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SignalInputDemoComponent } from './signal-input-demo.component';
import { By } from '@angular/platform-browser';
import { signal } from '@angular/core';
describe('SignalInputDemoComponent', () => {
let component: SignalInputDemoComponent;
let fixture: ComponentFixture<SignalInputDemoComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SignalInputDemoComponent]
}).compileComponents();
fixture = TestBed.createComponent(SignalInputDemoComponent);
component = fixture.componentInstance;
});
it('should display initial message from signal input', () => {
// Set the signal input before initial change detection
component.initialMessage = 'Hello Signals!';
fixture.detectChanges();
expect(component.message()).toBe('Hello Signals!');
expect(fixture.nativeElement.textContent).toContain('Message: Hello Signals!');
expect(fixture.nativeElement.textContent).toContain('Length: 14');
});
it('should update message when signal input changes', () => {
component.initialMessage = 'First Message';
fixture.detectChanges();
expect(component.message()).toBe('First Message');
// Update the input and trigger change detection again
component.initialMessage = 'Second Message';
fixture.detectChanges();
expect(component.message()).toBe('Second Message');
expect(fixture.nativeElement.textContent).toContain('Message: Second Message');
});
it('should update message when internal method changes signal', () => {
component.initialMessage = 'Original';
fixture.detectChanges();
expect(component.message()).toBe('Original');
const button = fixture.debugElement.query(By.css('button')).nativeElement;
button.click();
fixture.detectChanges(); // Re-detect changes after button click
expect(component.message()).toBe('Updated Signal Message');
expect(fixture.nativeElement.textContent).toContain('Message: Updated Signal Message');
});
it('should react to computed signal changes', () => {
component.initialMessage = 'Test';
fixture.detectChanges();
expect(component.messageLength()).toBe(4);
expect(fixture.nativeElement.textContent).toContain('Length: 4');
component.message.set('Longer Test Message');
fixture.detectChanges(); // Change detection updates computed signals
expect(component.messageLength()).toBe(21);
expect(fixture.nativeElement.textContent).toContain('Length: 21');
});
});
4.4 Sharding Jest Tests for CI/CD Performance
For very large test suites, even with Jest’s parallelization on a single machine, CI/CD pipelines can become slow. Test sharding allows you to split your test suite into multiple chunks and run them concurrently on different CI runners or machines, significantly reducing total execution time.
4.4.1 How -shard Works
Jest v28 introduced the --shard CLI flag. You use it in the format --shard=<index>/<count>.
<index>: The 1-based index of the shard to run (e.g., 1, 2, 3).<count>: The total number of shards you want to split your tests into.
Example: jest --shard=1/3 runs the first third of your test files.
Under the hood, Jest’s test sequencer divides your test files into the specified number of groups. By default, it’s an even division by the number of files.
4.4.2 Parallelizing Tests with Sharding
To leverage sharding, you configure your CI/CD pipeline to spawn multiple jobs in parallel, each running Jest with a different --shard index.
GitHub Actions Example:
name: CI Tests with Sharding
on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false # Run all shards even if one fails
matrix:
shard_index: [1, 2, 3] # Define 3 parallel shards
total_shards: [3]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm install
- name: Run Jest (Shard ${{ matrix.shard_index }} of ${{ matrix.total_shards }})
run: npm test -- --shard=${{ matrix.shard_index }}/${{ matrix.total_shards }}
# The 'npm test' script points to 'jest' in package.json
Explanation:
- The
strategy.matrixcreates independent jobs for eachshard_index. - Each job checks out the code, installs dependencies, and then runs
npm testwith its assigned-shardargument. - This effectively reduces the total wall-clock time for your test suite by distributing the workload.
Tips for Sharding:
- Consider total compute time: Sharding increases the total CPU time consumed, which might impact billing on cloud CI services.
- Combine coverage: If you need a combined test coverage report, you’ll need to upload coverage reports from each shard as artifacts and then have a separate CI step to download and merge them.
- Balance shards: While dividing by file count is the default, if you have a few very slow test files, consider a more sophisticated sharding strategy (e.g., based on historical test durations) or use a monorepo tool like Nx that provides advanced test distribution features.
- Nx Monorepos: For large Angular monorepos, Nx provides powerful built-in features for E2E and unit test distribution (
Nx Atomizer), intelligent caching, and affected project analysis, which significantly speeds up CI pipelines by only running tests for changed code.
Best Practices and Common Pitfalls
Writing high-quality, maintainable tests requires adherence to certain best practices and awareness of common pitfalls.
5.1 AAA Pattern (Arrange, Act, Assert)
The AAA pattern provides a clear and consistent structure for your test cases:
- Arrange: Set up the test environment, initialize objects, and mock dependencies.
- Act: Perform the action or call the method you are testing.
- Assert: Verify the expected outcome using assertions.
it('should increment the counter', () => {
// Arrange
const component = new CounterComponent(); // Simple component
// Act
component.increment();
// Assert
expect(component.count).toBe(1);
});
5.2 Avoiding Testing Implementation Details
Focus your tests on the observable behavior of your code, not its internal implementation. If you test private methods or how a component achieves its goal rather than what it achieves, your tests will be brittle and break easily when refactoring.
Bad Example (testing implementation detail):
// Component:
// class MyComponent { private calculateSum(a, b) { return a + b; } ... }
// Test:
// expect(component['calculateSum'](2,3)).toBe(5); // Don't test private methods
Good Example (testing public behavior):
// Component:
// <input [(ngModel)]="num1"> <input [(ngModel)]="num2">
// <button (click)="displaySum()">Calculate</button> <p>{{ sum }}</p>
// Test:
// typeInto(num1Input, '2'); typeInto(num2Input, '3');
// click(calculateButton);
// expect(screen.getByText('5')).toBeInTheDocument(); // Test the visible outcome
5.3 Using fakeAsync and tick() for Asynchronous Operations
Angular’s fakeAsync zone and the tick() function allow you to test asynchronous code synchronously, simplifying tests that involve setTimeout, setInterval, or HTTP requests (when not using HttpClientTestingModule).
fakeAsync(): Creates a special testing zone that “fakes” asynchronous operations, allowing you to control time.tick(ms): Advances the virtual clock bymsmilliseconds.flush(): Flushes all pending asynchronous tasks in thefakeAsynczone.
import { fakeAsync, tick } from '@angular/core/testing';
it('should execute setTimeout callback synchronously', fakeAsync(() => {
let result = false;
setTimeout(() => {
result = true;
}, 1000);
expect(result).toBe(false); // Not yet executed
tick(500); // Advance clock by 500ms
expect(result).toBe(false); // Still not executed
tick(500); // Advance by another 500ms (total 1000ms)
expect(result).toBe(true); // Now executed
}));
it('should handle multiple async operations', fakeAsync(() => {
let log: string[] = [];
setTimeout(() => log.push('A'), 100);
setTimeout(() => log.push('B'), 50);
tick(50);
expect(log).toEqual(['B']);
tick(50);
expect(log).toEqual(['B', 'A']);
}));
Note: For HTTP calls, HttpClientTestingModule and HttpTestingController are generally preferred over fakeAsync as they provide more granular control over network requests.
5.4 Keeping Tests Independent and Isolated
Each test (it block) should be able to run independently of others. Avoid relying on the state set up in previous tests. This ensures that tests are reliable, easy to debug, and can be run in any order. The beforeEach hook is your best friend for achieving this isolation.
5.5 Preventing Memory Leaks in Tests (fixture.destroy())
When using TestBed to create component fixtures, it’s crucial to clean them up after each test to prevent memory leaks in your test runner, especially in large test suites.
fixture.destroy(): Destroys the component and its associated change detector. Call this in anafterEachhook.
// src/app/components/my-component/my-component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MyComponent } from './my.component';
describe('MyComponent', () => {
let fixture: ComponentFixture<MyComponent>;
let component: MyComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MyComponent] // For standalone components
// declarations: [MyComponent] for NgModule based components
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => {
fixture.destroy(); // Essential cleanup
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
// ... other tests
});
5.6 SIFERS (Simple Injectable Functions Explicitly Returning State)
SIFERS is a pattern that promotes clean and flexible test setup functions. Instead of putting all setup logic directly into beforeEach, you create a setup function that can receive optional arguments, allowing for different test scenarios.
// src/app/services/feature-flag.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class FeatureFlagService {
private _isEnabled = new BehaviorSubject<boolean>(false);
isEnabled$: Observable<boolean> = this._isEnabled.asObservable();
setFeatureFlag(enabled: boolean) {
this._isEnabled.next(enabled);
}
}
// src/app/services/feature-flag.service.spec.ts
import { FeatureFlagService } from './feature-flag.service';
import { createSpyFromClass } from 'jest-auto-spies';
import { of } from 'rxjs';
// SIFER setup function
function setup(options?: { initialEnabled?: boolean; mockValue?: boolean }) {
const mockService = createSpyFromClass(FeatureFlagService, {
// Override a specific method's return value if needed
// isEnabled$: of(options?.mockValue ?? false) as any // Cast for type compatibility with spy
});
if (options?.initialEnabled !== undefined) {
mockService.setFeatureFlag(options.initialEnabled);
}
// If you are using createSpyFromClass for Observables, you might need to mock them like this:
(mockService.isEnabled$ as any) = of(options?.mockValue ?? false);
const service = new FeatureFlagService(); // Create real instance if not using mock for tests
return {
service,
mockService,
};
}
describe('FeatureFlagService with SIFERS', () => {
it('should be disabled by default', () => {
const { service } = setup();
let isEnabled: boolean | undefined;
service.isEnabled$.subscribe(val => (isEnabled = val));
expect(isEnabled).toBe(false);
});
it('should be enabled when feature flag is set to true', () => {
const { service } = setup({ initialEnabled: true });
let isEnabled: boolean | undefined;
service.isEnabled$.subscribe(val => (isEnabled = val));
expect(isEnabled).toBe(true);
});
it('should return mock value if specified in setup', () => {
const { mockService } = setup({ mockValue: true });
let isEnabled: boolean | undefined;
mockService.isEnabled$.subscribe(val => (isEnabled = val));
expect(isEnabled).toBe(true);
expect(mockService.isEnabled$).toHaveBeenCalled(); // This checks if the observable was subscribed to (via mock)
});
});
5.7 Type Safety for Mocks
When creating mocks, especially with jest.fn(), the mock function might be typed as any, leading to a loss of type safety. This can hide bugs where your mock doesn’t accurately reflect the real interface of the mocked dependency.
Problem:
const unreliableMock = jest.fn(() => 123); // Type: Mock<any, any>
// If this was supposed to mock a function that returns `string`, TypeScript won't complain here.
Solution: Explicitly type your mocks or use utilities that preserve type safety.
Explicit Typing with
jest.Mocked: Jest providesjest.Mocked<T>utility type to wrap your mocked instances with correct typing.interface MyApi { getData(): string; postData(data: string): Promise<void>; } describe('MyComponent', () => { let mockApi: jest.Mocked<MyApi>; beforeEach(() => { mockApi = { getData: jest.fn(() => 'mocked data'), postData: jest.fn(async () => {}) }; // If mockApi.getData.mockReturnValue(123) was used, TypeScript would complain! }); it('should use the typed mock', () => { expect(mockApi.getData()).toBe('mocked data'); expect(mockApi.getData).toHaveBeenCalled(); }); });Using
createSpyFromClass(fromjest-auto-spies): This library automatically preserves types when creating spies, which is a great advantage.
Guided Projects
These guided projects will help you apply the learned concepts in practical scenarios.
6.1 Project 1: Testing a Simple Counter Component
Goal: Create and test a simple Angular counter component that increments and decrements a number.
Steps:
Create a new Angular component:
ng generate component counter --standaloneImplement the
CounterComponent(src/app/counter/counter.component.ts):import { Component, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; @Component({ selector: 'app-counter', template: ` <h1>Counter: {{ count() }}</h1> <button (click)="increment()">Increment</button> <button (click)="decrement()">Decrement</button> <button (click)="reset()">Reset</button> `, standalone: true, imports: [CommonModule], styles: [` :host { display: block; padding: 20px; text-align: center; } button { margin: 0 10px; padding: 10px 20px; font-size: 1em; cursor: pointer; } `] }) export class CounterComponent { count = signal(0); increment() { this.count.update(current => current + 1); } decrement() { this.count.update(current => current - 1); } reset() { this.count.set(0); } }Write
CounterComponenttests (src/app/counter/counter.component.spec.ts) usingTestBedand DOM querying:import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CounterComponent } from './counter.component'; import { By } from '@angular/platform-browser'; describe('CounterComponent (TestBed)', () => { let component: CounterComponent; let fixture: ComponentFixture<CounterComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [CounterComponent], }).compileComponents(); fixture = TestBed.createComponent(CounterComponent); component = fixture.componentInstance; fixture.detectChanges(); // Initial change detection }); it('should create the component', () => { expect(component).toBeTruthy(); }); it('should display initial count as 0', () => { const h1Element: HTMLElement = fixture.nativeElement.querySelector('h1'); expect(h1Element.textContent).toContain('Counter: 0'); expect(component.count()).toBe(0); }); it('should increment count when Increment button is clicked', () => { const incrementButton = fixture.debugElement.query(By.css('button:nth-of-type(1)')); incrementButton.triggerEventHandler('click', null); fixture.detectChanges(); // Trigger change detection after click expect(component.count()).toBe(1); expect(fixture.nativeElement.textContent).toContain('Counter: 1'); }); it('should decrement count when Decrement button is clicked', () => { // First increment to a positive number component.increment(); fixture.detectChanges(); expect(component.count()).toBe(1); const decrementButton = fixture.debugElement.query(By.css('button:nth-of-type(2)')); decrementButton.triggerEventHandler('click', null); fixture.detectChanges(); expect(component.count()).toBe(0); expect(fixture.nativeElement.textContent).toContain('Counter: 0'); }); it('should reset count when Reset button is clicked', () => { component.increment(); component.increment(); fixture.detectChanges(); expect(component.count()).toBe(2); const resetButton = fixture.debugElement.query(By.css('button:nth-of-type(3)')); resetButton.triggerEventHandler('click', null); fixture.detectChanges(); expect(component.count()).toBe(0); expect(fixture.nativeElement.textContent).toContain('Counter: 0'); }); });Run tests:
npm test
6.2 Project 2: Testing a User List with Data Fetching
Goal: Create components that display a list of users fetched from a service, and test their interactions and data handling.
Steps:
Create a
Userinterface (src/app/models/user.interface.ts):export interface User { id: number; name: string; email: string; }Create a
UserService(src/app/services/user.service.ts):import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { User } from '../models/user.interface'; import { delay } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class UserService { private apiUrl = '/api/users'; // Mock this in tests constructor(private http: HttpClient) {} getUsers(): Observable<User[]> { // Simulate an HTTP request with a delay return this.http.get<User[]>(this.apiUrl).pipe(delay(100)); } }Create a
UserListComponent(src/app/user-list/user-list.component.ts):import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { UserService } from '../services/user.service'; import { User } from '../models/user.interface'; @Component({ selector: 'app-user-list', template: ` <h2>User List</h2> <p *ngIf="loading">Loading users...</p> <ul *ngIf="!loading && users.length > 0"> <li *ngFor="let user of users"> {{ user.name }} ({{ user.email }}) </li> </ul> <p *ngIf="!loading && users.length === 0">No users found.</p> <button (click)="loadUsers()">Load Users</button> `, standalone: true, imports: [CommonModule], styles: [` ul { list-style: none; padding: 0; } li { margin-bottom: 5px; padding: 5px; border: 1px solid #eee; } `] }) export class UserListComponent implements OnInit { users: User[] = []; loading: boolean = false; constructor(private userService: UserService) {} ngOnInit(): void { this.loadUsers(); } loadUsers(): void { this.loading = true; this.userService.getUsers().subscribe( (data: User[]) => { this.users = data; this.loading = false; }, (error) => { console.error('Error loading users:', error); this.loading = false; } ); } }Write
UserListComponenttests (src/app/user-list/user-list.component.spec.ts) usingHttpClientTestingModuleandfakeAsync:import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { UserListComponent } from './user-list.component'; import { UserService } from '../services/user.service'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { User } from '../models/user.interface'; import { By } from '@angular/platform-browser'; import { of } from 'rxjs'; describe('UserListComponent', () => { let component: UserListComponent; let fixture: ComponentFixture<UserListComponent>; let userService: UserService; let httpMock: HttpTestingController; const dummyUsers: User[] = [ { id: 1, name: 'Alice Smith', email: 'alice@example.com' }, { id: 2, name: 'Bob Johnson', email: 'bob@example.com' }, ]; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [UserListComponent, HttpClientTestingModule], // Import both providers: [UserService] }).compileComponents(); fixture = TestBed.createComponent(UserListComponent); component = fixture.componentInstance; userService = TestBed.inject(UserService); httpMock = TestBed.inject(HttpTestingController); }); afterEach(() => { httpMock.verify(); // Ensure no outstanding requests }); it('should display "Loading users..." initially then user list', fakeAsync(() => { fixture.detectChanges(); // ngOnInit calls loadUsers // Expect loading message to be present expect(fixture.nativeElement.textContent).toContain('Loading users...'); // Expect one HTTP request const req = httpMock.expectOne('/api/users'); req.flush(dummyUsers); // Provide mock data tick(100); // Advance time by 100ms for the simulated delay in service fixture.detectChanges(); // Trigger change detection to update UI after data arrives // Expect loading message to be gone expect(fixture.nativeElement.textContent).not.toContain('Loading users...'); // Expect users to be displayed expect(fixture.nativeElement.querySelectorAll('li').length).toBe(2); expect(fixture.nativeElement.textContent).toContain('Alice Smith'); expect(fixture.nativeElement.textContent).toContain('Bob Johnson'); })); it('should display "No users found." if no users are returned', fakeAsync(() => { fixture.detectChanges(); // ngOnInit calls loadUsers const req = httpMock.expectOne('/api/users'); req.flush([]); // Provide empty array tick(100); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('No users found.'); expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0); })); it('should reload users when "Load Users" button is clicked', fakeAsync(() => { // Initial load on ngOnInit fixture.detectChanges(); httpMock.expectOne('/api/users').flush([]); // Flush initial empty response tick(100); fixture.detectChanges(); expect(component.users.length).toBe(0); // Click the button to load users again const loadButton = fixture.debugElement.query(By.css('button')).nativeElement; loadButton.click(); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Loading users...'); const req = httpMock.expectOne('/api/users'); req.flush(dummyUsers); // Flush the second request with dummy users tick(100); fixture.detectChanges(); expect(component.users.length).toBe(2); expect(fixture.nativeElement.textContent).toContain('Alice Smith'); })); it('should handle API errors gracefully', fakeAsync(() => { jest.spyOn(console, 'error').mockImplementation(() => {}); // Suppress console error fixture.detectChanges(); // ngOnInit calls loadUsers const req = httpMock.expectOne('/api/users'); req.error(new ErrorEvent('Network error'), { status: 500, statusText: 'Server Error' }); tick(100); fixture.detectChanges(); expect(component.loading).toBe(false); expect(component.users.length).toBe(0); expect(fixture.nativeElement.textContent).not.toContain('Loading users...'); expect(fixture.nativeElement.textContent).toContain('No users found.'); // Or a specific error message expect(console.error).toHaveBeenCalled(); })); });
6.3 Project 3: Building a Test Suite for a Reactive Form
Goal: Test an Angular reactive form for validation, submission, and interaction with a service.
Steps:
Create a
ContactService(src/app/services/contact.service.ts):import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; import { delay } from 'rxjs/operators'; export interface ContactForm { name: string; email: string; message: string; } @Injectable({ providedIn: 'root' }) export class ContactService { submitContactForm(formData: ContactForm): Observable<string> { console.log('Submitting form:', formData); // Simulate API call success return of('Form submitted successfully!').pipe(delay(500)); } }Create a
ContactFormComponent(src/app/contact-form/contact-form.component.ts):import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { ContactService } from '../services/contact.service'; @Component({ selector: 'app-contact-form', template: ` <h2>Contact Us</h2> <form [formGroup]="contactForm" (ngSubmit)="onSubmit()"> <div> <label for="name">Name:</label> <input id="name" type="text" formControlName="name"> <div *ngIf="contactForm.get('name')?.invalid && (contactForm.get('name')?.dirty || contactForm.get('name')?.touched)"> <small *ngIf="contactForm.get('name')?.errors?.['required']">Name is required.</small> <small *ngIf="contactForm.get('name')?.errors?.['minlength']">Name must be at least 3 characters.</small> </div> </div> <div> <label for="email">Email:</label> <input id="email" type="email" formControlName="email"> <div *ngIf="contactForm.get('email')?.invalid && (contactForm.get('email')?.dirty || contactForm.get('email')?.touched)"> <small *ngIf="contactForm.get('email')?.errors?.['required']">Email is required.</small> <small *ngIf="contactForm.get('email')?.errors?.['email']">Enter a valid email.</small> </div> </div> <div> <label for="message">Message:</label> <textarea id="message" formControlName="message"></textarea> <div *ngIf="contactForm.get('message')?.invalid && (contactForm.get('message')?.dirty || contactForm.get('message')?.touched)"> <small *ngIf="contactForm.get('message')?.errors?.['required']">Message is required.</small> </div> </div> <button type="submit" [disabled]="contactForm.invalid">Submit</button> </form> <p *ngIf="submissionMessage">{{ submissionMessage }}</p> `, standalone: true, imports: [CommonModule, ReactiveFormsModule], styles: [` form div { margin-bottom: 15px; } label { display: block; margin-bottom: 5px; font-weight: bold; } input[type="text"], input[type="email"], textarea { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; } small { color: red; font-size: 0.8em; } button { padding: 10px 20px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; } button:disabled { background-color: #cccccc; cursor: not-allowed; } `] }) export class ContactFormComponent implements OnInit { contactForm: FormGroup; submissionMessage: string | null = null; constructor(private fb: FormBuilder, private contactService: ContactService) { this.contactForm = this.fb.group({ name: ['', [Validators.required, Validators.minLength(3)]], email: ['', [Validators.required, Validators.email]], message: ['', Validators.required] }); } ngOnInit(): void {} onSubmit(): void { if (this.contactForm.valid) { this.contactService.submitContactForm(this.contactForm.value).subscribe( (response: string) => { this.submissionMessage = response; this.contactForm.reset(); }, (error) => { this.submissionMessage = 'Submission failed. Please try again.'; console.error('Form submission error:', error); } ); } else { this.contactForm.markAllAsTouched(); } } }Write
ContactFormComponenttests (src/app/contact-form/contact-form.component.spec.ts) usingTestBed,fakeAsync, andContactServicemocking:import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ContactFormComponent } from './contact-form.component'; import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; import { ContactService, ContactForm } from '../services/contact.service'; import { of, throwError } from 'rxjs'; import { By } from '@angular/platform-browser'; import { render, screen, fireEvent } from '@testing-library/angular'; import userEvent from '@testing-library/user-event'; describe('ContactFormComponent', () => { let component: ContactFormComponent; let fixture: ComponentFixture<ContactFormComponent>; let mockContactService: jest.Mocked<ContactService>; // Use Jest's Mocked type const validFormData: ContactForm = { name: 'Test User', email: 'test@example.com', message: 'Hello Jest!', }; beforeEach(async () => { // Create a mock for ContactService mockContactService = { submitContactForm: jest.fn(() => of('Submission successful!').pipe(tick(500))) // Simulate success with delay } as jest.Mocked<ContactService>; // Cast as Mocked<ContactService> await TestBed.configureTestingModule({ imports: [ContactFormComponent, ReactiveFormsModule], providers: [ FormBuilder, // Provide FormBuilder { provide: ContactService, useValue: mockContactService } ], }).compileComponents(); fixture = TestBed.createComponent(ContactFormComponent); component = fixture.componentInstance; fixture.detectChanges(); // Initial change detection }); it('should create the form component', () => { expect(component).toBeTruthy(); expect(component.contactForm).toBeDefined(); expect(component.contactForm.invalid).toBe(true); // Form should be invalid initially }); // --- Validation Tests --- it('should have name required error', () => { const nameControl = component.contactForm.get('name'); nameControl?.markAsTouched(); fixture.detectChanges(); expect(nameControl?.errors?.['required']).toBe(true); expect(fixture.nativeElement.querySelector('small')?.textContent).toContain('Name is required.'); }); it('should have name minlength error', () => { const nameControl = component.contactForm.get('name'); nameControl?.setValue('ab'); // Less than 3 chars nameControl?.markAsTouched(); fixture.detectChanges(); expect(nameControl?.errors?.['minlength']).toBe(true); expect(fixture.nativeElement.querySelector('small')?.textContent).toContain('Name must be at least 3 characters.'); }); it('should have email required and email format errors', () => { const emailControl = component.contactForm.get('email'); emailControl?.markAsTouched(); fixture.detectChanges(); expect(emailControl?.errors?.['required']).toBe(true); emailControl?.setValue('invalid-email'); fixture.detectChanges(); expect(emailControl?.errors?.['email']).toBe(true); }); it('should have message required error', () => { const messageControl = component.contactForm.get('message'); messageControl?.markAsTouched(); fixture.detectChanges(); expect(messageControl?.errors?.['required']).toBe(true); }); it('should enable submit button when form is valid', () => { component.contactForm.setValue(validFormData); fixture.detectChanges(); const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; expect(submitButton.disabled).toBe(false); }); it('should disable submit button when form is invalid', () => { const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; expect(submitButton.disabled).toBe(true); // Initially invalid }); // --- Submission Tests --- it('should call contactService.submitContactForm and display success message on valid submission', fakeAsync(() => { component.contactForm.setValue(validFormData); fixture.detectChanges(); const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; submitButton.click(); fixture.detectChanges(); // Update after submission initiated expect(mockContactService.submitContactForm).toHaveBeenCalledWith(validFormData); expect(component.submissionMessage).toBeNull(); // Message not yet received tick(500); // Advance time for the service's simulated delay fixture.detectChanges(); // Update UI after service response expect(component.submissionMessage).toBe('Submission successful!'); expect(fixture.nativeElement.textContent).toContain('Submission successful!'); expect(component.contactForm.pristine).toBe(true); // Form should be reset expect(component.contactForm.untouched).toBe(true); // Form should be reset })); it('should display submission failed message on service error', fakeAsync(() => { mockContactService.submitContactForm.mockReturnValue(throwError(() => new Error('API Error'))); // Mock service to return error jest.spyOn(console, 'error').mockImplementation(() => {}); // Suppress console error component.contactForm.setValue(validFormData); fixture.detectChanges(); const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; submitButton.click(); fixture.detectChanges(); tick(500); fixture.detectChanges(); expect(mockContactService.submitContactForm).toHaveBeenCalled(); expect(component.submissionMessage).toBe('Submission failed. Please try again.'); expect(fixture.nativeElement.textContent).toContain('Submission failed. Please try again.'); expect(console.error).toHaveBeenCalledWith('Form submission error:', expect.any(Error)); })); it('should mark all controls as touched if form is invalid on submit', () => { // Form is initially invalid const submitButton = fixture.debugElement.query(By.css('button[type="submit"]')).nativeElement; submitButton.click(); fixture.detectChanges(); expect(component.contactForm.get('name')?.touched).toBe(true); expect(component.contactForm.get('email')?.touched).toBe(true); expect(component.contactForm.get('message')?.touched).toBe(true); }); }); describe('ContactFormComponent (Angular Testing Library)', () => { let mockContactService: jest.Mocked<ContactService>; beforeEach(async () => { mockContactService = { submitContactForm: jest.fn(() => of('ATL Submission successful!').pipe(tick(500))) } as jest.Mocked<ContactService>; await render(ContactFormComponent, { imports: [ReactiveFormsModule], providers: [ { provide: ContactService, useValue: mockContactService } ], }); }); it('should show validation errors when fields are touched and invalid (ATL)', async () => { await userEvent.type(screen.getByLabelText(/name:/i), 'a'); // Type less than minlength fireEvent.blur(screen.getByLabelText(/name:/i)); // Touch the field expect(await screen.findByText('Name must be at least 3 characters.')).toBeInTheDocument(); await userEvent.type(screen.getByLabelText(/email:/i), 'invalid-email'); fireEvent.blur(screen.getByLabelText(/email:/i)); expect(await screen.findByText('Enter a valid email.')).toBeInTheDocument(); }); it('should submit form successfully and display message (ATL)', fakeAsync(async () => { // Fill the form using userEvent await userEvent.type(screen.getByLabelText(/name:/i), validFormData.name); await userEvent.type(screen.getByLabelText(/email:/i), validFormData.email); await userEvent.type(screen.getByLabelText(/message:/i), validFormData.message); // Click the submit button const submitButton = screen.getByRole('button', { name: /submit/i }); expect(submitButton).toBeEnabled(); // Ensure button is enabled after filling form fireEvent.click(submitButton); expect(mockContactService.submitContactForm).toHaveBeenCalledWith(validFormData); expect(screen.queryByText('Submission successful!')).not.toBeInTheDocument(); // Not yet appeared tick(500); // Advance time expect(await screen.findByText('ATL Submission successful!')).toBeInTheDocument(); // Check if form fields are reset (assuming reset() functionality works) expect((screen.getByLabelText(/name:/i) as HTMLInputElement).value).toBe(''); })); });
Further Exploration & Resources
To continue your learning journey and stay updated with the latest in Jest Angular Testing, here are some valuable resources:
7.1 Blogs and Articles
- Angular.dev Testing Guide: The official documentation is always the most authoritative source.
- Medium (ngconf, Angular Adventurer, Tomas Trajan, etc.): Many Angular developers share their insights, best practices, and solutions to common testing challenges. Search for “Angular Jest testing,” “Angular advanced testing,” or specific topics like “Angular Signals testing.”
- DEV Community: A platform where developers share articles and tutorials on a wide range of topics, including Angular and Jest.
- Nx Blog: If you’re working with monorepos, the Nx blog has excellent articles on optimizing Angular testing performance and CI/CD.
- Testing Library Blog: While not specific to Angular, their general philosophy on user-centric testing is invaluable.
7.2 Video Tutorials/Courses
- Angular’s Official YouTube Channel: Look for videos related to testing, new Angular features, and best practices.
- Fireship (YouTube): While not exclusively Angular, they often cover modern web development topics including testing.
- Academind (YouTube / Udemy): Maximilian Schwarzmüller offers comprehensive Angular courses that often include testing sections.
- Individual Angular Dev Channels: Many independent Angular developers create high-quality tutorials on YouTube. Search for “Angular Jest tutorial 2025.”
7.3 Official Documentation
- Jest Official Documentation: https://jestjs.io/ (Essential for Jest’s core features, APIs, and CLI options)
- Angular.dev Testing Guide: https://angular.dev/guide/testing (Covers Angular-specific testing utilities like
TestBed,fakeAsync,HttpClientTestingModule) - Angular Testing Library: https://testing-library.com/docs/angular-testing-library/intro/ (Documentation for the user-centric testing approach)
- Jest Preset Angular: https://github.com/thymikee/jest-preset-angular (For setup and advanced configuration details)
7.4 Community Forums
- Stack Overflow: Search for specific error messages or “how-to” questions.
- Angular Discord/Slack Channels: Join Angular communities for real-time discussions and help.
- GitHub Issues: Check the issue trackers for Jest, Angular, and related libraries for ongoing discussions about bugs or new features.
7.5 Additional Project Ideas
- Shopping Cart Application: Test adding/removing items, quantity updates, total calculation, and local storage integration.
- To-Do List with Filters: Test adding tasks, marking as complete, filtering by status, and editing tasks.
- Authentication Module: Test login/logout functionality, token handling, and guarded routes.
- Drag-and-Drop Component: Test complex user interactions and state changes during drag-and-drop operations.
- Data Table with Sorting/Pagination: Test data manipulation, UI updates based on user interaction, and service calls for paginated data.
- Theme Switcher Component: Test component inputs, output events, and interactions with a global theme service.
- Form with Custom Validators: Test custom synchronous and asynchronous validators.
- Real-time Chat Application (Mocking WebSockets): Test message sending/receiving, user presence, and notification handling by mocking WebSocket interactions.
- Animation-heavy Component: Test when animations complete and their impact on component state (requires creative mocking of animation APIs or careful use of
fakeAsync). - Accessibility (a11y) Testing with Jest: Learn to use
@testing-library/angularwithjest-axefor basic accessibility checks in unit tests.
7.6 Essential Third-Party Libraries/Tools
@testing-library/angular: For writing user-centric, robust component tests.jest-auto-spies: Simplifies the creation of mocks for classes, maintaining type safety.msw(Mock Service Worker): For mocking API calls at the network level, providing a more realistic testing environment for integration tests without hitting actual backends.observer-spy: Useful for testing RxJS observables in a more declarative way.jest-axe: Integratesaxe-core(an accessibility testing engine) with Jest to perform accessibility checks in your unit tests.- Nx (for Monorepos): Offers advanced testing capabilities like intelligent test re-execution, caching, and distributed task execution for large Angular monorepos.