How To Test Components In Angular Using TestBed

⚡ Using Modern Angular (v17+ & Vitest)? Jump directly to Modern Component Testing with Standalone, Signals & Vitest to see the idiomatic, lightning-fast test setup.
When talking about unit testing, the classic textbook definition is to test the smallest isolated units of an app, like pure functions or isolated classes. That sounds simple in theory, but in real-world frontend development, components have templates, dependency injection, lifecycle hooks, signal bindings, and child components.
If you don't know how to set up TestBed properly, your tests will quickly throw missing provider errors (NullInjectorError) or unknown element errors (NG0304).
In this practical guide, I will show you how to test Angular components step by step: configuring TestBed, mocking injected services, isolating child components safely, and transitioning from legacy Jasmine/Karma setups to modern Vitest.
The Scenario: An NBA Player List Component
Let's imagine we have a component that displays a list of NBA players fetched from an API via PlayerService. It delegates rendering each player card to a child component <app-player>.
Here is the component implementation in both legacy and modern syntax:
1. Legacy Angular (v2 – v16: NgModule & Constructor DI)
// player-list.component.ts (Legacy)
import { Component, OnInit } from '@angular/core';
import { PlayerService } from '../services/player.service';
import { Player } from '../models/player';
@Component({
selector: 'app-player-list',
templateUrl: './player-list.component.html',
})
export class PlayerListComponent implements OnInit {
players: Player[] = [];
constructor(private playerService: PlayerService) {}
ngOnInit(): void {
this.playerService.getPlayers().subscribe((res) => {
this.players = res.data;
});
}
}<!-- player-list.component.html (Legacy) -->
<div *ngFor="let player of players" class="player-card">
<app-player [player]="player"></app-player>
</div>2. Modern Angular (v17+ / v19: Standalone, Signals & inject())
// player-list.component.ts (Modern Standalone & Signals)
import { Component, inject, OnInit, signal } from '@angular/core';
import { PlayerService } from '../services/player.service';
import { Player } from '../models/player';
import { PlayerComponent } from '../player/player.component';
@Component({
selector: 'app-player-list',
standalone: true,
imports: [PlayerComponent],
template: `
@for (player of players(); track player.id) {
<div class="player-card">
<app-player [player]="player" />
</div>
} @empty {
<p>No players found.</p>
}
`,
})
export class PlayerListComponent implements OnInit {
private playerService = inject(PlayerService);
players = signal<Player[]>([]);
ngOnInit(): void {
this.playerService.getPlayers().subscribe((res) => {
this.players.set(res.data);
});
}
}Now let's see how we configure TestBed to test this component without making real HTTP requests or breaking child templates.
What is TestBed and How Does It Work?
TestBed is Angular's primary testing API. It creates a dynamic Angular testing module (DynamicTestModule) that mimics an Angular environment in memory.
The core workflow consists of two phases:
TestBed.configureTestingModule(...): Configures the imports, providers, and component under test.TestBed.createComponent(Component): Instantiates the component and returns aComponentFixture.
The ComponentFixture gives you access to:
fixture.componentInstance: The TypeScript component class instance (for checking properties, signals, and methods).fixture.nativeElement: The root HTML DOM element of the component.fixture.debugElement: Angular's abstraction over the DOM with querying helpers (query(By.css(...))).fixture.detectChanges(): Manually triggers change detection and runs template bindings.
Step-by-Step: Testing with Legacy Jasmine & Karma
Let's review the traditional Jasmine setup first so you understand how dependency injection errors happen.
Step 1: Basic TestBed Setup
// player-list.component.spec.ts (Jasmine)
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PlayerListComponent } from './player-list.component';
import { PlayerService } from '../services/player.service';
describe('PlayerListComponent (Jasmine)', () => {
let fixture: ComponentFixture<PlayerListComponent>;
let component: PlayerListComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PlayerListComponent],
providers: [
// If we don't provide PlayerService here, we get NullInjectorError!
],
}).compileComponents();
fixture = TestBed.createComponent(PlayerListComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
});If you run this test, it will fail with:
NullInjectorError: No provider for PlayerService!Step 2: Providing a Mock Service with Jasmine Spies
To prevent Angular from creating the real service (which might try to make real HTTP calls), provide a mock using jasmine.createSpyObj:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { PlayerListComponent } from './player-list.component';
import { PlayerService } from '../services/player.service';
describe('PlayerListComponent with Mock Service', () => {
let fixture: ComponentFixture<PlayerListComponent>;
let mockPlayerService: jasmine.SpyObj<PlayerService>;
const FAKE_PLAYERS = {
data: [
{ id: 1, name: 'LeBron James' },
{ id: 2, name: 'Stephen Curry' },
],
};
beforeEach(async () => {
mockPlayerService = jasmine.createSpyObj<PlayerService>('PlayerService', ['getPlayers']);
mockPlayerService.getPlayers.and.returnValue(of(FAKE_PLAYERS));
await TestBed.configureTestingModule({
declarations: [PlayerListComponent],
providers: [
{ provide: PlayerService, useValue: mockPlayerService },
],
}).compileComponents();
fixture = TestBed.createComponent(PlayerListComponent);
});
it('should fetch players on ngOnInit', () => {
fixture.detectChanges(); // Triggers ngOnInit()
expect(mockPlayerService.getPlayers).toHaveBeenCalledTimes(1);
expect(fixture.componentInstance.players.length).toBe(2);
});
});Step 3: Handling the Child Component (<app-player>)
When you run fixture.detectChanges(), Angular parses the HTML template and encounters <app-player>. If it's not declared in the test module, you'll see:
Error: NG0304: 'app-player' is not a known elementNever use NO_ERRORS_SCHEMA to fix this! It disables template validation completely and hides typos. Instead, declare a lightweight stub component:
import { Component, Input } from '@angular/core';
import { By } from '@angular/platform-browser';
// 1. Lightweight stub component
@Component({
selector: 'app-player',
template: '<div class="player-stub">{{ player?.name }}</div>',
})
class MockPlayerComponent {
@Input() player: any;
}
// 2. Add to declarations
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [PlayerListComponent, MockPlayerComponent],
providers: [{ provide: PlayerService, useValue: mockPlayerService }],
}).compileComponents();
});
it('should render two player cards in the DOM', () => {
fixture.detectChanges();
const renderedPlayers = fixture.debugElement.queryAll(By.css('.player-stub'));
expect(renderedPlayers.length).toBe(2);
});Modern Component Testing with Standalone, Signals & Vitest
In modern Angular (v18 and v19+), Vitest is supported natively via the @angular/build test builder. Vitest runs tests directly in Node with ESM without spinning up a heavy Chrome browser instance, making test suites run in fractions of a second.
Here is how you test a modern Standalone component with Signals using Vitest:
// player-list.component.spec.ts (Modern Angular + Vitest)
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, input } from '@angular/core';
import { By } from '@angular/platform-browser';
import { of } from 'rxjs';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PlayerListComponent } from './player-list.component';
import { PlayerService } from '../services/player.service';
import { Player } from '../models/player';
// 1. Modern Standalone Mock Component using Signal inputs
@Component({
selector: 'app-player',
standalone: true,
template: '<div class="player-stub">{{ player().name }}</div>',
})
class MockPlayerComponent {
player = input.required<Player>();
}
describe('PlayerListComponent (Vitest + Standalone)', () => {
let fixture: ComponentFixture<PlayerListComponent>;
let component: PlayerListComponent;
// 2. Mock service using Vitest vi.fn()
const mockPlayerService = {
getPlayers: vi.fn(),
};
const MOCK_DATA = {
data: [
{ id: 1, name: 'Luka Dončić' },
{ id: 2, name: 'Giannis Antetokounmpo' },
],
};
beforeEach(async () => {
vi.clearAllMocks();
mockPlayerService.getPlayers.mockReturnValue(of(MOCK_DATA));
await TestBed.configureTestingModule({
// Standalone components go in 'imports', NOT 'declarations'
imports: [PlayerListComponent],
providers: [
{ provide: PlayerService, useValue: mockPlayerService },
],
})
// Override the real child component with our mock
.overrideComponent(PlayerListComponent, {
remove: { imports: [] },
add: { imports: [MockPlayerComponent] },
})
.compileComponents();
fixture = TestBed.createComponent(PlayerListComponent);
component = fixture.componentInstance;
});
it('should instantiate the standalone component', () => {
expect(component).toBeTruthy();
});
it('should populate the players signal on init', () => {
fixture.detectChanges();
expect(mockPlayerService.getPlayers).toHaveBeenCalledTimes(1);
// Reading signal values directly in assertions
expect(component.players().length).toBe(2);
expect(component.players()[0].name).toBe('Luka Dončić');
});
it('should render the list of players in the DOM with @for', () => {
fixture.detectChanges();
const playerNodes = fixture.debugElement.queryAll(By.css('.player-stub'));
expect(playerNodes.length).toBe(2);
expect(playerNodes[0].nativeElement.textContent).toContain('Luka Dončić');
});
});How to Test Signal Inputs (input() and model())
If you want to test a child component that accepts a Signal input(), you don't need complex wrapper components anymore. You can update signal inputs directly via fixture.componentRef.setInput:
// player.component.spec.ts (Testing Signal Inputs with Vitest)
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { PlayerComponent } from './player.component';
describe('PlayerComponent (Signal Inputs)', () => {
let fixture: ComponentFixture<PlayerComponent>;
let component: PlayerComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PlayerComponent],
}).compileComponents();
fixture = TestBed.createComponent(PlayerComponent);
component = fixture.componentInstance;
});
it('should render updated player details when input signal changes', () => {
// Set input signal directly
fixture.componentRef.setInput('player', { id: 10, name: 'Nikola Jokić' });
fixture.detectChanges();
const nameElement = fixture.nativeElement.querySelector('.player-name');
expect(nameElement.textContent).toContain('Nikola Jokić');
});
});Jasmine vs Vitest Comparison Cheat Sheet
| Feature | Legacy Jasmine & Karma | Modern Vitest |
|---|---|---|
| Execution Engine | Headless Chrome browser (slow start) | Native Node.js ESM (sub-second execution) |
| Mock Functions | jasmine.createSpy(), spyOn() | vi.fn(), vi.spyOn() |
| Clear Mocks | jasmine.restoreAllMocks() | vi.clearAllMocks(), vi.resetAllMocks() |
| Async Timers | jasmine.clock().tick(100) | vi.advanceTimersByTime(100) |
| Standalone Setup | declarations: [MyComp] | imports: [MyComp] |
| Input Testing | component.myInput = val | fixture.componentRef.setInput('myInput', val) |
Conclusion
Testing Angular components with TestBed gives you full control over change detection, dependency injection, and DOM assertions.
By migrating from legacy NgModule declarations and Jasmine/Karma to Standalone components and Vitest, you eliminate browser overhead, get instant feedback in your terminal, and write clean tests for Signal inputs and modern control flow.
These are my experiences learning and facing my daily challenges working with Angular.
Frequently Asked Questions
How do I configure TestBed for Standalone components in Angular?
Import the standalone component directly in the imports array of TestBed.configureTestingModule({ imports: [MyComponent], providers: [...] }) rather than declaring it.
How do you test Angular components using Vitest instead of Jasmine and Karma?
Modern Angular supports Vitest out of the box. Use vi.fn() and vi.spyOn() for service mocks, import TestBed from @angular/core/testing, and run tests natively in ESM without launching a headless browser.
How do you mock child components when testing an Angular component?
You can provide a lightweight Standalone dummy component with the same selector and input() signals, or use TestBed.overrideComponent() to replace child dependencies without using NO_ERRORS_SCHEMA.
How do you test Signal inputs and state in Angular components?
Use fixture.componentRef.setInput('propName', value) to update signal inputs, call fixture.detectChanges(), and assert either the DOM output or the component's computed signal values.
Related Articles
How and When to Use Mocks vs Spies in Unit Testing (Vitest, Jest, and Jasmine)
Learn the practical differences between Mocks and Spies in unit testing. Explore practical examples using modern Vitest (vi.fn, vi.spyOn), Jest, and Jasmine in Angular.
Testing Components In Angular: NO_ERRORS_SCHEMA, Stub Components, and ng-mocks
Learn how to test Angular components with complex child dependencies. Compare NO_ERRORS_SCHEMA, manual stub components, and ng-mocks in both legacy and modern Standalone Angular.
Getting Started with Standalone Components in Angular (Complete Modern Guide)
Learn how to build module-less Angular applications using Standalone Components. Explore bootstrapApplication, app.config.ts, lazy loading with loadComponent, and Vitest unit testing.
How to Share Data Between Components in Angular (Signals & Services)
Learn the best patterns to share data between Angular components. Compare legacy Input/Output decorators and BehaviorSubjects with modern Signals, input(), output(), model(), and Signal-based services.
Share this article
If you found this guide helpful, consider sharing it with your team or fellow developers.
Real Software. Real Lessons.
I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.