Angular Performance & Testing
Optimize Angular apps with OnPush change detection, lazy loading, trackBy, and deferrable views. Unit test components and services with TestBed and Jest.
A fast Angular app uses OnPush change detection, lazy loads routes, and defers heavy components. A reliable Angular app has unit tests for services and integration tests for components. This lesson covers both.
Change Detection: Default vs OnPush
By default, Angular checks every component on every event. With OnPush, a component only re-checks when:
- An
@Input()reference changes - An event handler in the component fires
- An Observable emits via the
asyncpipe - You manually trigger it with
ChangeDetectorRef
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
@Component({
selector: 'app-product-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // ← opt-in
template: `
<div class="card">
<h3>{{ product.name }}</h3>
<p>{{ product.price | currency }}</p>
</div>
`,
})
export class ProductCardComponent {
@Input() product!: Product;
}Rule: Mutating an object will NOT trigger re-render with OnPush — you must replace the reference:
// ❌ Won't trigger OnPush re-render
this.product.name = 'New Name';
// ✅ New object reference — triggers re-render
this.product = { ...this.product, name: 'New Name' };Manually triggering change detection
import { ChangeDetectorRef, inject } from '@angular/core';
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class MyComponent {
private cdr = inject(ChangeDetectorRef);
updateFromExternalEvent(): void {
// after state change from outside Angular zone
this.cdr.markForCheck();
}
}Angular Signals (Angular 16+) — Reactive Without Observables
Signals integrate natively with change detection — any component reading a signal is automatically updated when it changes:
import { Component, signal, computed, effect } from '@angular/core';
@Component({
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count() }}</p>
<p>Double: {{ double() }}</p>
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
count = signal(0);
double = computed(() => this.count() * 2);
constructor() {
effect(() => {
console.log('count changed:', this.count());
});
}
increment(): void {
this.count.update(c => c + 1);
// OR: this.count.set(this.count() + 1);
}
}trackBy in *ngFor
Without trackBy, Angular recreates all DOM nodes when the array reference changes. With trackBy, it reuses existing nodes:
@for (item of items; track item.id) {
<app-product-card [product]="item" />
}The track expression in the new @for syntax replaces the old trackBy function.
Deferrable Views (@defer) — Angular 17+
Defer loading heavy components until they're needed:
<!-- Load component only when it becomes visible in viewport -->
@defer (on viewport) {
<app-heavy-chart [data]="data" />
} @loading {
<p>Loading chart...</p>
} @error {
<p>Failed to load chart</p>
} @placeholder {
<div class="chart-skeleton" style="height: 300px"></div>
}Other triggers:
@defer (on idle) { ... } <!-- when browser is idle -->
@defer (on interaction) { ... } <!-- when user clicks/hovers -->
@defer (when isLoggedIn) { ... } <!-- when condition is true -->
@defer (on timer(3000)) { ... } <!-- after 3 seconds -->Lazy Loading Routes (Performance Essential)
Already covered in the routing lesson — but it's the single highest-impact performance optimization. Every feature should be lazy-loaded unless it's part of the critical path:
export const routes: Routes = [
{ path: '', component: HomeComponent }, // critical
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes) },
{ path: 'reports', loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent) },
];Unit Testing — Services
// cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartService } from './cart.service';
describe('CartService', () => {
let service: CartService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(CartService);
});
it('should start with empty cart', () => {
expect(service.getItems().length).toBe(0);
expect(service.total).toBe(0);
});
it('should add an item', () => {
service.add({ productId: 1, name: 'Widget', quantity: 1, price: 9.99 });
expect(service.getItems().length).toBe(1);
expect(service.total).toBeCloseTo(9.99);
});
it('should increase quantity for duplicate product', () => {
service.add({ productId: 1, name: 'Widget', quantity: 1, price: 9.99 });
service.add({ productId: 1, name: 'Widget', quantity: 2, price: 9.99 });
expect(service.getItems()[0].quantity).toBe(3);
});
});Unit Testing — HTTP Services
import { TestBed } from '@angular/core/testing';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { ProductService } from './product.service';
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
],
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // ensure no pending requests
it('should fetch all products', () => {
const mockProducts: Product[] = [
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 24.99 },
];
service.getAll().subscribe(products => {
expect(products.length).toBe(2);
expect(products[0].name).toBe('Widget');
});
const req = httpMock.expectOne('/api/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
});
it('should handle errors', () => {
service.getAll().subscribe({
error: (err) => expect(err.message).toContain('Server error'),
});
httpMock.expectOne('/api/products').flush(
{ message: 'Internal Server Error' },
{ status: 500, statusText: 'Internal Server Error' },
);
});
});Unit Testing — Components
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ProductCardComponent } from './product-card.component';
describe('ProductCardComponent', () => {
let component: ProductCardComponent;
let fixture: ComponentFixture<ProductCardComponent>;
const mockProduct: Product = { id: 1, name: 'Widget', price: 9.99, active: true };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductCardComponent], // standalone component
}).compileComponents();
fixture = TestBed.createComponent(ProductCardComponent);
component = fixture.componentInstance;
component.product = mockProduct;
fixture.detectChanges(); // trigger initial rendering
});
it('should display product name', () => {
const h3 = fixture.debugElement.query(By.css('h3'));
expect(h3.nativeElement.textContent).toContain('Widget');
});
it('should emit deleteRequested when delete button clicked', () => {
spyOn(component.deleteRequested, 'emit');
const btn = fixture.debugElement.query(By.css('[data-test="delete-btn"]'));
btn.nativeElement.click();
expect(component.deleteRequested.emit).toHaveBeenCalledWith(1);
});
});Testing with Mocked Services
import { of, throwError } from 'rxjs';
describe('ProductListComponent', () => {
const mockProductService = {
getAll: jasmine.createSpy('getAll').and.returnValue(of([
{ id: 1, name: 'Widget', price: 9.99 },
])),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductListComponent],
providers: [
{ provide: ProductService, useValue: mockProductService },
],
}).compileComponents();
});
it('should display products from service', () => {
fixture.detectChanges();
const items = fixture.debugElement.queryAll(By.css('.product-item'));
expect(items.length).toBe(1);
});
it('should show error when service fails', () => {
mockProductService.getAll.and.returnValue(throwError(() => new Error('Network error')));
fixture.detectChanges();
const error = fixture.debugElement.query(By.css('.error'));
expect(error.nativeElement.textContent).toContain('Network error');
});
});Run Tests
ng test # run once, watch mode
ng test --watch=false # run once, CI mode
ng test --code-coverage # generate coverage reportQuick Reference
Performance:
OnPush: changeDetection: ChangeDetectionStrategy.OnPush
Signals: signal(), computed(), effect()
track: @for (item of items; track item.id)
Defer: @defer (on viewport) { }
Lazy routes: loadChildren/loadComponent in route configTesting:
Service: TestBed.inject(Service)
HTTP mock: provideHttpClientTesting() + HttpTestingController
Component: TestBed.createComponent(C) + fixture.detectChanges()
Mock service: { provide: Service, useValue: mockObj }
Spy: spyOn(component.output, 'emit')
Query DOM: fixture.debugElement.query(By.css('selector'))Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.