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
// 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
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:
TestBed.configureTestingModule({
providers: [provideExperimentalZonelessChangeDetection()]
});
// then: await fixture.whenStable() instead of detectChanges()#HTTP
HttpTestingController is one of the best-designed testing utilities in any
framework.
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
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
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:
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 ↗- TypeScript (Angular)
angular/src/web-frameworks/angular-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Testing LibraryThe query API shared across React, Vue, Angular and Svelte — its guiding principle, the queries, user-event, and the mistakes it is designed to prevent.
- DOM TestingTesting the layer every front-end framework produces — queries by role and accessible name, event simulation, jsdom's limits, and why selector choice decides maintainability.
- Dependency Injection for TestabilityWhy injected dependencies are the one technique that makes isolated testing possible, the three forms of injection, and how to do it without a container.
- JestThe default JavaScript test runner — configuration, projects, snapshot testing, fake timers, and the choices that keep a large Jest suite fast.
- Testing ReactComponents, hooks, context, server components and async state — what to test in a React application, what to leave alone, and how to avoid act warnings.