Skip to content
End To End Tester

Testing Angular

TestBed, the change detection model that confuses everyone, HttpTestingController, signals and standalone components — testing Angular without fighting it.

1 min read · updated 19 September 2026

Angular is the only major front-end framework with a real dependency injection container, and that shapes its testing story: substituting a dependency is a first-class operation with no mocking library involved.

The cost is TestBed and a change-detection model you have to drive yourself.

#Services: no TestBed needed

typescript
// The simplest Angular test there is. No TestBed, no fixture, microseconds.
describe('PricingService', () => {
  it('applies the discount at the threshold', () => {
    const service = new PricingService(new FixedClock(JAN_1));

    expect(service.discountFor({ subtotalCents: 10_000 })).toBe(2_000);
  });
});

Reach for TestBed only when you need the injector, a component or a directive. A service with constructor dependencies is a plain class — see dependency injection.

#Components with TestBed

typescript
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { BasketLineComponent } from './basket-line.component';

describe('BasketLineComponent', () => {
  let fixture: ComponentFixture<BasketLineComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [BasketLineComponent],                 // standalone component
      providers: [{ provide: PricingService, useClass: StubPricingService }]
    }).compileComponents();

    fixture = TestBed.createComponent(BasketLineComponent);
    fixture.componentRef.setInput('unitCents', 1_200);
    fixture.detectChanges();                           // ← required
  });

  it('shows the line total', () => {
    const total = fixture.nativeElement.querySelector('[data-test=line-total]');

    expect(total.textContent).toContain('£12.00');
  });

  it('recalculates when the quantity changes', () => {
    const input: HTMLInputElement = fixture.nativeElement.querySelector('input[name=quantity]');
    input.value = '3';
    input.dispatchEvent(new Event('input'));

    fixture.detectChanges();                           // ← required again

    expect(fixture.nativeElement.textContent).toContain('£36.00');
  });
});

fixture.detectChanges() after every state change is the thing that catches everyone. In an application, Angular's zone triggers change detection for you; in a test it does not, unless you opt in:

typescript
TestBed.configureTestingModule({
  providers: [provideExperimentalZonelessChangeDetection()]
});
// then: await fixture.whenStable() instead of detectChanges()

#HTTP

HttpTestingController is one of the best-designed testing utilities in any framework.

typescript
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';

describe('RatesService', () => {
  let service: RatesService;
  let http: HttpTestingController;

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

  // Fails the test if any request was made that nothing asserted on.
  afterEach(() => http.verify());

  it('requests the rate and maps the response', () => {
    let rate: number | undefined;
    service.gbpRate().subscribe((r) => (rate = r));

    const req = http.expectOne('/api/rates?base=USD');
    expect(req.request.method).toBe('GET');
    req.flush({ rates: { GBP: 0.79 } });

    expect(rate).toBe(0.79);
  });

  it('surfaces a rate limit', () => {
    let error: unknown;
    service.gbpRate().subscribe({ error: (e) => (error = e) });

    http.expectOne('/api/rates?base=USD')
        .flush({ message: 'slow down' }, { status: 429, statusText: 'Too Many Requests' });

    expect(error).toBeInstanceOf(RateLimitedError);
  });
});

http.verify() in afterEach is the detail worth copying — it turns an unasserted request into a failure rather than a silent pass.

#fakeAsync and tick

typescript
import { fakeAsync, tick, flush } from '@angular/core/testing';

it('debounces the search by 300ms', fakeAsync(() => {
  const search = jasmine.createSpy('search');
  component.queryChanged('fie');
  component.queryChanged('field');

  tick(299);
  expect(search).not.toHaveBeenCalled();

  tick(1);
  expect(search).toHaveBeenCalledOnceWith('field');

  flush();                    // drain anything still pending, or the test fails
}));

fakeAsync gives a synthetic clock — no waiting, fully deterministic. It is the right tool for debounce, polling and retry backoff, and it removes a common source of flakiness.

#Signals

typescript
it('derives the total from the lines signal', () => {
  const component = TestBed.createComponent(BasketComponent).componentInstance;

  component.lines.set([{ sku: 'book-1', quantity: 2, unitCents: 1_200 }]);

  // Computed signals are pull-based: reading is enough, no detectChanges.
  expect(component.totalCents()).toBe(2_400);
});

Signals make a large part of Angular testing simpler, because state becomes readable without a fixture and without change detection.

#Testing Library for Angular

If the detectChanges ceremony grates, @testing-library/angular removes most of it and brings the same query API as everywhere else:

typescript
import { render, screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';

test('recalculates when the quantity changes', async () => {
  await render(BasketLineComponent, {
    inputs: { unitCents: 1_200, quantity: 1 },
    providers: [{ provide: PricingService, useClass: StubPricingService }]
  });

  await userEvent.clear(screen.getByRole('spinbutton', { name: 'Quantity' }));
  await userEvent.type(screen.getByRole('spinbutton', { name: 'Quantity' }), '3');

  expect(await screen.findByText('£36.00')).toBeVisible();
});

Change detection is handled, queries are by role and name, and the test looks like its React and Vue equivalents. For most component tests this is the better default; keep raw TestBed for directives, pipes and anything needing the injector directly.

#What not to test

  • That @Input() binding works — that is Angular's test, not yours
  • Template syntax — the compiler checks it
  • Lifecycle hook call order
  • Private methods; test through the template or extract a service

Common questions

Why does my Angular test not show the updated value?
Change detection has not run. Angular does not update the DOM automatically in a test the way it does in an application — you must call fixture.detectChanges() after anything that changes state, or use the auto-detect configuration.
What is the difference between fakeAsync and waitForAsync?
fakeAsync gives you a synthetic clock so you can call tick() and advance timers deterministically. waitForAsync waits for genuinely asynchronous work to settle. Use fakeAsync for timers and debounce; use waitForAsync or await fixture.whenStable() for promises.
Should I use TestBed or test services as plain classes?
Plain classes wherever possible — a service with constructor dependencies can just be constructed with doubles, which is faster and simpler. Use TestBed when you need Angular's injector, a component fixture or a directive.

Runnable samples for this page

last test results ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?