Jest for Angular

// table of contents

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.

FeatureKarma/JasmineJest
Test RunnerKarma (browser-based)Jest (Node.js with jsdom)
SpeedSlower due to real browser overheadFaster due to jsdom and parallelization
SetupCan be more complex to configureGenerally simpler, especially with jest-preset-angular
FeaturesRequires additional libraries for mocking/spyingBuilt-in assertion, mocking, spying, and code coverage
ESM SupportRelies on Angular’s build processImproved ESM support with jest-preset-angular and Jest’s evolution
Watch ModeLess efficientHighly 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 (like describe, 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. jsdom emulates a browser DOM.
  • globals: Configuration for ts-jest, including the tsconfig for compilation and useESM: true for 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"

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 @Component decorator, 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 by ngOnChanges.
  • 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() and jest.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 (from jest-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 to AppModule, 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 a ComponentFixture<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 around nativeElement for 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 importing NgModules that 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 on componentInstance.
  • 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 .mjs files. This bypasses Jest’s own transpilation of node_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 ignores node_modules. However, if some node_modules packages ship in a format Jest doesn’t natively understand (e.g., modern ESM that needs a specific transform), you might need to remove them from transformIgnorePatterns so 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.js if there are issues with tslib’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 describe or beforeEach), 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 */ });
    });
    
  • Solution 2 (Less Ideal): Reset mocks explicitly:

    • If creating mocks is very expensive and can’t be done beforeEach, you can create them once with beforeAll and then use mockClear() or mockReset() in beforeEach to 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', () => { /* ... */ });
    });
    

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.matrix creates independent jobs for each shard_index.
  • Each job checks out the code, installs dependencies, and then runs npm test with its assigned -shard argument.
  • 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 by ms milliseconds.
  • flush(): Flushes all pending asynchronous tasks in the fakeAsync zone.
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 an afterEach hook.
// 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 provides jest.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 (from jest-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:

  1. Create a new Angular component:

    ng generate component counter --standalone
    
  2. Implement 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);
      }
    }
    
  3. Write CounterComponent tests (src/app/counter/counter.component.spec.ts) using TestBed and 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');
      });
    });
    
  4. 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:

  1. Create a User interface (src/app/models/user.interface.ts):

    export interface User {
      id: number;
      name: string;
      email: string;
    }
    
  2. 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));
      }
    }
    
  3. 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;
          }
        );
      }
    }
    
  4. Write UserListComponent tests (src/app/user-list/user-list.component.spec.ts) using HttpClientTestingModule and fakeAsync:

    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:

  1. 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));
      }
    }
    
  2. 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();
        }
      }
    }
    
  3. Write ContactFormComponent tests (src/app/contact-form/contact-form.component.spec.ts) using TestBed, fakeAsync, and ContactService mocking:

    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

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

  1. Shopping Cart Application: Test adding/removing items, quantity updates, total calculation, and local storage integration.
  2. To-Do List with Filters: Test adding tasks, marking as complete, filtering by status, and editing tasks.
  3. Authentication Module: Test login/logout functionality, token handling, and guarded routes.
  4. Drag-and-Drop Component: Test complex user interactions and state changes during drag-and-drop operations.
  5. Data Table with Sorting/Pagination: Test data manipulation, UI updates based on user interaction, and service calls for paginated data.
  6. Theme Switcher Component: Test component inputs, output events, and interactions with a global theme service.
  7. Form with Custom Validators: Test custom synchronous and asynchronous validators.
  8. Real-time Chat Application (Mocking WebSockets): Test message sending/receiving, user presence, and notification handling by mocking WebSocket interactions.
  9. Animation-heavy Component: Test when animations complete and their impact on component state (requires creative mocking of animation APIs or careful use of fakeAsync).
  10. Accessibility (a11y) Testing with Jest: Learn to use @testing-library/angular with jest-axe for 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: Integrates axe-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.