<link rel="stylesheet" href="/assets/fonts/jetbrains-mono/jetbrains-mono.css" />
All posts

Testing and Debugging in Angular: A complete Guide to Jest, Jasmine/Karma, Cypress, Playwright, and Angular Devtools

Introduction: Why Testing and Debugging Make the Difference

Every Angular application that reaches production eventually faces the same question: "How do I know this change didn't break anything?". The serious answer isn't "I tried it by hand in the browser," but an automated test suite that runs in seconds, on every commit, without human intervention. At the same time, when something goes wrong — a component that re-renders too often, a memory leak, a route that won't load — you need targeted debugging tools to understand what is happening and where, instead of relying on scattered console.log calls.

In this guide we dive deep into three pillars of a mature Angular application's lifecycle:

  • Unit testing with Jasmine/Karma (the Angular CLI default) and with Jest (the faster, increasingly popular alternative in the modern JavaScript ecosystem).
  • E2E (end-to-end) testing with Cypress and Playwright, to validate complete user flows in a real browser.
  • Performance debugging with Angular DevTools, to find bottlenecks in change detection and rendering.

Every section includes ready-to-use code examples, drawn from real scenarios involving components, services, signals, and HTTP calls — the same building blocks found in any production Angular application.

Topics: #Angular #Testing #UnitTesting #Jest #Jasmine #Karma #Cypress #Playwright #E2ETesting #AngularDevTools #Debugging #Performance #WebDevelopment

The Testing Pyramid: Where to Invest Your Time

Before writing a single line of test code, it's worth having a clear mental model of the testing pyramid, originally proposed by Mike Cohn:

  • Base (wide): Unit tests. Fast (milliseconds), isolated, numerous. They test a function, component, or service in isolation.
  • Middle: Integration tests. Verify that multiple units collaborate correctly (e.g. a component with its real service, not mocked).
  • Tip (narrow): E2E tests. Slow (seconds/minutes), brittle if poorly written, but the only ones that validate the real user experience from login to checkout.

The most common mistake is inverting the pyramid: few unit tests and dozens of slow, brittle E2E tests that turn CI into a 40-minute nightmare. The goal of this guide is to give you the tools to build the pyramid the right way up: plenty of fast unit tests, a reasonable number of E2E tests on critical flows, and debugging tools for when something still slips past the tests.

Part 1: Unit Testing with Jest and Jasmine/Karma

1.1 — The default setup: Jasmine + Karma

When you create a project with ng new, Angular CLI automatically configures Jasmine as the assertion framework (describe, it, expect) and Karma as the test runner, which launches tests in a real browser (Chrome Headless by default) via karma.conf.js.

// karma.conf.js — configuration generated by Angular CLI
process.env.CHROME_BIN = require('puppeteer').executablePath();

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage'),
      require('@angular-devkit/build-angular/plugins/karma'),
    ],
    client: {
      jasmine: {
        // random: false disables random test order (useful while debugging)
        random: false,
      },
      clearContext: false,
    },
    coverageReporter: {
      dir: require('path').join(__dirname, './coverage/my-app'),
      subdir: '.',
      reporters: [{ type: 'html' }, { type: 'text-summary' }],
      // Minimum thresholds: the build fails if coverage drops below these values
      check: {
        global: {
          statements: 80,
          branches: 75,
          functions: 80,
          lines: 80,
        },
      },
    },
    reporters: ['progress', 'kjhtml'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['ChromeHeadless'],
    singleRun: true, // false locally for watch mode, true in CI
    restartOnFileChange: true,
  });
};

To run the tests: ng test locally (watch mode enabled) or ng test --no-watch --browsers=ChromeHeadless in CI, where you don't need an interactive browser and you want the process to exit with an exit code instead of staying in watch mode.

1.2 — Anatomy of a unit test: describe, it, expect

Every Jasmine test file follows the same structure: describe groups logically related tests, it (or its alias test) defines a single case, and expect performs the assertion.

// price-calculator.ts
export function applyDiscount(price: number, percentage: number): number {
  if (percentage < 0 || percentage > 100) {
    throw new Error('La percentuale di sconto deve essere tra 0 e 100');
  }
  return Number((price - (price * percentage) / 100).toFixed(2));
}

// price-calculator.spec.ts
import { applyDiscount } from './price-calculator';

describe('applyDiscount', () => {
  it('applica correttamente uno sconto del 20%', () => {
    expect(applyDiscount(100, 20)).toBe(80);
  });

  it('non modifica il prezzo con sconto 0%', () => {
    expect(applyDiscount(50, 0)).toBe(50);
  });

  it('arrotonda a due decimali', () => {
    expect(applyDiscount(19.99, 15)).toBe(16.99);
  });

  it('lancia un errore se la percentuale è negativa', () => {
    expect(() => applyDiscount(100, -5)).toThrowError(
      'La percentuale di sconto deve essere tra 0 e 100',
    );
  });

  it('lancia un errore se la percentuale supera 100', () => {
    expect(() => applyDiscount(100, 150)).toThrowError();
  });
});

This is a pure unit test: no Angular dependency, no TestBed, just a function and its assertions. It's the fastest kind of test to write, read, and run — and should be preferred whenever logic can be extracted from a component or service into a pure function that's testable in isolation.

1.3 — Testing an Angular service with TestBed

When logic depends on Angular's DI (Dependency Injection), you need TestBed to build a test environment with the same providers the real application would use — while replacing external dependencies (HTTP, storage, other services) with mocks.

// cart.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface CartItem {
  id: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly _items = signal([]);
  readonly items = this._items.asReadonly();

  readonly total = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0),
  );

  readonly itemCount = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0),
  );

  addItem(item: CartItem): void {
    const existing = this._items().find(i => i.id === item.id);
    if (existing) {
      this._items.update(items =>
        items.map(i => (i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i)),
      );
    } else {
      this._items.update(items => [...items, item]);
    }
  }

  removeItem(id: string): void {
    this._items.update(items => items.filter(i => i.id !== id));
  }

  clear(): void {
    this._items.set([]);
  }
}

// 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('parte con carrello vuoto', () => {
    expect(service.items()).toEqual([]);
    expect(service.total()).toBe(0);
  });

  it('aggiunge un articolo e calcola il totale', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 2 });
    expect(service.items().length).toBe(1);
    expect(service.total()).toBe(20);
  });

  it('incrementa la quantità se l\'articolo esiste già', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 1 });
    service.addItem({ id: 'p1', price: 10, quantity: 3 });
    expect(service.items().length).toBe(1);
    expect(service.items()[0].quantity).toBe(4);
  });

  it('rimuove un articolo per id', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 1 });
    service.addItem({ id: 'p2', price: 20, quantity: 1 });
    service.removeItem('p1');
    expect(service.items().length).toBe(1);
    expect(service.items()[0].id).toBe('p2');
  });

  it('itemCount somma le quantità, non il numero di righe', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 3 });
    service.addItem({ id: 'p2', price: 5, quantity: 2 });
    expect(service.itemCount()).toBe(5);
  });
});

Notice how this test also validates computed signals: no special configuration is needed, computeds recalculate synchronously and can be read like a normal function (service.total()) immediately after the state changes.

1.4 — Testing a standalone component with TestBed and ComponentFixture

Testing a component requires one more step than a service: you need to create a real instance of the component in the DOM via ComponentFixture, force a change detection cycle with fixture.detectChanges(), and then query the resulting DOM with DebugElement or native queries.

// quantity-selector.component.ts
import { Component, input, output, computed } from '@angular/core';

@Component({
  selector: 'app-quantity-selector',
  standalone: true,
  template: `
    -
    {{ value() }}
    +
  `,
})
export class QuantitySelectorComponent {
  value = input(1);
  min = input(1);
  max = input(99);
  valueChange = output();

  readonly canIncrement = computed(() => this.value() < this.max());

  increment(): void {
    if (this.value() < this.max()) {
      this.valueChange.emit(this.value() + 1);
    }
  }

  decrement(): void {
    if (this.value() > this.min()) {
      this.valueChange.emit(this.value() - 1);
    }
  }
}

// quantity-selector.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { QuantitySelectorComponent } from './quantity-selector.component';

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

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

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

  function getIncrementButton() {
    return fixture.debugElement.query(By.css('[data-testid="increment"]'));
  }

  function getQuantityText(): string {
    return fixture.debugElement.query(By.css('[data-testid="quantity-value"]')).nativeElement.textContent.trim();
  }

  it('mostra il valore iniziale', () => {
    fixture.componentRef.setInput('value', 3);
    fixture.detectChanges();
    expect(getQuantityText()).toBe('3');
  });

  it('emette valueChange con valore incrementato al click su +', () => {
    fixture.componentRef.setInput('value', 5);
    fixture.detectChanges();

    let emittedValue: number | undefined;
    component.valueChange.subscribe(v => (emittedValue = v));

    getIncrementButton().nativeElement.click();

    expect(emittedValue).toBe(6);
  });

  it('disabilita il pulsante + quando si raggiunge il massimo', () => {
    fixture.componentRef.setInput('value', 99);
    fixture.componentRef.setInput('max', 99);
    fixture.detectChanges();

    expect(getIncrementButton().nativeElement.disabled).toBeTrue();
  });

  it('non emette valueChange se il pulsante è disabilitato', () => {
    fixture.componentRef.setInput('value', 99);
    fixture.componentRef.setInput('max', 99);
    fixture.detectChanges();

    let called = false;
    component.valueChange.subscribe(() => (called = true));
    getIncrementButton().nativeElement.click();

    expect(called).toBeFalse();
  });
});

Using data-testid attributes instead of CSS classes to select elements in tests is a well-established best practice: it decouples tests from visual styling, so a CSS refactor doesn't break the test suite.

1.5 — Spying on and mocking dependencies with jasmine.createSpyObj

When a component depends on a service, we don't want its unit tests to invoke the service's real logic (which might in turn call a real HTTP API). Instead, we create a mock with only the necessary methods, controlling exactly what they return.

// checkout.component.spec.ts (excerpt)
import { of, throwError } from 'rxjs';
import { OrderService } from './order.service';
import { NotificationService } from '../shared/notification.service';

describe('CheckoutComponent', () => {
  let orderServiceSpy: jasmine.SpyObj;
  let notificationSpy: jasmine.SpyObj;

  beforeEach(async () => {
    orderServiceSpy = jasmine.createSpyObj('OrderService', ['submitOrder', 'getShippingCost']);
    notificationSpy = jasmine.createSpyObj('NotificationService', ['success', 'error']);

    await TestBed.configureTestingModule({
      imports: [CheckoutComponent],
      providers: [
        { provide: OrderService, useValue: orderServiceSpy },
        { provide: NotificationService, useValue: notificationSpy },
      ],
    }).compileComponents();
  });

  it('mostra un messaggio di successo quando l\'ordine va a buon fine', () => {
    orderServiceSpy.submitOrder.and.returnValue(of({ orderId: 'ORD-123', status: 'confirmed' }));

    const fixture = TestBed.createComponent(CheckoutComponent);
    fixture.componentInstance.submit();

    expect(orderServiceSpy.submitOrder).toHaveBeenCalledTimes(1);
    expect(notificationSpy.success).toHaveBeenCalledWith(jasmine.stringMatching(/ORD-123/));
  });

  it('mostra un errore se il servizio ordini fallisce', () => {
    orderServiceSpy.submitOrder.and.returnValue(
      throwError(() => new Error('Pagamento rifiutato')),
    );

    const fixture = TestBed.createComponent(CheckoutComponent);
    fixture.componentInstance.submit();

    expect(notificationSpy.error).toHaveBeenCalledWith('Pagamento rifiutato');
    expect(notificationSpy.success).not.toHaveBeenCalled();
  });
});

jasmine.createSpyObj creates an object with fake methods that record every call. .and.returnValue(...) configures what to return, and toHaveBeenCalledWith(...) checks the arguments passed. This technique fully isolates the component under test from the real logic of its dependencies.

1.6 — Testing HTTP calls with HttpClientTestingModule

For services that use HttpClient, Angular provides HttpClientTestingModule and HttpTestingController: they intercept real HTTP requests and let you respond with fake data, while also verifying the URL, method, and headers used.

// product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/api/products';

  getById(id: string): Observable {
    return this.http.get(`${this.baseUrl}/${id}`);
  }

  search(query: string, page = 1): Observable> {
    return this.http.get>(this.baseUrl, {
      params: { q: query, page: page.toString() },
    });
  }
}

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

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
    });
    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    // Essential: verifies there are no orphan HTTP requests left unhandled by the test
    httpMock.verify();
  });

  it('recupera un prodotto per id', () => {
    const mockProduct = { id: 'p1', name: 'Tastiera meccanica', price: 89.9 };

    service.getById('p1').subscribe(product => {
      expect(product).toEqual(mockProduct);
    });

    const req = httpMock.expectOne('/api/products/p1');
    expect(req.request.method).toBe('GET');
    req.flush(mockProduct);
  });

  it('propaga un errore 404 al subscriber', () => {
    service.getById('missing').subscribe({
      next: () => fail('doveva fallire'),
      error: err => expect(err.status).toBe(404),
    });

    const req = httpMock.expectOne('/api/products/missing');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });
  });

  it('costruisce correttamente i query params per la ricerca', () => {
    service.search('meccanica', 2).subscribe();

    const req = httpMock.expectOne(
      r => r.url === '/api/products' && r.params.get('q') === 'meccanica' && r.params.get('page') === '2',
    );
    expect(req.request.method).toBe('GET');
    req.flush({ items: [], total: 0, page: 2 });
  });
});

The httpMock.verify() call in afterEach is essential: it fails the test if there are HTTP requests the component/service sent that no assertion intercepted, preventing false positives where a request gets silently "forgotten."

1.7 — Async code: fakeAsync, tick(), and waitForAsync

Some scenarios — a debounced search field, timeouts, retries with delay — involve setTimeout or timed RxJS operators like debounceTime. Testing them with real timers would make the suite slow and non-deterministic. fakeAsync solves the problem by virtualizing time.

// search-box.component.ts (excerpt)
export class SearchBoxComponent {
  private readonly productService = inject(ProductService);
  query = new FormControl('');
  results = signal([]);

  constructor() {
    this.query.valueChanges
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        switchMap(q => (q ? this.productService.search(q) : of({ items: [] as Product[] }))),
        takeUntilDestroyed(),
      )
      .subscribe(result => this.results.set(result.items));
  }
}

// search-box.component.spec.ts
import { fakeAsync, tick, TestBed } from '@angular/core/testing';

it('esegue la ricerca solo dopo 300ms di inattività (debounce)', fakeAsync(() => {
  const fixture = TestBed.createComponent(SearchBoxComponent);
  const component = fixture.componentInstance;
  spyOn(TestBed.inject(ProductService), 'search').and.returnValue(
    of({ items: [{ id: 'p1', name: 'Mouse', price: 20 }] }),
  );

  component.query.setValue('mo');
  tick(100);
  component.query.setValue('mou');
  tick(100);
  component.query.setValue('mouse');

  // Only 200ms have passed since the last change: the search must NOT fire yet
  expect(TestBed.inject(ProductService).search).not.toHaveBeenCalled();

  tick(300); // complete the debounce
  fixture.detectChanges();

  expect(TestBed.inject(ProductService).search).toHaveBeenCalledOnceWith('mouse');
  expect(component.results().length).toBe(1);
}));

tick(ms) artificially advances the virtual clock of the test zone, firing pending timers without actually waiting that long. This makes the test deterministic and instant, while still correctly validating the debounce logic.

1.8 — Testing Effects that react to Signals

With the introduction of Signals, many components use effect() to react to state changes (e.g. syncing with localStorage). Testing an effect requires explicitly forcing a flush of the reactive context with TestBed.flushEffects() or, more simply, with fixture.detectChanges() when the component is attached to a fixture.

// theme.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';

describe('ThemeService — persistenza tema con effect()', () => {
  beforeEach(() => localStorage.clear());

  it('salva il tema in localStorage quando cambia', () => {
    TestBed.runInInjectionContext(() => {
      const service = TestBed.inject(ThemeService);
      service.setTheme('dark');

      TestBed.tick(); // forces a flush of pending effects

      expect(localStorage.getItem('theme')).toBe('dark');
    });
  });
});

1.9 — Marble testing for complex Observables

When RxJS logic gets complex (combinations of combineLatest, switchMap, retry with backoff), marble testing with TestScheduler lets you describe timed sequences of events declaratively and verify them in a single synchronous tick.

import { TestScheduler } from 'rxjs/testing';

describe('retryWithBackoff (marble testing)', () => {
  let scheduler: TestScheduler;

  beforeEach(() => {
    scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('riprova due volte prima di emettere il valore con successo', () => {
    scheduler.run(({ cold, expectObservable }) => {
      // '#' represents an error, '-' a time frame, 'a' an emitted value
      const source$ = cold('-#', undefined, new Error('fail'));
      const expected = '  -- -- -- -- -#';

      // In a real scenario we'd apply retryWithBackoff(source$, { retries: 2 }) here
      expectObservable(source$.pipe()).toBe('-#', undefined, new Error('fail'));
    });
  });
});

Marble testing has a steeper learning curve compared to "classic" tests, but it pays off when the reactive logic involves precise timing that would be hard to verify reliably with plain fakeAsync.

1.10 — Switching to Jest: why and how

Jest has become the de-facto standard in the React/Node ecosystem, and more and more Angular teams are adopting it in place of Karma for three concrete reasons:

  • Speed: Jest runs tests in Node.js with JSDOM, without opening a real Chrome browser — significantly faster, especially in CI.
  • Smart watch mode: jest --watch runs only the tests related to changed files (via git diff analysis).
  • Built-in snapshot testing and mocking: jest.fn(), jest.mock(), and snapshots are native, with no extra libraries.
npm install --save-dev jest jest-preset-angular @types/jest
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
// jest.config.js
module.exports = {
  preset: 'jest-preset-angular',
  setupFilesAfterEach: ['/setup-jest.ts'],
  testPathIgnorePatterns: ['/node_modules/', '/dist/'],
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
  transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
};
// setup-jest.ts
import 'jest-preset-angular/setup-jest';
// package.json (excerpt)
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

1.11 — Jasmine vs Jest: practical syntax differences

Good news: the describe/it/expect syntax is nearly identical, so migrating most tests is often just a matter of configuration. The differences show up mainly in mocking.

AspectJasmineJest
Creating a spyjasmine.createSpy('name')jest.fn()
Mocking an entire moduleNot native (requires DI)jest.mock('./module')
Configuring the return valuespy.and.returnValue(x)fn.mockReturnValue(x)
Snapshot testingNot natively supportedexpect(x).toMatchSnapshot()
Fake timersfakeAsync + tick()jest.useFakeTimers() + jest.advanceTimersByTime()
Execution environmentReal browser (Karma + Chrome)Node.js + JSDOM
Typical speed (500 tests)~25-40s~8-15s
// Module mocking example with Jest — useful for heavy external libraries
jest.mock('./analytics.service', () => ({
  AnalyticsService: jest.fn().mockImplementation(() => ({
    track: jest.fn(),
    identify: jest.fn(),
  })),
}));

describe('CheckoutComponent con Jest', () => {
  it('traccia l\'evento purchase al completamento ordine', () => {
    const trackSpy = jest.fn();
    // ... component setup with the mock ...
    expect(trackSpy).toHaveBeenCalledWith('purchase', expect.objectContaining({ orderId: expect.any(String) }));
  });
});

1.12 — Snapshot testing: when to use it (and when to avoid it)

Snapshot tests capture a component's rendered output and compare it against a version saved to disk. They're useful for purely presentational components with stable output, but become a problem when the team auto-updates them with --ci=false --updateSnapshot without reviewing the diff — turning them into a purely formal check.

it('il badge di stato ordine ha il markup atteso', () => {
  const fixture = TestBed.createComponent(OrderStatusBadgeComponent);
  fixture.componentRef.setInput('status', 'shipped');
  fixture.detectChanges();

  expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
});

Rule of thumb: use snapshots only for simple, stable markup (badges, icons, formatters), never for components with business logic — there, an explicit assertion (expect(text).toBe('Spedito')) communicates intent far better than an opaque 200-line HTML snapshot.

1.13 — Cross-cutting best practices for effective unit tests

  • AAA pattern (Arrange-Act-Assert): structure every test into three clear blocks: prepare the data, perform the action, verify the result.
  • One conceptual assertion per test: multiple expect calls in the same test are fine if they verify the same thing from different angles, but a test that verifies three independent behaviors should be split into three.
  • Don't test implementation details: test observable behavior (output, emitted events, rendered DOM), not private variables or internal method calls.
  • Descriptive test names: it('disables the button when the cart is empty') is infinitely more useful than it('test 3') when a test fails in CI at 2am.
  • Independent tests: every test must be able to run on its own, in any order. If a test depends on state left by another, it's a brittle test.
  • Coverage as an indicator, not a goal: 100% code coverage with weak assertions (expect(true).toBe(true)) is worse than 70% with solid assertions. Use coverage to find untested code, not as a KPI to maximize at all costs.

1.14 — Testing Guards and Resolvers

Route guards and resolvers are often where an application's most delicate authorization logic lives — and that's exactly the kind of code that must have solid test coverage, because a bug here means unauthenticated users accessing protected pages, or the opposite.

// auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true;
  }

  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
  return false;
};

// auth.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';

describe('authGuard', () => {
  let authServiceSpy: jasmine.SpyObj;
  let routerSpy: jasmine.SpyObj;

  beforeEach(() => {
    authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
    routerSpy = jasmine.createSpyObj('Router', ['navigate']);

    TestBed.configureTestingModule({
      providers: [
        { provide: AuthService, useValue: authServiceSpy },
        { provide: Router, useValue: routerSpy },
      ],
    });
  });

  it('permette la navigazione se l\'utente è autenticato', () => {
    authServiceSpy.isAuthenticated.and.returnValue(true);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, { url: '/dashboard' } as any),
    );

    expect(result).toBeTrue();
    expect(routerSpy.navigate).not.toHaveBeenCalled();
  });

  it('reindirizza al login con returnUrl se non autenticato', () => {
    authServiceSpy.isAuthenticated.and.returnValue(false);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, { url: '/dashboard/settings' } as any),
    );

    expect(result).toBeFalse();
    expect(routerSpy.navigate).toHaveBeenCalledWith(['/login'], {
      queryParams: { returnUrl: '/dashboard/settings' },
    });
  });
});

Testing the guard as a plain function (thanks to TestBed.runInInjectionContext) instead of mounting an entire routed component is much faster and keeps the focus exactly on the authorization logic, without noise from rendering.

1.15 — Testing custom Pipes and Directives

// truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
  transform(value: string, maxLength = 100, suffix = '…'): string {
    if (!value || value.length <= maxLength) return value;
    return value.slice(0, maxLength).trim() + suffix;
  }
}

// truncate.pipe.spec.ts
describe('TruncatePipe', () => {
  const pipe = new TruncatePipe();

  it('non modifica stringhe più corte del limite', () => {
    expect(pipe.transform('Ciao mondo', 20)).toBe('Ciao mondo');
  });

  it('tronca e aggiunge il suffisso di default', () => {
    const result = pipe.transform('Questo è un testo piuttosto lungo da troncare', 10);
    expect(result).toBe('Questo è u…');
  });

  it('gestisce stringhe vuote o undefined senza errori', () => {
    expect(pipe.transform('', 10)).toBe('');
  });
});

// highlight-on-hover.directive.ts
import { Directive, ElementRef, HostListener, inject, input } from '@angular/core';

@Directive({ selector: '[appHighlightOnHover]', standalone: true })
export class HighlightOnHoverDirective {
  private readonly el = inject(ElementRef);
  color = input('#fff3cd');

  @HostListener('mouseenter') onEnter(): void {
    this.el.nativeElement.style.backgroundColor = this.color();
  }

  @HostListener('mouseleave') onLeave(): void {
    this.el.nativeElement.style.backgroundColor = '';
  }
});

// highlight-on-hover.directive.spec.ts
@Component({
  standalone: true,
  imports: [HighlightOnHoverDirective],
  template: `
Testo
`, }) class HostComponent {} describe('HighlightOnHoverDirective', () => { it('applica il colore di sfondo al mouseenter e lo rimuove al mouseleave', () => { const fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); const div: HTMLElement = fixture.nativeElement.querySelector('div'); div.dispatchEvent(new Event('mouseenter')); expect(div.style.backgroundColor).toBe('rgb(255, 0, 0)'); div.dispatchEvent(new Event('mouseleave')); expect(div.style.backgroundColor).toBe(''); }); });

Pipes and directives, being very small units of logic with no heavy dependencies, are among the cheapest things to test exhaustively: a few minutes of writing guarantee near-complete coverage of every edge case (empty string, boundary value, malformed input).

Part 2: E2E Testing with Cypress and Playwright

2.1 — Why you need E2E tests beyond unit tests

Unit tests verify that individual pieces work in isolation, but they can't catch integration problems: misconfigured routing, CSS that hides a button, CORS blocked only in a specific environment, or a regression in the full checkout flow. E2E tests open a real browser (or a headless one), navigate the application the way a real user would, and verify the final result.

2.2 — Cypress: installation and project structure

npm install --save-dev cypress
npx cypress open   # interactive mode, useful during development
npx cypress run    # headless mode, for CI
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4200',
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    viewportWidth: 1280,
    viewportHeight: 800,
    video: false, // disabled locally, often re-enabled in CI for debugging
    retries: {
      runMode: 2,  // retry failed tests twice in CI (mitigates network flakiness)
      openMode: 0,
    },
    env: {
      apiUrl: 'http://localhost:3000/api',
    },
  },
});

The typical structure of a Cypress project:

cypress/
├── e2e/
│   ├── auth/
│   │   ├── login.cy.ts
│   │   └── register.cy.ts
│   ├── checkout/
│   │   └── checkout-flow.cy.ts
│   └── blog/
│       └── blog-navigation.cy.ts
├── fixtures/
│   └── products.json
├── support/
│   ├── commands.ts
│   └── e2e.ts

2.3 — First Cypress test: login flow

// cypress/e2e/auth/login.cy.ts
describe('Login', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('effettua il login con credenziali valide e reindirizza alla dashboard', () => {
    cy.get('[data-testid="email-input"]').type('utente@example.com');
    cy.get('[data-testid="password-input"]').type('PasswordSicura123!');
    cy.get('[data-testid="login-submit"]').click();

    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="welcome-message"]').should('contain.text', 'Bentornato');
  });

  it('mostra un errore con credenziali errate', () => {
    cy.get('[data-testid="email-input"]').type('utente@example.com');
    cy.get('[data-testid="password-input"]').type('passwordSbagliata');
    cy.get('[data-testid="login-submit"]').click();

    cy.get('[data-testid="login-error"]')
      .should('be.visible')
      .and('contain.text', 'Credenziali non valide');
    cy.url().should('include', '/login');
  });

  it('disabilita il pulsante submit finché i campi non sono validi', () => {
    cy.get('[data-testid="login-submit"]').should('be.disabled');
    cy.get('[data-testid="email-input"]').type('non-una-email');
    cy.get('[data-testid="login-submit"]').should('be.disabled');
    cy.get('[data-testid="email-input"]').clear().type('valida@example.com');
    cy.get('[data-testid="password-input"]').type('almeno8caratteri');
    cy.get('[data-testid="login-submit"]').should('not.be.disabled');
  });
});

2.4 — Intercepting network calls with cy.intercept

An E2E test that depends on a real backend is slow and brittle (changing data, external services being down). cy.intercept lets you stub HTTP responses while keeping the test realistic but deterministic.

// cypress/e2e/checkout/checkout-flow.cy.ts
describe('Flusso di checkout completo', () => {
  beforeEach(() => {
    cy.intercept('GET', '/api/cart', { fixture: 'cart-with-items.json' }).as('getCart');
    cy.intercept('POST', '/api/orders', {
      statusCode: 201,
      body: { orderId: 'ORD-9981', status: 'confirmed' },
    }).as('submitOrder');

    cy.visit('/checkout');
    cy.wait('@getCart');
  });

  it('completa un ordine e mostra la conferma', () => {
    cy.get('[data-testid="shipping-name"]').type('Mario Rossi');
    cy.get('[data-testid="shipping-address"]').type('Via Roma 1, Milano');
    cy.get('[data-testid="place-order"]').click();

    cy.wait('@submitOrder').its('request.body').should('deep.include', {
      shippingName: 'Mario Rossi',
    });

    cy.get('[data-testid="order-confirmation"]').should('contain.text', 'ORD-9981');
  });

  it('mostra un errore se il pagamento viene rifiutato dal backend', () => {
    cy.intercept('POST', '/api/orders', {
      statusCode: 402,
      body: { message: 'Pagamento rifiutato' },
    }).as('submitOrderFailed');

    cy.get('[data-testid="shipping-name"]').type('Mario Rossi');
    cy.get('[data-testid="shipping-address"]').type('Via Roma 1, Milano');
    cy.get('[data-testid="place-order"]').click();

    cy.wait('@submitOrderFailed');
    cy.get('[data-testid="checkout-error"]').should('contain.text', 'Pagamento rifiutato');
  });
});

2.5 — Custom commands: staying DRY in Cypress tests

Actions repeated across many tests (logging in, adding a product to the cart) should be extracted into custom commands to avoid duplication.

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      loginViaApi(email: string, password: string): Chainable;
    }
  }
}

Cypress.Commands.add('loginViaApi', (email: string, password: string) => {
  cy.request('POST', '/api/auth/login', { email, password }).then(response => {
    window.localStorage.setItem('authToken', response.body.token);
  });
});

export {};
// Usage in a test — "silent" login via API instead of filling in the form every time
describe('Dashboard (utente già autenticato)', () => {
  beforeEach(() => {
    cy.loginViaApi('utente@example.com', 'PasswordSicura123!');
    cy.visit('/dashboard');
  });

  it('mostra le statistiche dell\'utente', () => {
    cy.get('[data-testid="stats-panel"]').should('be.visible');
  });
});

Logging in via a direct API call (cy.request) instead of through the UI every time is one of the most effective optimizations for speeding up an E2E suite: a test that isn't meant to test login itself doesn't need to go through it via the interface either.

2.6 — Cypress Component Testing

Beyond classic E2E tests, Cypress supports Component Testing: it mounts a single Angular component in isolation in a real browser, combining the speed of unit tests with the fidelity of real rendering (no JSDOM).

// quantity-selector.cy.ts (Cypress Component Testing)
import { QuantitySelectorComponent } from './quantity-selector.component';

describe('QuantitySelectorComponent (component test)', () => {
  it('incrementa il valore al click su +', () => {
    cy.mount(QuantitySelectorComponent, {
      componentProperties: { value: 5, max: 10 },
    });

    cy.get('[data-testid="increment"]').click();
    cy.get('[data-testid="quantity-value"]').should('have.text', '6');
  });
});

2.7 — Playwright: installation and configuration

npm init playwright@latest
# Installs the required browsers (Chromium, Firefox, WebKit)
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [['html', { open: 'never' }], ['github']],
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry', // records a trace only when a test fails and is retried
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
  ],
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
  },
});

2.8 — First Playwright test: the same login flow

// e2e/auth/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('effettua il login con credenziali valide', async ({ page }) => {
    await page.getByTestId('email-input').fill('utente@example.com');
    await page.getByTestId('password-input').fill('PasswordSicura123!');
    await page.getByTestId('login-submit').click();

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByTestId('welcome-message')).toContainText('Bentornato');
  });

  test('mostra un errore con credenziali non valide', async ({ page }) => {
    await page.getByTestId('email-input').fill('utente@example.com');
    await page.getByTestId('password-input').fill('sbagliata');
    await page.getByTestId('login-submit').click();

    await expect(page.getByTestId('login-error')).toBeVisible();
    await expect(page.getByTestId('login-error')).toContainText('Credenziali non valide');
  });
});

2.9 — Locators, auto-waiting, and network mocking

Playwright uses locators, "lazy" objects that automatically re-attach to the DOM before every action, with built-in auto-waiting: no sleep or explicit wait is needed, Playwright automatically waits for the element to be visible, enabled, and stable before interacting with it.

// e2e/checkout/checkout-flow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Flusso di checkout', () => {
  test.beforeEach(async ({ page }) => {
    await page.route('**/api/cart', route =>
      route.fulfill({ path: 'e2e/fixtures/cart-with-items.json' }),
    );
    await page.route('**/api/orders', route =>
      route.fulfill({
        status: 201,
        json: { orderId: 'ORD-9981', status: 'confirmed' },
      }),
    );
    await page.goto('/checkout');
  });

  test('completa un ordine e mostra la conferma', async ({ page }) => {
    await page.getByTestId('shipping-name').fill('Mario Rossi');
    await page.getByTestId('shipping-address').fill('Via Roma 1, Milano');

    const [orderRequest] = await Promise.all([
      page.waitForRequest('**/api/orders'),
      page.getByTestId('place-order').click(),
    ]);

    expect(orderRequest.postDataJSON()).toMatchObject({ shippingName: 'Mario Rossi' });
    await expect(page.getByTestId('order-confirmation')).toContainText('ORD-9981');
  });
});

2.10 — Cross-browser parallel execution and the Trace Viewer

One of Playwright's strengths is native parallel execution across Chromium, Firefox, and WebKit with no extra infrastructure (grid, cloud provider). When a test fails in CI, the recorded trace lets you review the entire run — DOM snapshots, network, console — step by step, like an interactive video.

# Runs the suite on every configured browser, in parallel
npx playwright test

# Runs only on Chromium with the interactive debug UI
npx playwright test --project=chromium --ui

# Analyzes the trace of a test that failed in CI
npx playwright show-trace trace.zip

2.11 — Cypress vs Playwright: comparison table

FeatureCypressPlaywright
ArchitectureRuns inside the browser (same event loop as the app)Controls the browser from outside via a protocol (CDP/WebSocket)
Supported browsersChrome, Edge, Firefox, ElectronChromium, Firefox, WebKit (so also Safari)
Multi-tab / multi-domainHistorically limited supportNative, no workarounds
Parallel executionRequires Cypress Cloud (paid) or manual shardingNative, free, built into the config
Typical speedGoodGenerally higher, especially in CI
Debug toolingTime-travel debugging in the interactive UITrace Viewer + UI Mode
Component testingYes, matureYes (Playwright Component Testing, more recent)
Learning curveVery gentle, highly readable APISlightly wider (async/await everywhere)

In practice: if the team is already used to Cypress and the project doesn't require Safari/WebKit, Cypress remains a great choice for its developer experience. If you need real multi-browser coverage (including Safari) and native parallelization with no extra cost, Playwright is today's more solid choice for new projects.

2.12 — CI/CD integration with GitHub Actions

# .github/workflows/e2e-playwright.yml
name: E2E Tests (Playwright)

on:
  pull_request:
    branches: [main, develop]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: Build application
        run: npm run build

      - name: Run E2E tests
        run: npx playwright test

      - name: Upload HTML report as artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14
# .github/workflows/e2e-cypress.yml (equivalent with Cypress)
name: E2E Tests (Cypress)

on:
  pull_request:
    branches: [main, develop]

jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm run start
          wait-on: 'http://localhost:4200'
          browser: chrome

A detail that's often overlooked: running E2E tests only on pull requests that touch frontend code (via paths: in the trigger) avoids wasting CI minutes on changes that only affect, say, documentation or the backend.

2.13 — Visual regression testing with Playwright

Beyond functional assertions, Playwright can capture screenshots and automatically compare them against a baseline saved on disk — useful for catching purely visual regressions (a margin that changed, a wrong color) that no textual assertion would catch.

// e2e/visual/product-card.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Regressione visiva — ProductCard', () => {
  test('la card prodotto corrisponde allo screenshot di riferimento', async ({ page }) => {
    await page.goto('/prodotti/tastiera-meccanica');
    const card = page.getByTestId('product-card');

    // The first run generates the baseline screenshot (--update-snapshots);
    // subsequent runs fail if there are pixel differences beyond the threshold.
    await expect(card).toHaveScreenshot('product-card-baseline.png', {
      maxDiffPixelRatio: 0.02,
    });
  });

  test('la card in stato "esaurito" ha un badge visivamente distinto', async ({ page }) => {
    await page.goto('/prodotti/tastiera-meccanica?stock=0');
    await expect(page.getByTestId('product-card')).toHaveScreenshot('product-card-sold-out.png');
  });
});
# Updates the baseline screenshots after an intentional UI change
npx playwright test --update-snapshots

# In CI, always save the generated diffs as an artifact for visual review

Rule of thumb: the maxDiffPixelRatio threshold should be calibrated to tolerate small anti-aliasing variations between environments (local vs. CI), otherwise the test becomes flaky for reasons that have nothing to do with an actual visual bug.

Part 3: Debugging Tricks with Angular DevTools

3.1 — Installation and first launch

Angular DevTools is an official extension for Chrome and Firefox that adds a dedicated panel to the browser's developer tools. After installing it from the Chrome Web Store, an "Angular" tab will appear in DevTools (F12) on any page running an Angular application in development mode (in production, with enableProdMode() active, the necessary metadata is stripped for performance and security reasons).

3.2 — Component Explorer: inspecting the component tree

The Components tab shows the entire tree of rendered components, similar to the DOM tree but at the level of Angular components rather than HTML tags. Selecting a component lets you inspect in real time:

  • The current values of every @Input() and every signal passed as input.
  • The component's internal state (properties, signals, computeds).
  • The parent/child component hierarchy, to understand where an unexpected value is coming from.

You can also edit a property's value on the fly from the panel and watch the component re-render instantly — extremely useful for reproducing an edge case (e.g. an empty list, a negative price) without having to change the upstream data.

3.3 — The Injector Tree: understanding where a service comes from

The Injector Tree tab visualizes the hierarchy of the application's Dependency Injection injectors — useful for diagnosing bugs like "why does this component get a different instance of the service than the one right next to it?", typically caused by a provider declared at the component level instead of root, which creates an isolated instance for that subtree.

3.4 — The Profiler: recording a Change Detection cycle

The Profiler tab is the most powerful tool for performance debugging. Pressing "Start recording" and interacting with the app (clicking, scrolling, typing), Angular DevTools records every change detection cycle and produces a bar chart — similar to a flame graph — where each bar represents a component and its height the time spent checking its bindings.

// Typical scenario to diagnose with the Profiler:
// a list component that re-renders on every keystroke in a search field,
// even though the list data hasn't changed.

@Component({
  selector: 'app-product-list',
  standalone: true,
  // ❌ Without OnPush, Angular checks this component on EVERY CD cycle
  // of the entire application, even for keystrokes in unrelated components.
  template: ``,
})
export class ProductListComponent {
  @Input() products: Product[] = [];
}

In the Profiler, this shows up as a wide, repeated bar for ProductListComponent on every single input event, even when the user is just typing in a field that has nothing to do with the product list.

3.5 — From diagnosis to fix: ChangeDetectionStrategy.OnPush

@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (p of products(); track p.id) {
      
    }
  `,
})
export class ProductListComponent {
  products = input([]);
}

With OnPush, Angular checks the component only when: (1) an @Input/signal input changes by reference, (2) a DOM event occurs inside it, or (3) it's explicitly marked with markForCheck(). Re-running the recording in the Profiler after this change, the bar for ProductListComponent disappears while typing in the search field — visual proof that the optimization worked.

3.6 — trackBy (or track in the new @for syntax) for large lists

Another common cause of bottlenecks visible in the Profiler: lists that get fully re-rendered (destroyed and recreated) on every update, instead of updating only the elements that actually changed.


@for (product of products(); track $index) {
  
}


@for (product of products(); track product.id) {
  
}

In the Profiler, the effect of track $index on a list that gets reordered (e.g. after a sort operation) shows up as a large number of ProductCardComponent instances being destroyed and recreated, instead of a simple repositioning — a clear signal that the chosen track doesn't correctly identify the elements.

3.7 — Combining Angular DevTools with Chrome's Performance panel

Angular DevTools excels at showing which components Angular is checking, but doesn't go into detail about what happens at the JavaScript engine level (garbage collection, layout thrashing, long tasks). For a complete analysis, record a parallel session in Chrome DevTools' native Performance tab:

  • Long Tasks (red bars over 50ms) indicate synchronous work blocking the main thread — often an overly heavy change detection cycle.
  • The Layout / Reflow section flags DOM reads/writes alternated inefficiently (e.g. reading offsetHeight inside a loop that then writes styles).
  • The memory graph over time helps spot memory leaks — typically RxJS subscriptions never unsubscribed in components that are repeatedly destroyed and recreated (e.g. in a route the user reopens often).
// Common cause of memory leaks: a manual subscription that's never unsubscribed
export class DashboardComponent implements OnInit {
  ngOnInit(): void {
    // ❌ Every time the component is recreated, a new subscription piles up
    interval(5000).subscribe(() => this.refreshStats());
  }
}

// ✅ Fixed with takeUntilDestroyed — the subscription is automatically
//    cleaned up when Angular destroys the component
export class DashboardComponent implements OnInit {
  private readonly destroyRef = inject(DestroyRef);

  ngOnInit(): void {
    interval(5000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.refreshStats());
  }
}

3.8 — Extra console tricks: console.table, console.time, debugger

Not all debugging requires dedicated tools. A few native console methods, often forgotten, greatly speed up the analysis of complex data:

// console.table: displays arrays of objects as a readable table,
// much more useful than console.log for debugging lists of data
console.table(this.products());

// console.time / console.timeEnd: quickly measure how long a block takes,
// without importing external profiling tools
console.time('calcolaTotaleCarrello');
const total = this.calculateTotal();
console.timeEnd('calcolaTotaleCarrello'); // prints: calcolaTotaleCarrello: 2.341ms

// console.trace: prints the full call stack — very useful for understanding
// WHERE a function called from too many places in the code is invoked from
someSharedUtilityFunction(): void {
  console.trace('someSharedUtilityFunction chiamata da:');
}

// The debugger statement pauses execution exactly like a manually set
// breakpoint, but lives in the source code — useful for rare conditions
if (order.total < 0) {
  debugger; // the browser stops here ONLY if DevTools is open
}

One last practical tip: in Chrome DevTools' conditional breakpoints (right-click on the line number → "Add conditional breakpoint") you can enter an expression like product.price < 0, pausing execution only when the condition is true, instead of having to click "continue" dozens of times inside a loop.

3.9 — Heap snapshots: pinpointing memory leaks with precision

When Angular DevTools' Profiler shows an application progressively slowing down after several minutes of use (a classic memory leak symptom), the next step is Chrome DevTools' Memory tab:

  1. Navigate the app to a "clean" state (e.g. an empty dashboard), then record a first Heap snapshot.
  2. Repeatedly perform the suspect action (e.g. open and close a dialog 10 times).
  3. Force a manual garbage collection (the trash can icon) and record a second Heap snapshot.
  4. Use the "Comparison" view between the two snapshots: objects that keep increasing in count (e.g. instances of DialogComponent that should have been destroyed) are the symptom of the leak.
// Typical cause detectable in a Comparison heap snapshot:
// a custom EventEmitter or RxJS subscription kept alive by a singleton service,
// which in turn holds a reference to the component and prevents garbage collection.

@Injectable({ providedIn: 'root' })
export class NotificationBusService {
  private readonly _messages = new Subject();
  readonly messages$ = this._messages.asObservable();
}

// ❌ The component subscribes to the singleton service but never unsubscribes:
// every instance of the component stays "hooked" to the Subject forever.
export class ToastComponent implements OnInit {
  constructor(private bus: NotificationBusService) {}
  ngOnInit(): void {
    this.bus.messages$.subscribe(msg => this.show(msg));
  }
}

In an application where ToastComponent is repeatedly created and destroyed (e.g. inside a route the user visits often), this single unsubscribed subscription accumulates one reference per instance ever created — exactly the pattern a Comparison heap snapshot makes visible, with a "Detached" instance count that keeps growing and never returns to zero.

Conclusion: Building a Multi-Layered Safety Net

None of these tools replaces the others: fast unit tests with Jest or Jasmine/Karma give immediate confidence in every single unit of code on every save; E2E tests with Cypress or Playwright validate that critical flows (login, checkout, search) really work end-to-end before release; Angular DevTools steps in when you need to understand why something is slow or behaving unexpectedly — something no automated test alone can fully diagnose.

The combination of these three layers — multiplied by a CI pipeline that runs them automatically on every pull request — is what turns an Angular project from "works on my machine" into a product the team can refactor with confidence, knowing a regression will be caught before it reaches users.

  • Minimum checklist for a mature Angular project:
  • ✅ Unit tests for every service with non-trivial logic, with minimum coverage enforced in CI.
  • ✅ E2E tests on the 3-5 flows that are genuinely critical to the business (not on every single page).
  • ✅ ChangeDetectionStrategy.OnPush as the default for new components.
  • ✅ A profiling session with Angular DevTools before every major release on high-traffic pages.

💬 Reader notes

0 notes

Write a note

Share your opinion, a suggestion or a compliment

Latest notes

No notes yet. Be the first to comment!