Angular
·8 min read·↗

How To Test Components In Angular Using TestBed

Main cover illustration for article: How To Test Components In Angular Using TestBed
Summarize with AI:

⚡ 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:

  1. TestBed.configureTestingModule(...): Configures the imports, providers, and component under test.
  2. TestBed.createComponent(Component): Instantiates the component and returns a ComponentFixture.

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 element

Never 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

FeatureLegacy Jasmine & KarmaModern Vitest
Execution EngineHeadless Chrome browser (slow start)Native Node.js ESM (sub-second execution)
Mock Functionsjasmine.createSpy(), spyOn()vi.fn(), vi.spyOn()
Clear Mocksjasmine.restoreAllMocks()vi.clearAllMocks(), vi.resetAllMocks()
Async Timersjasmine.clock().tick(100)vi.advanceTimersByTime(100)
Standalone Setupdeclarations: [MyComp]imports: [MyComp]
Input Testingcomponent.myInput = valfixture.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.

Part of the Angular Series

These are my experiences learning and facing my daily challenges working with Angular.

View Entire Series

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

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.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.