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
}
});| Question | Answer | Note |
|---|---|---|
| What do I import? | The component under test | Standalone 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 directly | A pipe is a pure function with an interface |
| How do I fake HTTP? | HttpTestingController | Assert on the request, then flush a response |
| How do I test router navigation? | RouterTestingHarness | Gives 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');
});| Level | Tool | What it is good at |
|---|---|---|
| Pure logic | Plain assertions | Signals, pipes, validators, reducers |
| Component | TestBed + DOM assertions | Rendering, binding, user-visible output |
| Service with HTTP | HttpTestingController | Request shape, retries, error mapping |
| Routing | RouterTestingHarness | Guards, resolvers, lazy loading |
| Critical flows | Playwright | Real browser, real network, real storage |
| Visual regressions | Screenshot comparison | Layout 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()inafterEach; a test that forgets an outstanding request can pass while leaking state into the next one. - For a zoneless app, never use
fakeAsyncandtickon 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.
Related
Advanced reactive and signal forms Server rendering, hydration and deployment
Last refreshed 2026-09-18.