Testing Angular applications

TestBed with standalone components, component harnesses, HttpTestingController, and how to test signal and zoneless code reliably.

TestBed with standalone components

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { InvoiceListComponent } from './invoice-list.component';
import { InvoiceApi } from './invoice.api';

describe('InvoiceListComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [InvoiceListComponent],       // standalone: import the component itself
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        { provide: InvoiceApi, useValue: { list: () => of({ items: [], total: 0, cursor: null }) } }
      ]
    }).compileComponents();
  });

  it('renders the empty state', () => {
    const fixture = TestBed.createComponent(InvoiceListComponent);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('No invoices yet');
  });

  it('renders rows after the resource resolves', async () => {
    const fixture = TestBed.createComponent(InvoiceListComponent);
    await fixture.whenStable();              // let the resource settle
    const rows = fixture.nativeElement.querySelectorAll('tbody tr');
    expect(rows.length).toBe(3);
  });
});
// Prefer overriding what the component injects over mocking the whole world.
TestBed.overrideComponent(InvoiceListComponent, {
  set: {
    providers: [{ provide: InvoiceApi, useValue: fakeApi }],
    imports: [InvoiceListComponent],   // required when overriding for a standalone component
    template: `<p>stubbed</p>`         // only if you truly need to ignore the real template
  }
});
QuestionAnswerNote
What do I import?The component under testStandalone components carry their own dependencies
How do I await a signal update?await fixture.whenStable()Preferred over a manual detectChanges loop
How do I test a service?TestBed.inject(Service)No component needed
How do I test a pipe?Instantiate it directlyA pipe is a pure function with an interface
How do I fake HTTP?HttpTestingControllerAssert on the request, then flush a response
How do I test router navigation?RouterTestingHarnessGives you the routed component instance
💡
In a zoneless application, tests are simpler: there is no Zone to flush and no fakeAsync needed for signal updates. Await whenStable() and the work is done. Code that still depends on Zone timing is a sign it should be rewritten around signals.

HTTP and router testing

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { InvoiceApi } from './invoice.api';

describe('InvoiceApi', () => {
  let api: InvoiceApi;
  let http: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting(), InvoiceApi]
    });
    api = TestBed.inject(InvoiceApi);
    http = TestBed.inject(HttpTestingController);
  });

  afterEach(() => http.verify());       // fails the test on an unexpected request

  it('sends the status filter as a query parameter', () => {
    api.list({ status: 'paid', limit: 10 }).subscribe();
    const req = http.expectOne((r) => r.url === '/api/v1/invoices' && r.params.get('status') === 'paid');
    expect(req.request.method).toBe('GET');
    req.flush({ items: [], total: 0, cursor: null });
  });

  it('retries once on a 503 and then succeeds', () => {
    api.list({}).subscribe((page) => expect(page.total).toBe(4));
    http.expectOne('/api/v1/invoices').flush('unavailable', { status: 503, statusText: 'Service Unavailable' });
    http.expectOne('/api/v1/invoices').flush({ items: [], total: 4, cursor: null });
  });

  it('surfaces a network failure as a useful error', () => {
    let message = '';
    api.list({}).subscribe({ error: (e: Error) => (message = e.message) });
    http.expectOne('/api/v1/invoices').error(new ProgressEvent('error'));
    expect(message).toBe('Network unavailable');
  });
});
// RouterTestingHarness: the shortest path to a routed component
import { RouterTestingHarness } from '@angular/router/testing';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

it('loads the invoice detail route', async () => {
  TestBed.configureTestingModule({ providers: [provideRouter(routes)] });
  const harness = await RouterTestingHarness.create('/invoices/42');
  expect(harness.routeNativeElement?.textContent).toContain('Invoice #42');
  await harness.navigateByUrl('/invoices/43');
});

// Component harnesses: query through a semantic API instead of CSS selectors
// const loader = TestbedHarnessEnvironment.loader(fixture);
// const button = await loader.getHarness(MatButtonHarness);

Testing signals, components and end-to-end

// Signals are plain values: test them without a TestBed at all.
describe('cart totals', () => {
  it('applies the promo discount to the subtotal', () => {
    const cart = new CartComponent();          // no DI needed if the component has none
    cart.add({ id: '1', name: 'Widget', price: 1000 });
    cart.add({ id: '2', name: 'Bolt', price: 500 });
    expect(cart.subtotal()).toBe(1500);
    cart.promoCode.set('SAVE10');
    expect(cart.total()).toBe(1350);
  });
});

// An effect is asynchronous: flush it before asserting.
it('persists the theme', async () => {
  const fixture = TestBed.createComponent(ThemeComponent);
  const component = fixture.componentInstance;
  component.mode.set('dark');
  await fixture.whenStable();
  expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
LevelToolWhat it is good at
Pure logicPlain assertionsSignals, pipes, validators, reducers
ComponentTestBed + DOM assertionsRendering, binding, user-visible output
Service with HTTPHttpTestingControllerRequest shape, retries, error mapping
RoutingRouterTestingHarnessGuards, resolvers, lazy loading
Critical flowsPlaywrightReal browser, real network, real storage
Visual regressionsScreenshot comparisonLayout breakage from a CSS change
  • Test what the user can observe: rendered text, disabled state, emitted output. Asserting on private fields makes refactoring needlessly painful.
  • Call http.verify() in afterEach; a test that forgets an outstanding request can pass while leaking state into the next one.
  • For a zoneless app, never use fakeAsync and tick on signal code — there is no Zone to advance. Await stability instead.
  • Keep one or two end-to-end tests per critical journey and leave the combinatorial cases to unit tests. Browser suites are slow and flaky in proportion to their size.

FAQ

Why does detectChanges not show my async result?
The work has not completed yet. Await fixture.whenStable(), which waits for pending tasks including signal-driven rendering and resource loads. A single detectChanges only reflects state that is already resolved.
Should I test every component?
No. Test the logic that can break — pipes, validators, reducers, services with branching — plus the components where rendering is the risky part. A test that asserts a template renders a string the compiler already guarantees is a maintenance cost with no return.

Advanced reactive and signal forms Server rendering, hydration and deployment

Last refreshed 2026-09-18.