Lesson 26 of 30
TestBed renders your component in a real (fake) DOM so you can assert on what it actually produces — and the tool running that assertion is quietly changing from Karma to Vitest.
16 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Testing an Angular component isn't about calling its methods directly and checking return values — a component's job is to produce DOM from state, so a real test has to render it and inspect the result. TestBed is Angular's harness for that: it builds a miniature Angular environment, compiles your component's template, and hands you a ComponentFixture wrapping a real (if headless) instance you can click, read, and re-render.
Testing a plain service is simpler — no DOM involved, so you often don't need TestBed at all. You can just instantiate the class directly, or use TestBed.inject() when the service itself depends on other injected things.
One honest tooling note: for years, Angular's default test runner was Karma, running specs inside a real browser. That's changing — Vitest is now the modern, officially-supported Angular test runner, and new projects increasingly default to it. TestBed and the testing mental model haven't changed at all — you're still rendering a fixture and asserting on it — only the process executing your spec file and reporting results has moved.
// counter.component.ts
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<button (click)="increment()">Count: {{ count() }}</button>
`,
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update((c) => c + 1);
}
}
// counter.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
it('increments the displayed count on click', async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent], // standalone components go in `imports`, not `declarations`
}).compileComponents();
const fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges(); // triggers the FIRST render
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
expect(button.textContent).toContain('Count: 0');
button.click();
fixture.detectChanges(); // re-render, now that the signal changed
expect(button.textContent).toContain('Count: 1');
});
});
Three things worth noticing. First, a standalone component is registered via imports, the same array you'd use in a real app — there's no separate declarations array anymore, because there's no NgModule gluing things together. Second, fixture.detectChanges() is called twice on purpose: once to trigger the initial render (nothing is on screen until you call it), and again after button.click() so the fixture picks up the fact that count changed and the template needs to re-run. Third, the test asserts on button.textContent — the actual rendered DOM — not on component.count() directly. You could read the signal directly, and sometimes that's the right call for a pure logic check, but asserting on what the user would actually see is what makes this a real component test instead of a disguised unit test of a method.
fixture.detectChanges() and being confused why the DOM is empty. Angular doesn't render a freshly-created fixture automatically — you have to ask for the first pass explicitly, which trips up anyone used to a framework that renders on mount by default.component.count.set(5) and asserting component.count() tests that a signal holds the value you just told it to hold — which is testing the signal API, not your component. Clicking the button and reading the DOM tests what a user actually experiences.describe/it/expect shape and the same TestBed API work under either runner. The migration is almost entirely a tooling/config change, not a rewrite of how you write assertions.This is the same core discipline as testing a React component: render it into a real (if simulated) DOM, interact with it the way a user would, then assert on what's visible rather than on private internals — the mechanics differ (TestBed/fixtures here, a render-and-query approach there) but the philosophy is identical. It also leans on the same closure-driven idea that resurfaces constantly in this platform: increment() closes over this.count, and the whole point of the test is verifying that captured state actually changes the thing on screen.