Angular Testing
Testing Angular applications with Jest for unit tests and Cypress for end-to-end tests
You are an expert in testing Angular applications using Jest for unit and integration tests and Cypress for end-to-end tests. ## Key Points 1. **Use `data-testid` attributes for DOM queries.** They are resilient to CSS class and structure changes, making tests less brittle. 2. **Test behavior, not implementation.** Assert on what the user sees and what actions produce, not on internal component state. 3. **Mock HTTP at the `HttpClient` level.** Use `HttpClientTestingModule` in Jest and `cy.intercept` in Cypress. Never let tests hit real APIs. 4. **Use `fixture.detectChanges()` deliberately.** Call it after setup and after each action that should trigger change detection. This gives you control over when Angular processes updates. 5. **Keep E2E tests focused on critical paths.** E2E tests are slow. Cover the happy path and key error scenarios. Use unit/integration tests for edge cases. 6. **Use `jest.fn()` for spying.** Jest mocks are simple and powerful. Avoid creating manual stub classes when a mock function suffices. 7. **Use `componentRef.setInput()` for signal inputs.** This is the official API for setting input signals in tests. - **Async timing issues.** Forgetting `await fixture.whenStable()` or `fakeAsync` + `tick()` when testing async operations leads to flaky tests. - **Testing internal state instead of DOM output.** Checking `component.someProperty` instead of what renders in the template misses template binding bugs. - **Overmocking.** Mocking every dependency hides integration bugs. For components, prefer shallow integration tests that render the real template with mocked services. - **Ignoring `afterEach` cleanup.** Not calling `httpMock.verify()` lets unmatched HTTP expectations pass silently. - **Cypress tests depending on order.** Each `it()` block should be independent. Use `beforeEach` for setup and avoid relying on state from previous tests. ## Quick Example ```bash npm install -D jest @angular-builders/jest @types/jest ``` ```typescript // setup-jest.ts import 'jest-preset-angular/setup-jest'; ```
skilldb get angular-skills/Angular TestingFull skill: 518 linesTesting — Angular
You are an expert in testing Angular applications using Jest for unit and integration tests and Cypress for end-to-end tests.
Core Philosophy
Testing Angular applications well requires understanding that Angular's dependency injection system is your greatest testing asset. Every service, every HTTP call, every router interaction can be swapped with a test double through DI. The TestBed is not just a test utility — it is a miniature Angular runtime that gives you precise control over which dependencies are real and which are mocked. The key insight is that you should mock at the boundary (HTTP, external services) and let the component under test use its real template, real bindings, and real change detection.
The testing pyramid for Angular is clear: unit tests for services, pipes, and pure logic are fast and cheap. Integration tests for components (using TestBed with real templates but mocked services) catch the bugs that matter most — template binding errors, change detection issues, and event handling mistakes. E2E tests with Cypress cover critical user workflows across multiple pages. The majority of your tests should be in the first two categories; E2E tests are expensive to write and maintain and should focus on the flows that generate revenue or prevent data loss.
Test behavior, not implementation. Assert on what appears in the DOM, what events are emitted, and what services are called — not on the internal properties of the component class. A test that accesses component.someInternalFlag couples itself to implementation details and provides false confidence. A test that clicks a button and verifies the rendered output catches real bugs that users would encounter.
Anti-Patterns
-
Testing Component Properties Instead of DOM Output — asserting
expect(component.isLoading).toBe(true)instead of checking whether the loading spinner actually renders in the template. This misses template binding bugs entirely. -
Forgetting
fixture.detectChanges()After State Changes — modifying component state or service responses without triggering change detection, then wondering why assertions fail against stale DOM content. -
Omitting
httpMock.verify()inafterEach— skipping the verification step that ensures all expected HTTP requests were made and no unexpected requests went unmatched. Silent unmatched requests hide real bugs. -
E2E Tests That Depend on Execution Order — writing Cypress tests where
it()blocks rely on state created by previous tests. Each test should be independently repeatable with its own setup inbeforeEach. -
Over-Mocking Until Tests Are Meaningless — replacing every dependency with a mock so thoroughly that the test only verifies mock wiring. If the component, its template, all services, and all child components are mocked, the test exercises nothing real.
Overview
A well-tested Angular application uses a layered testing strategy: unit tests for services, pipes, and pure logic; integration tests for components with their templates and dependencies; and end-to-end tests for critical user workflows. Jest provides fast, parallel test execution with excellent mocking capabilities. Cypress offers reliable browser-based E2E testing with time-travel debugging.
Core Concepts
Jest Setup for Angular
Angular CLI projects use Karma by default, but Jest is widely preferred. Use @angular-builders/jest for seamless integration:
npm install -D jest @angular-builders/jest @types/jest
// angular.json (partial)
{
"projects": {
"my-app": {
"architect": {
"test": {
"builder": "@angular-builders/jest:run",
"options": {
"configPath": "jest.config.ts"
}
}
}
}
}
}
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
preset: 'jest-preset-angular',
setupFilesAfterSetup: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: ['/node_modules/', '/cypress/'],
};
export default config;
// setup-jest.ts
import 'jest-preset-angular/setup-jest';
TestBed Basics
Angular's TestBed configures a testing module for component and service tests:
import { TestBed } from '@angular/core/testing';
import { UserService } from './user.service';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
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 no outstanding requests
});
it('should fetch users', () => {
const mockUsers = [{ id: 1, name: 'Alice' }];
service.getUsers().subscribe(users => {
expect(users).toEqual(mockUsers);
});
const req = httpMock.expectOne('/api/users');
expect(req.request.method).toBe('GET');
req.flush(mockUsers);
});
it('should handle errors', () => {
service.getUsers().subscribe({
error: (err) => {
expect(err.status).toBe(500);
},
});
const req = httpMock.expectOne('/api/users');
req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
});
});
Implementation Patterns
Component Testing with Standalone Components
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductListComponent } from './product-list.component';
import { ProductService } from '../services/product.service';
import { of } from 'rxjs';
describe('ProductListComponent', () => {
let component: ProductListComponent;
let fixture: ComponentFixture<ProductListComponent>;
let productServiceMock: jest.Mocked<ProductService>;
beforeEach(async () => {
productServiceMock = {
getAll: jest.fn().mockReturnValue(of([
{ id: '1', name: 'Widget', price: 9.99 },
{ id: '2', name: 'Gadget', price: 19.99 },
])),
delete: jest.fn().mockReturnValue(of(void 0)),
} as any;
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [
{ provide: ProductService, useValue: productServiceMock },
],
}).compileComponents();
fixture = TestBed.createComponent(ProductListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should render product names', () => {
const items = fixture.nativeElement.querySelectorAll('.product-item');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('Widget');
expect(items[1].textContent).toContain('Gadget');
});
it('should call delete when remove button clicked', () => {
const deleteBtn = fixture.nativeElement.querySelector('[data-testid="delete-btn-1"]');
deleteBtn.click();
expect(productServiceMock.delete).toHaveBeenCalledWith('1');
});
});
Testing Components with Signals
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;
beforeEach(() => {
fixture = TestBed.createComponent(CounterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should increment the count', () => {
expect(component.count()).toBe(0);
component.increment();
expect(component.count()).toBe(1);
// Verify DOM update
fixture.detectChanges();
const display = fixture.nativeElement.querySelector('[data-testid="count"]');
expect(display.textContent).toContain('1');
});
it('should compute doubled value', () => {
component.count.set(5);
expect(component.doubled()).toBe(10);
});
});
Testing Components with Input Signals
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentRef } from '@angular/core';
import { UserCardComponent } from './user-card.component';
describe('UserCardComponent', () => {
let fixture: ComponentFixture<UserCardComponent>;
let componentRef: ComponentRef<UserCardComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(UserCardComponent);
componentRef = fixture.componentRef;
});
it('should display user name', () => {
componentRef.setInput('name', 'Alice');
componentRef.setInput('bio', 'Software engineer');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Alice');
expect(fixture.nativeElement.textContent).toContain('Software engineer');
});
});
Testing Reactive Forms
describe('RegistrationFormComponent', () => {
let component: RegistrationFormComponent;
let fixture: ComponentFixture<RegistrationFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RegistrationFormComponent],
}).compileComponents();
fixture = TestBed.createComponent(RegistrationFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should be invalid when empty', () => {
expect(component.form.valid).toBeFalsy();
});
it('should validate email format', () => {
const email = component.form.controls.email;
email.setValue('not-an-email');
expect(email.hasError('email')).toBeTruthy();
email.setValue('user@example.com');
expect(email.valid).toBeTruthy();
});
it('should validate password match', () => {
component.form.patchValue({
password: 'StrongP@ss1',
confirmPassword: 'DifferentP@ss1',
});
expect(component.form.hasError('passwordMismatch')).toBeTruthy();
component.form.controls.confirmPassword.setValue('StrongP@ss1');
expect(component.form.hasError('passwordMismatch')).toBeFalsy();
});
it('should submit when valid', () => {
const submitSpy = jest.spyOn(component, 'onSubmit');
component.form.patchValue({
username: 'alice',
email: 'alice@example.com',
password: 'StrongP@ss1',
confirmPassword: 'StrongP@ss1',
acceptTerms: true,
});
fixture.detectChanges();
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
expect(submitSpy).toHaveBeenCalled();
expect(component.form.valid).toBeTruthy();
});
});
Testing NgRx
// Testing a reducer
import { productsFeature } from './products.reducer';
import { ProductActions } from './products.actions';
describe('Products Reducer', () => {
const { reducer } = productsFeature;
it('should set loading on loadProducts', () => {
const state = reducer(undefined, ProductActions.loadProducts());
expect(state.loading).toBe(true);
expect(state.error).toBeNull();
});
it('should populate products on success', () => {
const products = [{ id: '1', name: 'Widget', price: 9.99, inStock: true }];
const state = reducer(undefined, ProductActions.loadProductsSuccess({ products }));
expect(state.products).toEqual(products);
expect(state.loading).toBe(false);
});
});
// Testing an effect
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jest-marbles';
import { Observable, of, throwError } from 'rxjs';
import * as productEffects from './products.effects';
describe('Product Effects', () => {
let actions$: Observable<any>;
let productServiceMock: jest.Mocked<ProductService>;
beforeEach(() => {
productServiceMock = { getAll: jest.fn(), delete: jest.fn() } as any;
TestBed.configureTestingModule({
providers: [
provideMockActions(() => actions$),
{ provide: ProductService, useValue: productServiceMock },
],
});
});
it('should dispatch success on load', () => {
const products = [{ id: '1', name: 'Widget', price: 9.99, inStock: true }];
productServiceMock.getAll.mockReturnValue(of(products));
actions$ = hot('-a', { a: ProductActions.loadProducts() });
const expected = cold('-b', { b: ProductActions.loadProductsSuccess({ products }) });
expect(TestBed.runInInjectionContext(() => productEffects.loadProducts())).toBeObservable(expected);
});
});
Testing with Router
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
describe('NavigationComponent', () => {
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NavigationComponent,
RouterTestingModule.withRoutes([
{ path: 'home', component: DummyComponent },
{ path: 'about', component: DummyComponent },
]),
],
}).compileComponents();
router = TestBed.inject(Router);
});
it('should navigate to about page', async () => {
const navigateSpy = jest.spyOn(router, 'navigate');
const fixture = TestBed.createComponent(NavigationComponent);
fixture.detectChanges();
const aboutLink = fixture.nativeElement.querySelector('[data-testid="about-link"]');
aboutLink.click();
expect(navigateSpy).toHaveBeenCalledWith(['/about']);
});
});
Cypress E2E Tests
// cypress/e2e/products.cy.ts
describe('Product Management', () => {
beforeEach(() => {
cy.intercept('GET', '/api/products', {
fixture: 'products.json',
}).as('getProducts');
cy.visit('/products');
cy.wait('@getProducts');
});
it('should display product list', () => {
cy.get('[data-testid="product-item"]').should('have.length.greaterThan', 0);
cy.get('[data-testid="product-item"]').first().should('contain', 'Widget');
});
it('should search products', () => {
cy.get('[data-testid="search-input"]').type('Gadget');
cy.get('[data-testid="product-item"]').should('have.length', 1);
cy.get('[data-testid="product-item"]').first().should('contain', 'Gadget');
});
it('should add a new product', () => {
cy.intercept('POST', '/api/products', {
statusCode: 201,
body: { id: '3', name: 'New Product', price: 29.99 },
}).as('createProduct');
cy.get('[data-testid="add-product-btn"]').click();
cy.get('[data-testid="product-name"]').type('New Product');
cy.get('[data-testid="product-price"]').type('29.99');
cy.get('[data-testid="submit-btn"]').click();
cy.wait('@createProduct');
cy.get('[data-testid="product-item"]').should('contain', 'New Product');
});
it('should handle server errors gracefully', () => {
cy.intercept('DELETE', '/api/products/*', {
statusCode: 500,
body: { message: 'Internal Server Error' },
}).as('deleteProduct');
cy.get('[data-testid="delete-btn"]').first().click();
cy.get('[data-testid="confirm-delete"]').click();
cy.wait('@deleteProduct');
cy.get('[data-testid="error-toast"]').should('contain', 'Failed to delete');
});
});
Cypress Component Testing
// product-card.component.cy.ts
import { ProductCardComponent } from './product-card.component';
describe('ProductCardComponent', () => {
it('should render product details', () => {
cy.mount(ProductCardComponent, {
componentProperties: {
product: { id: '1', name: 'Widget', price: 9.99, description: 'A fine widget' },
},
});
cy.get('[data-testid="product-name"]').should('contain', 'Widget');
cy.get('[data-testid="product-price"]').should('contain', '$9.99');
});
it('should emit addToCart event', () => {
const addToCartSpy = cy.spy().as('addToCartSpy');
cy.mount(ProductCardComponent, {
componentProperties: {
product: { id: '1', name: 'Widget', price: 9.99, description: 'A fine widget' },
addToCart: { emit: addToCartSpy } as any,
},
});
cy.get('[data-testid="add-to-cart-btn"]').click();
cy.get('@addToCartSpy').should('have.been.calledWith', '1');
});
});
Best Practices
-
Use
data-testidattributes for DOM queries. They are resilient to CSS class and structure changes, making tests less brittle. -
Test behavior, not implementation. Assert on what the user sees and what actions produce, not on internal component state.
-
Mock HTTP at the
HttpClientlevel. UseHttpClientTestingModulein Jest andcy.interceptin Cypress. Never let tests hit real APIs. -
Use
fixture.detectChanges()deliberately. Call it after setup and after each action that should trigger change detection. This gives you control over when Angular processes updates. -
Keep E2E tests focused on critical paths. E2E tests are slow. Cover the happy path and key error scenarios. Use unit/integration tests for edge cases.
-
Use
jest.fn()for spying. Jest mocks are simple and powerful. Avoid creating manual stub classes when a mock function suffices. -
Use
componentRef.setInput()for signal inputs. This is the official API for setting input signals in tests.
Common Pitfalls
-
Async timing issues. Forgetting
await fixture.whenStable()orfakeAsync+tick()when testing async operations leads to flaky tests. -
Testing internal state instead of DOM output. Checking
component.somePropertyinstead of what renders in the template misses template binding bugs. -
Overmocking. Mocking every dependency hides integration bugs. For components, prefer shallow integration tests that render the real template with mocked services.
-
Ignoring
afterEachcleanup. Not callinghttpMock.verify()lets unmatched HTTP expectations pass silently. -
Cypress tests depending on order. Each
it()block should be independent. UsebeforeEachfor setup and avoid relying on state from previous tests. -
Not testing error states. Happy-path-only testing misses the majority of bugs. Always test what happens when APIs fail, inputs are invalid, or the user does something unexpected.
Install this skill directly: skilldb add angular-skills
Related Skills
Angular Dependency Injection
Angular dependency injection system including providers, injection tokens, and hierarchical injectors
Angular Forms
Reactive forms, form validation, dynamic forms, and typed form controls in Angular
Angular Ngrx
NgRx state management with store, effects, selectors, and the component store
Angular Routing
Angular Router configuration including lazy loading, guards, resolvers, and nested routes
Angular Rxjs Patterns
RxJS reactive patterns for data fetching, state management, and event handling in Angular
Angular Signals
Angular Signals for fine-grained reactivity and efficient change detection