Angular Testing Best Practices
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
- Test User Behavior: Focus on what users see and do
- Use TestBed Minimal: Only configure what's needed for the test
- Mock External Dependencies: HTTP, routing, services
- Mutation Testing: Verify test effectiveness with Stryker
- 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)lcovonlyreporter: 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 assertionsspyOn(): 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 requestsHttpTestingController: Lets you expect specific requests and provide mock responseshttpMock.expectOne(url): Asserts exactly one request was made to that URLreq.flush(data): Provides the mock response datahttpMock.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:
donecallback: Traditional Jasmine async testing. Calldone()when test completes. Errors ifdoneisn't called within timeout.fakeAsyncandtick(): 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
- Angular General - Angular fundamentals and components
- Angular State Management - Testing services, signals, and RxJS
Testing Strategy
- Testing Strategy - Overall testing approach
- Unit Testing - General unit testing principles
- Integration Testing - Integration testing patterns
- Mutation Testing - Stryker configuration for Angular
- CI Testing - CI pipeline testing practices
Language and Tools
- TypeScript Testing - Jest and testing patterns
External Resources
Summary
Key Takeaways
- TestBed configuration should be minimal - only configure what's needed
- Mock external dependencies - HTTP, routing, services with jasmine.createSpyObj
- HttpTestingController for testing HTTP requests and responses
- detectChanges() triggers change detection manually in tests
- fakeAsync and tick for testing async operations synchronously
- Test user behavior - what users see and do, not implementation
- Signals testing is straightforward - just check computed values
- Mutation testing with Stryker verifies test effectiveness (target: 80%+)
- Spy on methods to verify they're called with correct arguments
- 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.