Skip to main content

Angular Testing Best Practices

Why Angular Testing

Angular's comprehensive testing utilities (TestBed, fixture, DebugElement) make component and service testing straightforward. Thorough testing catches bugs before production, ensures requirements are met, and provides confidence during refactoring. Test user-facing behavior, not implementation details.

Overview

This guide covers Angular testing best practices using TestBed, Jasmine, and Stryker mutation testing. Angular's testing infrastructure is built around Jasmine (test framework) and Karma (test runner). TestBed is Angular's primary testing utility that creates an Angular testing module, allowing you to test components with their dependencies in an isolated environment.


Core Principles

  1. Test User Behavior: Focus on what users see and do
  2. Use TestBed Minimal: Only configure what's needed for the test
  3. Mock External Dependencies: HTTP, routing, services
  4. Mutation Testing: Verify test effectiveness with Stryker
  5. Async Testing: Handle observables and promises correctly

Testing Setup

Karma is the test runner that executes your tests in real browsers (or headless browsers for CI). Proper configuration ensures consistent test execution across development and CI environments. Code coverage thresholds enforce quality standards - tests must achieve minimum coverage percentages or the build fails.

Karma Configuration

// karma.conf.js
module.exports = function(config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
random: false // Consistent test order
},
clearContext: false
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' },
{ type: 'lcovonly' }
],
check: {
global: {
statements: 85,
branches: 85,
functions: 85,
lines: 85
}
}
},
reporters: ['progress', 'coverage'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['ChromeHeadless'],
singleRun: false,
restartOnFileChange: true
});
};

Key configuration points:

  • random: false: Ensures tests run in consistent order (aids debugging)
  • Coverage thresholds: Enforces minimum 85% coverage across statements, branches, functions, and lines
  • ChromeHeadless: Runs tests in headless Chrome for CI pipelines (faster, no GUI needed)
  • lcovonly reporter: Generates coverage data in a format tools like SonarQube can consume

Component Testing

Component testing in Angular uses TestBed to create a testing module that simulates the Angular runtime environment. The ComponentFixture wraps the component instance and provides methods to trigger change detection and access the DOM. This allows you to test components in isolation while still having access to Angular's features like dependency injection and change detection.

The pattern is: configure TestBed → create fixture → access component instance → interact with DOM → assert results.

Basic Component Test

// payment-card.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PaymentCardComponent } from './payment-card.component';

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

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PaymentCardComponent] // Standalone component
}).compileComponents();

fixture = TestBed.createComponent(PaymentCardComponent);
component = fixture.componentInstance;
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should display payment information', () => {
// Arrange
component.payment = {
id: 'PAY-123',
amount: 100,
currency: 'USD',
status: 'pending'
};

// Act
fixture.detectChanges();

// Assert
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.payment-id')?.textContent).toContain('PAY-123');
expect(compiled.querySelector('.payment-amount')?.textContent).toContain('100');
expect(compiled.querySelector('.payment-status')?.textContent).toContain('pending');
});

it('should emit view event when button clicked', () => {
// Arrange
component.payment = {
id: 'PAY-123',
amount: 100,
currency: 'USD',
status: 'pending'
};
spyOn(component.view, 'emit');

// Act
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button');
button?.click();

// Assert
expect(component.view.emit).toHaveBeenCalledWith('PAY-123');
});
});

Testing patterns demonstrated:

  • compileComponents(): Compiles component templates and styles (required for external templates, optional for inline)
  • fixture.detectChanges(): Manually triggers change detection (doesn't happen automatically in tests)
  • fixture.nativeElement: Accesses the component's DOM for assertions
  • spyOn(): Creates Jasmine spies to verify method calls
  • Arrange-Act-Assert: Clear test structure that improves readability

The first test (should create) is generated by Angular CLI and verifies basic instantiation. The other tests focus on user-facing behavior - what users see and do.

Testing with Dependencies

// payment-list.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { of } from 'rxjs';
import { PaymentListComponent } from './payment-list.component';
import { PaymentService } from './services/payment.service';

describe('PaymentListComponent', () => {
let component: PaymentListComponent;
let fixture: ComponentFixture<PaymentListComponent>;
let paymentService: jasmine.SpyObj<PaymentService>;

beforeEach(async () => {
// Create spy object
const paymentServiceSpy = jasmine.createSpyObj('PaymentService', ['getPayments']);

await TestBed.configureTestingModule({
imports: [PaymentListComponent, HttpClientTestingModule],
providers: [
{ provide: PaymentService, useValue: paymentServiceSpy }
]
}).compileComponents();

fixture = TestBed.createComponent(PaymentListComponent);
component = fixture.componentInstance;
paymentService = TestBed.inject(PaymentService) as jasmine.SpyObj<PaymentService>;
});

it('should load payments on init', () => {
// Arrange
const mockPayments = [
{ id: 'PAY-1', amount: 100, currency: 'USD', status: 'completed' as const },
{ id: 'PAY-2', amount: 200, currency: 'EUR', status: 'pending' as const }
];
paymentService.getPayments.and.returnValue(of(mockPayments));

// Act
fixture.detectChanges(); // Triggers ngOnInit

// Assert
expect(paymentService.getPayments).toHaveBeenCalled();
expect(component.payments).toEqual(mockPayments);
});
});

When testing components with dependencies (services, HTTP client, router), you must provide mocks in the TestBed configuration. jasmine.createSpyObj creates a mock object with spy methods, allowing you to control return values and verify method calls. This isolates the component test from real services, making tests faster and more reliable.

The useValue provider syntax replaces the real service with your spy object. This is superior to injecting real services because it avoids side effects (no actual HTTP calls, database changes, etc.) and gives you complete control over service behavior in tests.


Service Testing

Service testing is generally simpler than component testing because services don't involve the DOM or change detection. However, services that make HTTP requests require special handling through Angular's HttpTestingController, which allows you to mock HTTP responses without actually hitting a backend.

Basic Service Test

// payment.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { PaymentService } from './payment.service';

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

beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [PaymentService]
});

service = TestBed.inject(PaymentService);
httpMock = TestBed.inject(HttpTestingController);
});

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

it('should retrieve payments from API', () => {
// Arrange
const mockPayments = [
{ id: 'PAY-1', amount: 100, currency: 'USD', status: 'completed' as const }
];

// Act
service.getPayments().subscribe(payments => {
// Assert
expect(payments).toEqual(mockPayments);
expect(payments.length).toBe(1);
});

// Assert HTTP request
const req = httpMock.expectOne('/api/payments');
expect(req.request.method).toBe('GET');
req.flush(mockPayments);
});

it('should create payment via POST', () => {
// Arrange
const newPayment = { amount: 150, currency: 'USD', vendorId: 'VENDOR-123' };
const createdPayment = { id: 'PAY-NEW', ...newPayment, status: 'pending' as const };

// Act
service.createPayment(newPayment).subscribe(payment => {
// Assert
expect(payment).toEqual(createdPayment);
});

// Assert HTTP request
const req = httpMock.expectOne('/api/payments');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newPayment);
req.flush(createdPayment);
});

it('should handle HTTP errors', () => {
// Act
service.getPayments().subscribe({
next: () => fail('Should have failed'),
error: (error) => {
// Assert
expect(error.status).toBe(500);
}
});

// Assert HTTP request
const req = httpMock.expectOne('/api/payments');
req.flush('Server error', { status: 500, statusText: 'Internal Server Error' });
});
});

HTTP testing patterns:

  • HttpClientTestingModule: Provides a mock HTTP backend that intercepts requests
  • HttpTestingController: Lets you expect specific requests and provide mock responses
  • httpMock.expectOne(url): Asserts exactly one request was made to that URL
  • req.flush(data): Provides the mock response data
  • httpMock.verify(): Critical - verifies no unexpected requests were made (catches bugs where you forgot to mock a request)

HTTP testing ensures your services correctly format requests, handle responses, and deal with errors - all without hitting real APIs.


Testing Forms

Reactive forms in Angular are highly testable because the form model is defined in code rather than templates. You can test form validation, value changes, and submission logic without rendering components or interacting with the DOM (though testing DOM interactions is also valuable for end-to-end validation).

Reactive Forms Testing

// payment-form.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { PaymentFormComponent } from './payment-form.component';

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

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PaymentFormComponent, ReactiveFormsModule]
}).compileComponents();

fixture = TestBed.createComponent(PaymentFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should invalidate form when amount is negative', () => {
// Act
component.form.patchValue({ amount: -100 });

// Assert
expect(component.form.valid).toBe(false);
expect(component.form.get('amount')?.hasError('min')).toBe(true);
});

it('should validate form when all fields valid', () => {
// Act
component.form.patchValue({
amount: 100,
currency: 'USD',
vendorId: 'VENDOR-123'
});

// Assert
expect(component.form.valid).toBe(true);
});

it('should call onSubmit when form submitted', () => {
// Arrange
spyOn(component, 'onSubmit');
component.form.patchValue({
amount: 100,
currency: 'USD',
vendorId: 'VENDOR-123'
});

// Act
const form = fixture.nativeElement.querySelector('form');
form?.dispatchEvent(new Event('submit'));

// Assert
expect(component.onSubmit).toHaveBeenCalled();
});
});

Form testing verifies both model-level validation (component.form.valid, hasError()) and user interactions (form submission via DOM events). Testing the form model directly is faster and covers validation logic. Testing through the DOM ensures the template is correctly wired to the form model.


Testing Async Operations

Angular provides multiple approaches for testing async code: callbacks with done, fakeAsync with tick(), and async/await. Each has trade-offs. The fakeAsync approach is generally preferred because it makes async tests look synchronous, improving readability.

Testing Observables

it('should handle observable data', (done) => {
// Arrange
const mockPayments = [{ id: 'PAY-1', amount: 100, currency: 'USD', status: 'pending' as const }];
paymentService.getPayments.and.returnValue(of(mockPayments));

// Act
component.loadPayments();

// Assert
component.payments$.subscribe(payments => {
expect(payments).toEqual(mockPayments);
done();
});
});

// Or use fakeAsync and tick
it('should handle observable data with fakeAsync', fakeAsync(() => {
// Arrange
const mockPayments = [{ id: 'PAY-1', amount: 100, currency: 'USD', status: 'pending' as const }];
paymentService.getPayments.and.returnValue(of(mockPayments));

// Act
component.loadPayments();
tick();

// Assert
expect(component.payments).toEqual(mockPayments);
}));

Async testing approaches:

  • done callback: Traditional Jasmine async testing. Call done() when test completes. Errors if done isn't called within timeout.
  • fakeAsync and tick(): Runs test in a simulated async zone. tick() advances time, making timers and promises resolve. Makes async code appear synchronous.
  • tick() without arguments: Flushes all pending async tasks

fakeAsync is powerful for testing code with timeouts, debouncing, or complex async flows. It gives you precise control over when async operations complete.

Testing Signals

it('should update signal value', () => {
// Arrange
component.amount.set(0);

// Act
component.updateAmount(150);

// Assert
expect(component.amount()).toBe(150);
});

it('should compute derived signal', () => {
// Arrange
component.amount.set(100);
component.currency.set('USD');

// Assert (computed signals update automatically)
expect(component.displayAmount()).toBe('$100.00');
});

Signals are synchronous and straightforward to test. No async handling needed - just call signal setters and assert the new values or computed results. Computed signals update automatically when their dependencies change, so you only need to set the dependent signals and assert the computed result.


Testing Routing

Testing routing involves verifying that navigation happens correctly, route parameters are read, and components react appropriately to route changes. Use provideRouter with RouterTestingHarness (Angular 15+) instead of the deprecated RouterTestingModule.

Router Testing

import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';

describe('PaymentDetailsComponent with Routing', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: 'payments/:id', component: PaymentDetailsComponent }
])
]
});
});

it('should display payment details on navigation', async () => {
const harness = await RouterTestingHarness.create();

// Navigate to route and get the routed component
const component = await harness.navigateByUrl(
'/payments/PAY-123',
PaymentDetailsComponent
);

// Assert component received route param
expect(component.paymentId).toBe('PAY-123');

// Or assert rendered output
expect(harness.routeNativeElement?.textContent).toContain('PAY-123');
});
});

RouterTestingHarness.navigateByUrl returns a promise that resolves once navigation and change detection complete, so no fakeAsync/tick is needed. The harness exposes the component instance directly and the rendered native element for DOM assertions.


Stryker Mutation Testing

Mutation testing validates the quality of your tests by introducing small code changes (mutations) and checking if your tests catch them. If tests still pass after a mutation, it means that code path isn't properly tested. Stryker automatically generates mutations (changing + to -, > to >=, removing conditionals, etc.) and runs your test suite for each mutation.

High mutation scores (80%+) indicate robust tests that actually verify behavior rather than just achieving code coverage. A file can have 100% code coverage but poor mutation score if tests don't assert the right things.

Stryker Configuration

// stryker.conf.json
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager": "npm",
"testRunner": "karma",
"karma": {
"configFile": "karma.conf.js",
"projectType": "angular-cli",
"config": {
"browsers": ["ChromeHeadless"]
}
},
"mutate": [
"src/**/*.ts",
"!src/**/*.spec.ts",
"!src/test/**/*.ts",
"!src/environments/**/*.ts"
],
"thresholds": {
"high": 85,
"low": 70,
"break": 65
},
"timeoutMS": 60000,
"concurrency": 4,
"coverageAnalysis": "perTest"
}

Running Stryker

# Install Stryker
npm install --save-dev @stryker-mutator/core \
@stryker-mutator/karma-runner \
@stryker-mutator/typescript-checker

# Run mutation testing
npx stryker run

# View report
open reports/mutation/html/index.html

See Mutation Testing for comprehensive Stryker patterns.


Common Patterns

Testing Pipes

import { PaymentStatusPipe } from './payment-status.pipe';

describe('PaymentStatusPipe', () => {
let pipe: PaymentStatusPipe;

beforeEach(() => {
pipe = new PaymentStatusPipe();
});

it('should transform status to display text', () => {
expect(pipe.transform('pending')).toBe(' Pending');
expect(pipe.transform('completed')).toBe(' Completed');
expect(pipe.transform('failed')).toBe(' Failed');
});
});

Testing Directives

import { Component, DebugElement } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { HighlightDirective } from './highlight.directive';

@Component({
template: `<span appHighlight>Test</span>`
})
class TestComponent {}

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

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

fixture = TestBed.createComponent(TestComponent);
de = fixture.debugElement.query(By.directive(HighlightDirective));
});

it('should highlight element', () => {
fixture.detectChanges();
const bgColor = de.nativeElement.style.backgroundColor;
expect(bgColor).toBe('yellow');
});
});

Further Reading

Angular Framework Guidelines

Testing Strategy

Language and Tools

External Resources


Summary

Key Takeaways

  1. TestBed configuration should be minimal - only configure what's needed
  2. Mock external dependencies - HTTP, routing, services with jasmine.createSpyObj
  3. HttpTestingController for testing HTTP requests and responses
  4. detectChanges() triggers change detection manually in tests
  5. fakeAsync and tick for testing async operations synchronously
  6. Test user behavior - what users see and do, not implementation
  7. Signals testing is straightforward - just check computed values
  8. Mutation testing with Stryker verifies test effectiveness (target: 80%+)
  9. Spy on methods to verify they're called with correct arguments
  10. Always call httpMock.verify() to ensure no outstanding HTTP requests

Next Steps: Review Angular Performance for optimization strategies and Mutation Testing for improving test quality.