Ch. 4 · Angular

Angular Component Tests That Verify Behavior

Test an Angular component through its DOM, inputs, outputs and HTTP calls, so refactors pass and real bugs fail. Five tests, run and broken.

~6 min readadvancedupdated Oct 7, 2026

A component test can check two different things. It can check behaviour: given these inputs and this user action, the screen shows this, the component emits that, and this request goes to the server. Or it can check implementation: this private method was called, this signal holds this array, this service method was spied on. Both kinds pass on the day you write them. They differ on the day someone refactors.

Interviewers who ask “how do you test Angular components?” want to hear that you know the difference and default to behaviour. This note writes a behaviour suite for a product search component, runs it, then makes two changes to the component (a harmless refactor and a real bug) to show what a good suite does with each. Everything was compiled with the Angular 20.3 AOT compiler and run with TestBed under Node; the pass/fail output is real. The same APIs exist in Angular 19, except where noted.

The component under test

@Component({
  selector: 'app-product-search',
  template: `
    <form (submit)="search($event)">
      <input name="q" [value]="query()" (input)="query.set($any($event.target).value)" />
      <button type="submit" [disabled]="loading()">Search</button>
    </form>
    @if (error()) { <p role="alert">Search failed. Try again.</p> }
    <ul>
      @for (p of results(); track p.id) {
        <li><button type="button" (click)="selected.emit(p)">{{ p.name }}</button></li>
      } @empty {
        @if (searched()) { <li class="empty">No products match "{{ query() }}"</li> }
      }
    </ul>
  `,
})
export class ProductSearch {
  limit = input(10);
  selected = output<Product>();
  private http = inject(HttpClient);
  query = signal('');
  results = signal<Product[]>([]);
  loading = signal(false);
  searched = signal(false);
  error = signal(false);

  search(event: Event) {
    event.preventDefault();
    this.loading.set(true);
    this.error.set(false);
    this.http
      .get<Product[]>('/api/products', { params: { q: this.query(), limit: this.limit() } })
      .subscribe({
        next: (r) => { this.results.set(r); this.searched.set(true); this.loading.set(false); },
        error: () => { this.error.set(true); this.loading.set(false); },
      });
  }
}
TypeScript

Its public surface is small: a limit input, a selected output, what it renders, and the request it makes. That’s what the tests should touch.

Setting up TestBed

beforeEach(async () => {
  TestBed.configureTestingModule({
    imports: [ProductSearch],
    providers: [provideZonelessChangeDetection(), provideHttpClient(), provideHttpClientTesting()],
  });
  fixture = TestBed.createComponent(ProductSearch);
  http = TestBed.inject(HttpTestingController);
  el = fixture.nativeElement;
  await fixture.whenStable();
});
TypeScript

A standalone component goes in imports. provideHttpClientTesting() replaces the real backend with one you control. provideZonelessChangeDetection() (its name since Angular 20, stable from 20.2; provideExperimentalZonelessChangeDetection() in 19) makes the test schedule change detection the way a zoneless app does, and await fixture.whenStable() waits for that to settle. That’s closer to production than calling fixture.detectChanges() by hand after every step, and it catches code that only works because a manual call happened to refresh the view.

Driving the component like a user

A small helper types into the input and submits the form, through the DOM:

async function searchFor(text: string) {
  const input = el.querySelector('input')!;
  input.value = text;
  input.dispatchEvent(new Event('input'));
  el.querySelector('form')!.dispatchEvent(new Event('submit'));
  await fixture.whenStable();
}
TypeScript

It doesn’t call component.search() or set component.query. If the template’s (input) binding were broken, a test that set the signal directly would still pass. Going through the DOM tests the wiring too.

The five tests

it('shows the products the API returns', async () => {
  await searchFor('lamp');
  const req = http.expectOne((r) => r.url === '/api/products');
  expect(req.request.params.get('q')).toBe('lamp');
  expect(req.request.params.get('limit')).toBe('10');
  req.flush([{ id: 1, name: 'Desk lamp' }, { id: 2, name: 'Floor lamp' }]);
  await fixture.whenStable();
  const names = [...el.querySelectorAll('li button')].map((b) => b.textContent!.trim());
  expect(names.join(', ')).toBe('Desk lamp, Floor lamp');
  http.verify();
});

it('uses the limit input in the request', async () => {
  fixture.componentRef.setInput('limit', 3);
  await searchFor('lamp');
  expect(http.expectOne((r) => r.url === '/api/products').request.params.get('limit')).toBe('3');
});

it('emits the product the user clicks', async () => {
  const picked: Product[] = [];
  fixture.componentInstance.selected.subscribe((p) => picked.push(p));
  await searchFor('lamp');
  http.expectOne((r) => r.url === '/api/products').flush([{ id: 7, name: 'Desk lamp' }]);
  await fixture.whenStable();
  el.querySelector<HTMLButtonElement>('li button')!.click();
  expect(picked[0].id).toBe(7);
});

it('says so when nothing matches', async () => { /* flush [] and read .empty */ });

it('shows an alert and re-enables the button when the API fails', async () => {
  await searchFor('lamp');
  expect(el.querySelector('button[type=submit]')!.hasAttribute('disabled')).toBe(true);
  http.expectOne((r) => r.url === '/api/products')
    .flush('down', { status: 503, statusText: 'Unavailable' });
  await fixture.whenStable();
  expect(el.querySelector('[role=alert]')!.textContent!.trim()).toBe('Search failed. Try again.');
  expect(el.querySelector('button[type=submit]')!.hasAttribute('disabled')).toBe(false);
});
TypeScript
  ✓ shows the products the API returns
  ✓ uses the limit input in the request
  ✓ emits the product the user clicks
  ✓ says so when nothing matches
  ✓ shows an alert and re-enables the button when the API fails
5 passed, 0 failed
Text

A few choices in there are worth defending in an interview:

  • setInput, not assignment. fixture.componentRef.setInput('limit', 3) goes through Angular’s input machinery, the same path as a parent’s binding. It works for signal inputs, which can’t be assigned at all, and it marks OnPush components dirty.
  • The request is part of the contract. expectOne with a predicate and assertions on params checks that the component asks the server for the right thing. http.verify() fails if any unexpected request was made.
  • The output is subscribed to, not spied on. selected.subscribe sees exactly what a parent’s (selected) handler would.
  • The failure path is tested. Flushing a 503 is one line, and it’s the path that most often has bugs.

Refactor versus regression

Now the interesting part. Two changes to the component.

A refactor: rename the results signal to products. Nothing a user or parent can see changes. All five tests passed. A test that asserted component.results().length would have failed to compile, and someone would have “fixed” the test rather than learning anything.

A bug: delete this.loading.set(false) from the error handler. After a failed search, the Search button stays disabled forever. The suite caught it:

  ✗ shows an alert and re-enables the button when the API fails
      expected false, got true
4 passed, 1 failed
Text

That’s the property to aim for: tests that fail when behaviour breaks and stay quiet when only the code shape changes. Spying on http.get with a stub, or asserting that loading() is false, can be made to pass both ways; checking the button the user sees cannot.

When to go further

Component harnesses. For components built on Angular Material or the CDK, harnesses (TestbedHarnessEnvironment.loader(fixture)) give a stable API like await select.open() instead of hunting for internal CSS classes that change between Material versions. You can write harnesses for your own components too.

Mocking child components. If a child is heavy (a chart, a map), override it with TestBed.overrideComponent(Parent, { remove: { imports: [Chart] }, add: { imports: [FakeChart] } }). Do this for cost, not by default; rendering real children tests the real integration.

What not to test here. Pure logic (price calculations, mappers) is faster and clearer as plain unit tests of functions. End-to-end flows across pages belong in Playwright or Cypress. Component tests sit between the two: one component, its template, and its immediate collaborators.

Interview practice

A colleague’s test for a login form does this: creates the component, sets component.email = 'a@b.com' and component.password = 'secret', calls component.submit(), and asserts expect(authService.login).toHaveBeenCalledWith('a@b.com', 'secret') using a jasmine spy. It passes. Later a bug ships where the submit button does nothing because its type attribute was changed to "button". Why didn’t the test catch it, and how would you rewrite it?

Answer

The test bypasses everything the user goes through. It sets fields on the class instead of typing into inputs, so it doesn’t verify the form bindings. It calls submit() directly instead of clicking the button or submitting the form, so it can’t notice that the button no longer triggers submission. It only proves that submit() passes two strings to a spy, which was never the part at risk.

I’d rewrite it to drive the DOM: set the input values and dispatch input events, click the actual submit button (or dispatch submit on the form), and await fixture.whenStable(). Then assert on the observable outcome. If login goes over HTTP, use provideHttpClientTesting() and expectOne('/api/login') to check the body, flush a success and assert the navigation or the emitted event; flush a 401 and assert the error message appears and the button is enabled again. If the auth service must be faked, I’d provide a small fake class through DI rather than spying on a real one, so the test doesn’t depend on internal method names. With that version, changing the button’s type makes the click do nothing, no request is made, and expectOne fails, which is exactly the regression that shipped.

Further reading

More in Angular

read ✓Angular · mid

Angular HTTP Request Contract Tests

Test an Angular service's HTTP contract with HttpTestingController: exact URLs and params, DTO mapping, 404 and network failures, and verify().

~6 min readread →
esc