Angular: Zero to Senior · Lesson 9 of 11

Performance & Testing

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:

  1. An @Input() reference changes
  2. An event handler in the component fires
  3. An Observable emits via the async pipe
  4. You manually trigger it with ChangeDetectorRef
TYPESCRIPT
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:

TYPESCRIPT
// ❌ 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

TYPESCRIPT
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:

TYPESCRIPT
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:

HTML
@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:

HTML
<!-- 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:

HTML
@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:

TYPESCRIPT
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

TYPESCRIPT
// 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

TYPESCRIPT
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

TYPESCRIPT
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

TYPESCRIPT
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

Bash
ng test                     # run once, watch mode
ng test --watch=false       # run once, CI mode
ng test --code-coverage     # generate coverage report

Quick 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 config

Testing:

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'))