Angular Testing Mastery: The Complete Guide to TestBed, Mocking & Beyond
Master Angular testing from TestBed fundamentals to advanced patterns. Covers mocking strategies (createSpyObj, useValue, useClass, useFactory, spyOn), component testing, services, directives, pipes, reactive forms, HTTP calls, observables, routing, and async testing with fakeAsync.
- 1. Jest Unit Testing Mastery: The Complete Guide for React & Angular
- 2. Angular Testing Mastery: The Complete Guide to TestBed, Mocking & Beyond
Introduction
Angular is one of the few frameworks that was designed with testing in mind from day one. While most frameworks treat testing as an afterthought, Angular ships with a complete testing architecture built into the framework itself ā TestBed, dependency injection, and first-class support for testing every building block: components, services, pipes, directives, guards, and resolvers.
But having great tools doesn't automatically make testing easy. Angular testing has a steep learning curve precisely because of its power. TestBed's configuration can feel overwhelming, understanding when to use fakeAsync versus waitForAsync is confusing, mocking services with dependency injection requires understanding Angular's provider system, and testing reactive forms or HTTP calls introduces even more complexity.
This guide is your complete, intensive reference to testing Angular applications. We'll start with the testing architecture, move through every Angular building block (services, components, directives, pipes, forms, HTTP, routing), cover mocking strategies in depth, and close with async testing patterns and interview preparation. Every section includes detailed explanations with real-world code examples that go far beyond simple "hello world" tests.
Whether you're using Karma/Jasmine (Angular's default) or Jest (increasingly popular), the TestBed patterns and testing strategies in this guide apply equally. The examples use Jasmine-style syntax (describe, it, expect) which is identical in Jest.
Why Testing Matters in Angular
Think of Angular as a well-organized factory. Components are the machines, services are the supply chains, and pipes are the quality-control filters. Before the factory ships a product, every machine and process gets inspected independently. That's unit testing ā verifying each piece works on its own before they all run together.
Angular makes this particularly elegant because of dependency injection (DI). In a real factory, you can't test a machine in isolation if it's welded to 10 other machines. But Angular's DI lets you "unplug" any dependency and replace it with a test double (mock). This is what makes Angular testing both powerful and feasible.
How This Guide is Structured
This guide progresses from fundamentals to advanced patterns:
| Section | Difficulty | What You'll Learn |
|---|---|---|
| Angular Testing Architecture | š¢ Beginner | TestBed, fixtures, change detection |
| Mocking in Angular | š¢ Beginner | All 7 mocking techniques |
| Testing Services | š¢ Beginner | Simple and services with dependencies |
| Testing Components | š” Intermediate | Inputs, outputs, shallow vs deep |
| Testing Directives | š” Intermediate | Host component pattern |
| Testing Pipes | š¢ Beginner | Pure pipe testing (easiest!) |
| Testing Reactive Forms | š” Intermediate | Validation, submission, DOM interaction |
| Testing HTTP Calls | š” Intermediate | HttpClientTestingModule |
| Testing Observables & RxJS | š” Intermediate | BehaviorSubject, fakeAsync |
| Testing Component Interaction | š“ Advanced | Parent-child communication |
| Testing Routing | š“ Advanced | Navigation and route guards |
| Async Testing Strategies | š“ Advanced | fakeAsync vs waitForAsync vs done |
| Common Mistakes | š“ Advanced | Top 4 pitfalls |
| Interview Preparation | š“ Advanced | Real interview Q&A |
š” tip[!TIP] If you're new to Angular testing, start with Testing Pipes (the simplest section) to build confidence, then move to Testing Services, and finally tackle Testing Components.
Angular Testing Architecture
Before writing a single test, you need to understand the four core pieces of Angular's testing infrastructure:
TestBed
TestBed is Angular's testing utility class that creates a dynamically-configured Angular module specifically for testing. Think of it as a mini @NgModule that you configure in each test suite with only the declarations, imports, and providers your test needs.
TestBed.configureTestingModule({
declarations: [MyComponent], // Components, directives, pipes
imports: [FormsModule, HttpClientTestingModule], // Angular modules
providers: [
MyService, // Real service
{ provide: AuthService, useValue: mockAuth }, // Mocked service
],
});
ComponentFixture
When you create a component through TestBed, you get back a ComponentFixture<T> ā a wrapper that gives you access to both the component instance and its DOM:
const fixture = TestBed.createComponent(MyComponent);
// Access the component instance (TS class)
const component = fixture.componentInstance;
// Access the rendered DOM
const nativeElement = fixture.nativeElement as HTMLElement;
// Access DebugElement (Angular's test-friendly wrapper around DOM)
const debugElement = fixture.debugElement;
// Trigger change detection manually
fixture.detectChanges();
DebugElement
DebugElement is Angular's abstraction over the native DOM that provides Angular-aware query methods:
import { By } from '@angular/platform-browser';
// Query by CSS selector
const button = fixture.debugElement.query(By.css('button.submit'));
// Query by directive
const childComponent = fixture.debugElement.query(By.directive(ChildComponent));
// Query all matching elements
const listItems = fixture.debugElement.queryAll(By.css('li'));
// Access the native element from a DebugElement
const nativeButton = button.nativeElement as HTMLButtonElement;
Change Detection in Tests
This is crucial: Angular does not run change detection automatically in tests. You must explicitly call fixture.detectChanges() whenever the component's state changes:
component.title = 'New Title';
// DOM still shows old title at this point
fixture.detectChanges();
// NOW the DOM is updated
This is intentional and powerful ā it lets you inspect state before and after rendering, test intermediate states, and control exactly when the DOM updates.
Mocking in Angular
Mocking is the backbone of isolated unit tests. Angular's dependency injection system provides elegant, declarative ways to replace real dependencies with test doubles. This section covers every mocking technique you'll need.
Creating Mock Services with jasmine.createSpyObj
The most common mocking pattern in Angular tests. createSpyObj creates an object with pre-defined spy methods:
// Create a mock with specified method names
const mockUserService = jasmine.createSpyObj('UserService', [
'getUser',
'updateUser',
'deleteUser',
]);
// Configure return values
mockUserService.getUser.and.returnValue(of({ id: '1', name: 'Alice' }));
mockUserService.updateUser.and.returnValue(of(true));
mockUserService.deleteUser.and.returnValue(of(undefined));
// Use in TestBed
TestBed.configureTestingModule({
providers: [
{ provide: UserService, useValue: mockUserService },
],
});
For services with both methods and properties, pass a second array:
const mockAuthService = jasmine.createSpyObj('AuthService',
['login', 'logout'], // methods
{ isLoggedIn: true, currentUser: { name: 'Alice' } } // properties
);
Using useValue ā Providing a Mock Object
The simplest provider override. You provide a plain object that satisfies the service's interface:
const mockLogger = {
log: jasmine.createSpy('log'),
error: jasmine.createSpy('error'),
warn: jasmine.createSpy('warn'),
};
TestBed.configureTestingModule({
providers: [
{ provide: LoggerService, useValue: mockLogger },
],
});
When to use: When you need a lightweight mock with only the methods your test actually calls. You don't need to implement the entire service interface ā just the parts under test.
Using useClass ā Providing a Mock Class
For more complex mocks where you need real behavior or state management:
class MockAuthService {
private _isLoggedIn = false;
login(credentials: Credentials): Observable<boolean> {
this._isLoggedIn = true;
return of(true);
}
logout(): void {
this._isLoggedIn = false;
}
get isLoggedIn(): boolean {
return this._isLoggedIn;
}
}
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useClass: MockAuthService },
],
});
When to use: When the mock needs internal state management, complex method interactions, or when multiple tests depend on realistic service behavior.
Using useFactory ā Dynamic Mock Creation
For mocks that need runtime configuration or dependencies:
TestBed.configureTestingModule({
providers: [
{
provide: ConfigService,
useFactory: () => {
const mock = jasmine.createSpyObj('ConfigService', ['get']);
mock.get.and.callFake((key: string) => {
const config: Record<string, string> = {
apiUrl: 'http://test-api.com',
environment: 'test',
};
return config[key];
});
return mock;
},
},
],
});
Spying on Real Methods with spyOn
When you want to keep the real service but intercept specific methods:
it('should call the analytics service on form submit', () => {
const analyticsService = TestBed.inject(AnalyticsService);
spyOn(analyticsService, 'trackEvent');
component.onSubmit();
expect(analyticsService.trackEvent).toHaveBeenCalledWith(
'form_submitted',
{ formName: 'contact' }
);
});
Component Stubs ā Mocking Child Components
When testing a parent component, you don't want its child components to run real logic. Create stubs:
// Stub for a complex child component
@Component({
selector: 'app-user-avatar',
template: '<div class="mock-avatar">{{ userId }}</div>',
})
class MockUserAvatarComponent {
@Input() userId = '';
@Input() size: 'sm' | 'md' | 'lg' = 'md';
}
TestBed.configureTestingModule({
declarations: [
ParentComponent,
MockUserAvatarComponent, // Use stub instead of real component
],
});
Alternatively, use NO_ERRORS_SCHEMA to ignore unknown elements (less precise but faster):
TestBed.configureTestingModule({
declarations: [ParentComponent],
schemas: [NO_ERRORS_SCHEMA], // Ignores unknown elements/attributes
});
Mocking Comparison Table
| Technique | Best For | Complexity |
|---|---|---|
jasmine.createSpyObj | Services with multiple methods | Low |
useValue | Simple mock objects | Low |
useClass | Mocks needing internal state | Medium |
useFactory | Dynamic or configurable mocks | Medium |
spyOn | Intercepting real service methods | Low |
| Component stubs | Isolating parent from child components | Medium |
NO_ERRORS_SCHEMA | Quick-and-dirty child component mocking | Low |
Testing Services
Services are the simplest Angular building blocks to test because they're plain classes (no DOM involved).
Simple Service
// calculator.service.ts
@Injectable({ providedIn: 'root' })
export class CalculatorService {
add(a: number, b: number): number {
return a + b;
}
divide(a: number, b: number): number {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
}
// calculator.service.spec.ts
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CalculatorService);
});
describe('add', () => {
it('should add two positive numbers', () => {
expect(service.add(3, 5)).toBe(8);
});
it('should handle negative numbers', () => {
expect(service.add(-3, -5)).toBe(-8);
});
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(service.divide(10, 2)).toBe(5);
});
it('should throw when dividing by zero', () => {
expect(() => service.divide(10, 0)).toThrowError('Division by zero');
});
});
});
Service with Dependencies
When a service depends on another service, you mock the dependency:
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
constructor(
private http: HttpClient,
private logger: LoggerService
) {}
getUser(id: string): Observable<User> {
this.logger.log(`Fetching user ${id}`);
return this.http.get<User>(`/api/users/${id}`);
}
}
// user.service.spec.ts
describe('UserService', () => {
let service: UserService;
let httpMock: HttpTestingController;
let mockLogger: jasmine.SpyObj<LoggerService>;
beforeEach(() => {
mockLogger = jasmine.createSpyObj('LoggerService', ['log', 'error']);
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
UserService,
{ provide: LoggerService, useValue: mockLogger },
],
});
service = TestBed.inject(UserService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // Ensure no outstanding HTTP requests
});
it('should fetch a user by ID', () => {
const mockUser: User = { id: '1', name: 'Alice', email: 'alice@test.com' };
service.getUser('1').subscribe(user => {
expect(user).toEqual(mockUser);
});
// Expect a GET request to the correct URL
const req = httpMock.expectOne('/api/users/1');
expect(req.request.method).toBe('GET');
// Respond with mock data
req.flush(mockUser);
// Verify logging was called
expect(mockLogger.log).toHaveBeenCalledWith('Fetching user 1');
});
it('should handle 404 errors', () => {
service.getUser('999').subscribe({
next: () => fail('Expected an error'),
error: (error) => {
expect(error.status).toBe(404);
},
});
const req = httpMock.expectOne('/api/users/999');
req.flush('Not found', { status: 404, statusText: 'Not Found' });
});
});
The httpMock.verify() call in afterEach is critical ā it ensures that every expected HTTP request was actually made and that no unexpected requests slipped through. This catches bugs where a service makes extra or missing API calls.
Testing Components
Testing Inputs and Outputs
// star-rating.component.ts
@Component({
selector: 'app-star-rating',
template: `
<div class="stars">
<span
*ngFor="let star of stars; let i = index"
class="star"
[class.filled]="i < rating"
(click)="onStarClick(i + 1)"
>ā
</span>
</div>
`,
})
export class StarRatingComponent {
@Input() rating = 0;
@Input() maxStars = 5;
@Output() ratingChange = new EventEmitter<number>();
get stars(): number[] {
return Array(this.maxStars).fill(0);
}
onStarClick(value: number): void {
this.rating = value;
this.ratingChange.emit(value);
}
}
// star-rating.component.spec.ts
describe('StarRatingComponent', () => {
let component: StarRatingComponent;
let fixture: ComponentFixture<StarRatingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [StarRatingComponent],
}).compileComponents();
fixture = TestBed.createComponent(StarRatingComponent);
component = fixture.componentInstance;
});
it('should render 5 stars by default', () => {
fixture.detectChanges();
const stars = fixture.debugElement.queryAll(By.css('.star'));
expect(stars.length).toBe(5);
});
it('should render custom number of stars', () => {
component.maxStars = 10;
fixture.detectChanges();
const stars = fixture.debugElement.queryAll(By.css('.star'));
expect(stars.length).toBe(10);
});
it('should fill stars according to rating', () => {
component.rating = 3;
fixture.detectChanges();
const filledStars = fixture.debugElement.queryAll(By.css('.star.filled'));
expect(filledStars.length).toBe(3);
});
it('should emit ratingChange when a star is clicked', () => {
fixture.detectChanges();
spyOn(component.ratingChange, 'emit');
const thirdStar = fixture.debugElement.queryAll(By.css('.star'))[2];
thirdStar.nativeElement.click();
expect(component.ratingChange.emit).toHaveBeenCalledWith(3);
expect(component.rating).toBe(3);
});
});
Shallow vs Deep Testing
Shallow testing isolates the component by stubbing child components. Deep testing renders the full component tree.
// Shallow ā stubs child components
TestBed.configureTestingModule({
declarations: [ParentComponent],
schemas: [NO_ERRORS_SCHEMA], // Ignores child component selectors
});
// Deep ā includes real child components
TestBed.configureTestingModule({
declarations: [ParentComponent, ChildComponent, GrandchildComponent],
imports: [SharedModule],
});
Recommendation: Use shallow testing for most component tests. It's faster, simpler, and isolates the component under test. Use deep testing only when you specifically need to test the interaction between parent and child components.
Testing Directives
Attribute Directive
// highlight.directive.ts
@Directive({
selector: '[appHighlight]',
})
export class HighlightDirective {
@Input() appHighlight = 'yellow';
@Input() defaultColor = '';
@HostListener('mouseenter') onMouseEnter() {
this.highlight(this.appHighlight || this.defaultColor || 'yellow');
}
@HostListener('mouseleave') onMouseLeave() {
this.highlight('');
}
private highlight(color: string) {
this.el.nativeElement.style.backgroundColor = color;
}
constructor(private el: ElementRef) {}
}
To test a directive, you need a host component ā a simple test component that uses the directive:
// highlight.directive.spec.ts
@Component({
template: `
<p appHighlight="cyan">Highlighted text</p>
<p appHighlight>Default highlight</p>
<p>No highlight</p>
`,
})
class TestHostComponent {}
describe('HighlightDirective', () => {
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [TestHostComponent, HighlightDirective],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
fixture.detectChanges();
});
it('should highlight with cyan on mouse enter', () => {
const paragraph = fixture.debugElement.query(By.css('p'));
paragraph.triggerEventHandler('mouseenter', null);
expect(paragraph.nativeElement.style.backgroundColor).toBe('cyan');
});
it('should remove highlight on mouse leave', () => {
const paragraph = fixture.debugElement.query(By.css('p'));
paragraph.triggerEventHandler('mouseenter', null);
expect(paragraph.nativeElement.style.backgroundColor).toBe('cyan');
paragraph.triggerEventHandler('mouseleave', null);
expect(paragraph.nativeElement.style.backgroundColor).toBe('');
});
it('should use default yellow for unspecified color', () => {
const paragraphs = fixture.debugElement.queryAll(By.css('p'));
const defaultParagraph = paragraphs[1]; // Second <p> with no color value
defaultParagraph.triggerEventHandler('mouseenter', null);
expect(defaultParagraph.nativeElement.style.backgroundColor).toBe('yellow');
});
});
The test host component pattern is standard practice for directive testing. You create a lightweight component whose sole purpose is to host the directive, allowing you to test the directive's behavior in a realistic DOM context.
Testing Pipes
Pipes are the easiest Angular building blocks to test because they're pure functions (in most cases).
Pure Pipe
// truncate.pipe.ts
@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 50, trail = '...'): string {
if (!value) return '';
if (value.length <= limit) return value;
return value.substring(0, limit) + trail;
}
}
// truncate.pipe.spec.ts
describe('TruncatePipe', () => {
let pipe: TruncatePipe;
beforeEach(() => {
pipe = new TruncatePipe(); // No TestBed needed for pure pipes!
});
it('should return empty string for null/undefined', () => {
expect(pipe.transform(null as any)).toBe('');
expect(pipe.transform(undefined as any)).toBe('');
});
it('should return the original string if shorter than limit', () => {
expect(pipe.transform('Hello', 10)).toBe('Hello');
});
it('should truncate long strings with ellipsis', () => {
const longText = 'This is a very long piece of text that should be truncated';
const result = pipe.transform(longText, 20);
expect(result).toBe('This is a very long ...');
expect(result.length).toBe(23); // 20 chars + '...'
});
it('should use custom trail text', () => {
const result = pipe.transform('Hello World Test', 11, ' [more]');
expect(result).toBe('Hello World [more]');
});
it('should use default limit of 50', () => {
const text = 'A'.repeat(60);
const result = pipe.transform(text);
expect(result).toBe('A'.repeat(50) + '...');
});
});
Notice that for pure pipes, you don't even need TestBed. Just instantiate the pipe class directly and test its transform method. This makes pipe tests extremely fast and simple.
Testing Reactive Forms
Reactive forms are one of Angular's most powerful features ā and they need thorough testing.
// registration.component.ts
@Component({
selector: 'app-registration',
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" placeholder="Email" />
<div *ngIf="form.get('email')?.errors?.['required'] && form.get('email')?.touched"
class="error">
Email is required
</div>
<div *ngIf="form.get('email')?.errors?.['email']"
class="error">
Invalid email format
</div>
<input formControlName="password" type="password" placeholder="Password" />
<div *ngIf="form.get('password')?.errors?.['minlength']"
class="error">
Password must be at least 8 characters
</div>
<input formControlName="confirmPassword" type="password" placeholder="Confirm Password" />
<div *ngIf="form.errors?.['passwordMismatch']"
class="error">
Passwords do not match
</div>
<button type="submit" [disabled]="form.invalid">Register</button>
</form>
`,
})
export class RegistrationComponent {
form = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [Validators.required, Validators.minLength(8)]),
confirmPassword: new FormControl('', [Validators.required]),
}, { validators: this.passwordMatchValidator });
constructor(private authService: AuthService) {}
passwordMatchValidator(group: AbstractControl): ValidationErrors | null {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
}
onSubmit(): void {
if (this.form.valid) {
this.authService.register(this.form.value);
}
}
}
// registration.component.spec.ts
describe('RegistrationComponent', () => {
let component: RegistrationComponent;
let fixture: ComponentFixture<RegistrationComponent>;
let mockAuthService: jasmine.SpyObj<AuthService>;
beforeEach(async () => {
mockAuthService = jasmine.createSpyObj('AuthService', ['register']);
await TestBed.configureTestingModule({
declarations: [RegistrationComponent],
imports: [ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: mockAuthService },
],
}).compileComponents();
fixture = TestBed.createComponent(RegistrationComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the form with three controls', () => {
expect(component.form.contains('email')).toBeTruthy();
expect(component.form.contains('password')).toBeTruthy();
expect(component.form.contains('confirmPassword')).toBeTruthy();
});
it('should mark email as invalid when empty', () => {
const emailControl = component.form.get('email')!;
emailControl.setValue('');
expect(emailControl.valid).toBeFalsy();
expect(emailControl.errors?.['required']).toBeTruthy();
});
it('should mark email as invalid for bad format', () => {
const emailControl = component.form.get('email')!;
emailControl.setValue('not-an-email');
expect(emailControl.errors?.['email']).toBeTruthy();
});
it('should mark email as valid for proper format', () => {
const emailControl = component.form.get('email')!;
emailControl.setValue('alice@example.com');
expect(emailControl.valid).toBeTruthy();
});
it('should enforce minimum password length', () => {
const passwordControl = component.form.get('password')!;
passwordControl.setValue('short');
expect(passwordControl.errors?.['minlength']).toBeTruthy();
passwordControl.setValue('longenough123');
expect(passwordControl.valid).toBeTruthy();
});
it('should show password mismatch error', () => {
component.form.get('password')!.setValue('securepass123');
component.form.get('confirmPassword')!.setValue('differentpass');
expect(component.form.errors?.['passwordMismatch']).toBeTruthy();
});
it('should not show mismatch when passwords match', () => {
component.form.get('password')!.setValue('securepass123');
component.form.get('confirmPassword')!.setValue('securepass123');
expect(component.form.errors).toBeNull();
});
it('should disable submit button when form is invalid', () => {
fixture.detectChanges();
const button = fixture.debugElement.query(By.css('button')).nativeElement;
expect(button.disabled).toBeTruthy();
});
it('should enable submit button when form is valid', () => {
component.form.get('email')!.setValue('alice@example.com');
component.form.get('password')!.setValue('securepass123');
component.form.get('confirmPassword')!.setValue('securepass123');
fixture.detectChanges();
const button = fixture.debugElement.query(By.css('button')).nativeElement;
expect(button.disabled).toBeFalsy();
});
it('should call authService.register on valid submit', () => {
component.form.setValue({
email: 'alice@example.com',
password: 'securepass123',
confirmPassword: 'securepass123',
});
component.onSubmit();
expect(mockAuthService.register).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'securepass123',
confirmPassword: 'securepass123',
});
});
it('should NOT call authService.register when form is invalid', () => {
component.form.setValue({
email: '',
password: '',
confirmPassword: '',
});
component.onSubmit();
expect(mockAuthService.register).not.toHaveBeenCalled();
});
});
Notice how we test both the form model (control values, validation state) and the DOM (button disabled state, error messages). This dual approach ensures that the form's reactive logic and its template binding both work correctly.
Testing HTTP Calls
Angular provides HttpClientTestingModule and HttpTestingController for testing HTTP calls without making real network requests.
// product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
private apiUrl = '/api/products';
constructor(private http: HttpClient) {}
getProducts(): Observable<Product[]> {
return this.http.get<Product[]>(this.apiUrl);
}
createProduct(product: Partial<Product>): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product);
}
updateProduct(id: string, updates: Partial<Product>): Observable<Product> {
return this.http.put<Product>(`${this.apiUrl}/${id}`, updates);
}
deleteProduct(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
// product.service.spec.ts
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
const mockProducts: Product[] = [
{ id: '1', name: 'Widget', price: 9.99 },
{ id: '2', name: 'Gadget', price: 19.99 },
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ProductService],
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
// Verify that no unmatched requests are outstanding
httpMock.verify();
});
describe('getProducts', () => {
it('should return a list of products', () => {
service.getProducts().subscribe(products => {
expect(products.length).toBe(2);
expect(products).toEqual(mockProducts);
});
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
});
it('should handle server errors', () => {
service.getProducts().subscribe({
next: () => fail('Expected an error'),
error: (error) => {
expect(error.status).toBe(500);
},
});
const req = httpMock.expectOne('/api/products');
req.flush('Server Error', {
status: 500,
statusText: 'Internal Server Error',
});
});
});
describe('createProduct', () => {
it('should POST a new product', () => {
const newProduct = { name: 'New Widget', price: 14.99 };
const createdProduct = { id: '3', ...newProduct };
service.createProduct(newProduct).subscribe(product => {
expect(product).toEqual(createdProduct);
});
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual(newProduct);
req.flush(createdProduct);
});
});
describe('deleteProduct', () => {
it('should DELETE the product by ID', () => {
service.deleteProduct('1').subscribe(() => {
// Success ā no response body to check
});
const req = httpMock.expectOne('/api/products/1');
expect(req.request.method).toBe('DELETE');
req.flush(null);
});
});
});
Key points about HTTP testing:
httpMock.expectOne(url)asserts that exactly one request was made to that URL. If zero or multiple requests match, the test fails.req.flush(data)simulates the server responding with the given data.req.flush(errorBody, { status, statusText })simulates an HTTP error.httpMock.verify()inafterEachcatches any unexpected or unfulfilled requests.
Testing Observables and RxJS
Angular is heavily built on RxJS. Here's how to test common observable patterns:
Testing with subscribe
it('should emit filtered values', () => {
const source$ = of(1, 2, 3, 4, 5).pipe(
filter(n => n % 2 === 0)
);
const results: number[] = [];
source$.subscribe(value => results.push(value));
expect(results).toEqual([2, 4]);
});
Testing BehaviorSubject and State
// counter.store.ts
@Injectable({ providedIn: 'root' })
export class CounterStore {
private countSubject = new BehaviorSubject<number>(0);
count$ = this.countSubject.asObservable();
increment(): void {
this.countSubject.next(this.countSubject.value + 1);
}
decrement(): void {
this.countSubject.next(this.countSubject.value - 1);
}
reset(): void {
this.countSubject.next(0);
}
}
// counter.store.spec.ts
describe('CounterStore', () => {
let store: CounterStore;
beforeEach(() => {
TestBed.configureTestingModule({});
store = TestBed.inject(CounterStore);
});
it('should start at 0', (done) => {
store.count$.subscribe(count => {
expect(count).toBe(0);
done();
});
});
it('should increment the count', () => {
store.increment();
store.increment();
let currentCount: number | undefined;
store.count$.subscribe(count => currentCount = count);
expect(currentCount).toBe(2);
});
it('should reset to zero', () => {
store.increment();
store.increment();
store.increment();
store.reset();
let currentCount: number | undefined;
store.count$.subscribe(count => currentCount = count);
expect(currentCount).toBe(0);
});
});
Using fakeAsync with Observables
For observables that involve time (debounce, delay, interval):
it('should debounce search input', fakeAsync(() => {
const searchResults: string[][] = [];
component.searchResults$.subscribe(results => {
searchResults.push(results);
});
// Type 'ang' quickly
component.searchControl.setValue('a');
tick(100);
component.searchControl.setValue('an');
tick(100);
component.searchControl.setValue('ang');
// Wait for debounce (300ms)
tick(300);
// Only one search should have been triggered (for 'ang')
expect(mockSearchService.search).toHaveBeenCalledTimes(1);
expect(mockSearchService.search).toHaveBeenCalledWith('ang');
}));
Testing Component Interaction
Parent-Child Communication
// parent.component.ts
@Component({
selector: 'app-parent',
template: `
<h1>{{ title }}</h1>
<app-child
[message]="parentMessage"
(notify)="onChildNotify($event)"
></app-child>
<p *ngIf="childMessage">Child says: {{ childMessage }}</p>
`,
})
export class ParentComponent {
title = 'Parent Component';
parentMessage = 'Hello from parent';
childMessage = '';
onChildNotify(message: string): void {
this.childMessage = message;
}
}
// parent.component.spec.ts (deep test ā includes real child)
describe('ParentComponent (deep)', () => {
let fixture: ComponentFixture<ParentComponent>;
let component: ParentComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ParentComponent, ChildComponent],
}).compileComponents();
fixture = TestBed.createComponent(ParentComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should pass message to child component', () => {
const childDebug = fixture.debugElement.query(By.directive(ChildComponent));
const childComponent = childDebug.componentInstance as ChildComponent;
expect(childComponent.message).toBe('Hello from parent');
});
it('should display child notification in parent', () => {
const childDebug = fixture.debugElement.query(By.directive(ChildComponent));
const childComponent = childDebug.componentInstance as ChildComponent;
// Trigger the child's output
childComponent.notify.emit('Hello from child!');
fixture.detectChanges();
const paragraph = fixture.debugElement.query(By.css('p'));
expect(paragraph.nativeElement.textContent).toContain('Hello from child!');
});
});
Testing Routing
Testing Router Navigation
// app.component.spec.ts
describe('AppComponent routing', () => {
let router: Router;
let fixture: ComponentFixture<AppComponent>;
let location: Location;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule.withRoutes([
{ path: '', component: HomeComponent },
{ path: 'about', component: AboutComponent },
{ path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuard] },
]),
],
declarations: [AppComponent, HomeComponent, AboutComponent, DashboardComponent],
providers: [
{ provide: AuthGuard, useValue: { canActivate: () => true } },
],
}).compileComponents();
router = TestBed.inject(Router);
location = TestBed.inject(Location);
fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
});
it('should navigate to about page', fakeAsync(() => {
router.navigate(['/about']);
tick();
expect(location.path()).toBe('/about');
}));
it('should navigate to home by default', fakeAsync(() => {
router.navigate(['']);
tick();
expect(location.path()).toBe('/');
}));
});
Testing Route Guards
// auth.guard.spec.ts
describe('AuthGuard', () => {
let guard: AuthGuard;
let mockAuthService: jasmine.SpyObj<AuthService>;
let mockRouter: jasmine.SpyObj<Router>;
beforeEach(() => {
mockAuthService = jasmine.createSpyObj('AuthService', [], {
isLoggedIn: false,
});
mockRouter = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
AuthGuard,
{ provide: AuthService, useValue: mockAuthService },
{ provide: Router, useValue: mockRouter },
],
});
guard = TestBed.inject(AuthGuard);
});
it('should allow access when user is logged in', () => {
// Update the mock property to return true
Object.defineProperty(mockAuthService, 'isLoggedIn', { get: () => true });
const result = guard.canActivate();
expect(result).toBeTruthy();
expect(mockRouter.navigate).not.toHaveBeenCalled();
});
it('should redirect to login when user is not logged in', () => {
const result = guard.canActivate();
expect(result).toBeFalsy();
expect(mockRouter.navigate).toHaveBeenCalledWith(['/login']);
});
});
Async Testing Strategies
Angular provides three approaches for testing asynchronous code. Understanding when to use each is critical for writing reliable tests.
fakeAsync + tick
The most powerful and commonly used approach. fakeAsync creates a simulated async zone where you control time:
it('should show loading spinner then data', fakeAsync(() => {
mockService.getData.and.returnValue(of(mockData).pipe(delay(1000)));
component.loadData();
fixture.detectChanges();
// Immediately after calling loadData
expect(fixture.nativeElement.querySelector('.spinner')).toBeTruthy();
// Fast-forward 1 second
tick(1000);
fixture.detectChanges();
// Spinner should be gone, data should be visible
expect(fixture.nativeElement.querySelector('.spinner')).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Mock Data');
}));
Use when: You need to control time progression ā debounce, delay, setTimeout, setInterval, or any time-based observable operation.
waitForAsync (formerly async)
Wraps the test in a zone that tracks all async operations and waits for them to complete:
it('should load data from the service', waitForAsync(() => {
mockService.getData.and.returnValue(of(mockData));
component.loadData();
fixture.detectChanges();
fixture.whenStable().then(() => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Mock Data');
});
}));
Use when: You have async operations that resolve on their own (HTTP calls, promises) and you don't need to control time.
done Callback
The oldest approach, borrowed from Jasmine/Mocha. Pass a done function to the test and call it when assertions are complete:
it('should emit the correct value', (done) => {
service.getObservableData().subscribe(value => {
expect(value).toBe('expected');
done();
});
});
Use when: Testing simple observable emissions or promises where the other approaches feel like overkill.
Comparison Table
| Approach | Controls Time | Auto-Waits | Best For |
|---|---|---|---|
fakeAsync + tick | ā | ā | Debounce, delay, timers |
waitForAsync | ā | ā | HTTP calls, promises |
done callback | ā | ā | Simple observables |
Common Mistakes to Avoid
Mistake 1: Missing detectChanges()
// ā Forgetting detectChanges ā DOM won't reflect changes
component.title = 'Updated Title';
expect(fixture.nativeElement.textContent).toContain('Updated Title'); // FAILS
// ā
Always call detectChanges after changing state
component.title = 'Updated Title';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Updated Title'); // PASSES
This is the #1 mistake in Angular testing. Without detectChanges(), Angular's template bindings don't update, so the DOM still shows the old values.
Mistake 2: Not Destroying Fixtures
// ā Leaking fixtures can cause test interference
afterEach(() => {
// Nothing here ā fixture stays alive
});
// ā
Destroy the fixture after each test
afterEach(() => {
fixture.destroy();
});
While TestBed typically cleans up between test suites, explicitly destroying fixtures prevents DOM leaks in complex test suites with many components.
Mistake 3: Testing Private Methods Directly
// ā Accessing private methods ā breaks encapsulation
it('should calculate tax correctly', () => {
const result = (component as any).calculateTax(100);
expect(result).toBe(18);
});
// ā
Test through the public API
it('should display the correct total with tax', () => {
component.price = 100;
fixture.detectChanges();
const total = fixture.nativeElement.querySelector('.total');
expect(total.textContent).toContain('118');
});
If a private method needs testing, it's a sign that it should either be extracted into a service (making it public and injectable) or tested indirectly through the public methods that call it.
Mistake 4: Not Using httpMock.verify()
// ā Missing verify ā won't catch unexpected HTTP requests
afterEach(() => {
// Nothing here
});
// ā
Always verify no outstanding requests
afterEach(() => {
httpMock.verify();
});
Without httpMock.verify(), your tests won't catch scenarios where a service makes unexpected API calls or fails to make expected ones.
Best Practices Summary
| Practice | Why |
|---|---|
| Configure TestBed minimally | Faster tests, clearer dependencies |
Use createSpyObj for service mocks | Quick, type-safe, tracks calls |
Use NO_ERRORS_SCHEMA for shallow tests | Avoid declaring every child component |
Always call detectChanges() | Angular won't update DOM without it |
Call httpMock.verify() in afterEach | Catches unexpected/missing HTTP requests |
| Test behavior, not implementation | Tests survive refactoring |
Use fakeAsync for time-dependent tests | Deterministic time control |
Destroy fixtures in afterEach | Prevents DOM leaks |
Interview Preparation
Common Interview Questions
Q: What is TestBed and why is it important?
TestBed is Angular's primary testing utility that creates a dynamically-configured Angular module for testing. It lets you declare components, import modules, and provide services (including mocks) in a controlled, isolated environment. Without TestBed, you cannot test components that use Angular features like dependency injection, template compilation, or change detection.
Q: What is the difference between fakeAsync and waitForAsync?
fakeAsynccreates a simulated async zone where you manually control time usingtick(). It's ideal for testing debounce, delay, or timer-based logic.waitForAsyncwraps the test in a zone that automatically waits for all async operations to complete. It's simpler but gives you no control over timing. UsefakeAsyncwhen timing matters, andwaitForAsyncwhen you just need to wait for async operations to finish.
Q: How do you mock a service in Angular tests?
Angular provides several ways: (1)
jasmine.createSpyObjto create a mock with spy methods, then provide it with{ provide: Service, useValue: mock }. (2)useClassto provide a full mock class with internal state. (3)useFactoryfor dynamic mock creation. (4)spyOnto intercept methods on a real service. The most common pattern iscreateSpyObjwithuseValuefor simple services, anduseClassfor services that need stateful behavior.
Q: What is fixture.detectChanges() and why do you need to call it manually?
detectChanges()triggers Angular's change detection mechanism, which updates the component's DOM to reflect the current state of its properties. In tests, Angular intentionally does not run change detection automatically ā this gives you precise control over when the DOM updates, allowing you to test intermediate states and verify that bindings work correctly. You must call it after setting inputs, triggering events, or modifying component properties.
Q: Explain shallow vs deep component testing.
Shallow testing isolates the component by stubbing or ignoring its child components (using
NO_ERRORS_SCHEMAor component stubs). This tests the component's own logic without interference from child components. Deep testing renders the full component tree, including real child components. Use shallow testing for most component tests (faster, more isolated), and deep testing only when you specifically need to test parent-child interaction.
Q: How do you test HTTP calls in Angular?
Import
HttpClientTestingModuleinstead ofHttpClientModule, and injectHttpTestingController. Call the service method that makes the HTTP request, then usehttpMock.expectOne(url)to get a reference to the pending request. Assert the request method and body withreq.request.methodandreq.request.body. Respond withreq.flush(data)for success orreq.flush(error, { status, statusText })for errors. Always callhttpMock.verify()inafterEachto ensure no unexpected requests remain.
Conclusion
Angular testing may seem daunting at first, but the framework's built-in testing architecture is actually one of its greatest strengths. Once you understand the core patterns ā TestBed configuration, fixture management, change detection, and dependency mocking ā every type of test follows a predictable structure.
The key principles to remember:
- TestBed is your foundation ā configure it with only what your test needs
- Mock at the boundaries ā real business logic, fake I/O (HTTP, storage, external APIs)
- Use the right mocking technique ā
createSpyObjfor simple services,useClassfor stateful mocks,spyOnfor intercepting real methods - Always call
detectChanges()ā Angular won't update the DOM without it - Choose the right async strategy ā
fakeAsyncfor time control,waitForAsyncfor simple async,donefor basic observables - Test behavior, not implementation ā test what users see and what APIs receive
With these patterns in your toolkit, you can confidently test components, services, pipes, directives, forms, HTTP calls, routing, and every other Angular building block. Testing isn't just about catching bugs ā it's about building confidence in your code and enabling fearless refactoring.
š” tip[!TIP] When writing a new Angular feature, create the test file first (
*.spec.ts). Use TestBed to configure the module, write a failing test for the expected behavior, then implement the code to make it pass. This test-first approach naturally produces better APIs and more maintainable code.
š§ Test Your Knowledge
Now that you've learned the concepts, let's see if you can apply them! Take this quick quiz to test your understanding.