Angular Component Testing with TestBed — Complete Guide

Angular Component Testing with TestBed — Complete Guide

Angular's TestBed is the primary tool for unit testing components. It creates a mini Angular module in memory, compiles your component, and lets you interact with it the same way Angular's runtime would. This guide walks through every pattern you'll hit in real projects.

Setting Up TestBed

Every Angular component test starts with TestBed.configureTestingModule. This call mirrors NgModule — you declare the component under test and import whatever it depends on.

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { UserCardComponent } from './user-card.component';

describe('UserCardComponent', () => {
  let component: UserCardComponent;
  let fixture: ComponentFixture<UserCardComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [UserCardComponent],
      imports: [],
    }).compileComponents();

    fixture = TestBed.createComponent(UserCardComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

compileComponents() is async because Angular needs to fetch external template and style files. Always await it. fixture.detectChanges() runs the initial change detection cycle — without it, ngOnInit doesn't fire and bindings aren't evaluated.

For standalone components (Angular 14+), the setup changes slightly:

await TestBed.configureTestingModule({
  imports: [UserCardComponent], // standalone component goes in imports
}).compileComponents();

Testing Component Rendering

Once the fixture exists, query the DOM using Angular's By.css helper or the native nativeElement.

it('renders the user name', () => {
  component.user = { name: 'Alice', email: 'alice@example.com' };
  fixture.detectChanges();

  const nameEl = fixture.debugElement.query(By.css('[data-testid="user-name"]'));
  expect(nameEl.nativeElement.textContent.trim()).toBe('Alice');
});

it('shows a placeholder when no user is set', () => {
  component.user = null;
  fixture.detectChanges();

  const placeholder = fixture.nativeElement.querySelector('.empty-state');
  expect(placeholder).not.toBeNull();
});

Prefer data-testid attributes over CSS classes or element types. Classes change with styling refactors; test IDs communicate intent and stay stable.

fixture.debugElement is the Angular-aware wrapper. It understands directives and Angular-specific traversal. fixture.nativeElement is the raw DOM element — fine for simple queries, but debugElement gives you more power when you need it.

Testing Input and Output Bindings

Component inputs and outputs are the public API of your component. Test them explicitly.

// Component definition
@Component({
  selector: 'app-rating',
  template: `
    <div class="stars">
      <button *ngFor="let star of stars; let i = index"
              [class.filled]="i < value"
              (click)="onStarClick(i + 1)"
              [attr.data-testid]="'star-' + (i + 1)">
        ★
      </button>
    </div>
  `
})
export class RatingComponent {
  @Input() value = 0;
  @Input() max = 5;
  @Output() valueChange = new EventEmitter<number>();

  get stars() { return Array(this.max); }

  onStarClick(rating: number) {
    this.valueChange.emit(rating);
  }
}

// Tests
describe('RatingComponent', () => {
  let component: RatingComponent;
  let fixture: ComponentFixture<RatingComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [RatingComponent],
      imports: [CommonModule],
    }).compileComponents();

    fixture = TestBed.createComponent(RatingComponent);
    component = fixture.componentInstance;
  });

  it('renders the correct number of stars based on max input', () => {
    component.max = 3;
    fixture.detectChanges();

    const stars = fixture.debugElement.queryAll(By.css('button'));
    expect(stars.length).toBe(3);
  });

  it('marks stars as filled up to the value input', () => {
    component.value = 3;
    component.max = 5;
    fixture.detectChanges();

    const filledStars = fixture.debugElement.queryAll(By.css('button.filled'));
    expect(filledStars.length).toBe(3);
  });

  it('emits valueChange when a star is clicked', () => {
    component.max = 5;
    fixture.detectChanges();

    let emittedValue: number | undefined;
    component.valueChange.subscribe((v: number) => (emittedValue = v));

    const fourthStar = fixture.debugElement.query(By.css('[data-testid="star-4"]'));
    fourthStar.nativeElement.click();

    expect(emittedValue).toBe(4);
  });
});

Subscribe to output EventEmitters directly on the component instance — no need for spy wrappers. Clicking DOM elements via .click() triggers Angular's event binding pipeline.

Change Detection

Angular's default change detection is CheckAlways — every change detection cycle checks every component in the tree. But components using ChangeDetectionStrategy.OnPush only update when inputs change by reference or events fire.

@Component({
  selector: 'app-summary',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ data.label }}</p>`
})
export class SummaryComponent {
  @Input() data!: { label: string };
}

Testing OnPush components requires triggering change detection after each input change:

it('updates the label when data input changes by reference', () => {
  component.data = { label: 'Initial' };
  fixture.detectChanges();

  expect(fixture.nativeElement.querySelector('p').textContent).toBe('Initial');

  // Mutating the existing object won't trigger OnPush
  // This would fail: component.data.label = 'Updated';

  // Replace the reference instead
  component.data = { label: 'Updated' };
  fixture.detectChanges();

  expect(fixture.nativeElement.querySelector('p').textContent).toBe('Updated');
});

fixture.detectChanges() is your manual trigger. Call it after every state change that should produce a DOM update. In some tests you want to call fixture.autoDetectChanges(true) instead, which wires up automatic detection — useful when testing event cascades that trigger multiple cycles.

Async Testing with fakeAsync and waitForAsync

Real components make HTTP calls, use timers, or await promises. Angular provides two utilities for keeping tests synchronous despite async operations.

fakeAsync

fakeAsync wraps your test in a special zone that controls time. Pending timers and microtasks don't execute until you call tick() or flush().

import { fakeAsync, tick } from '@angular/core/testing';

@Component({
  selector: 'app-debounced-search',
  template: `<input (input)="onInput($event)" /><p>{{ result }}</p>`
})
export class DebouncedSearchComponent {
  result = '';

  onInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    setTimeout(() => {
      this.result = `Results for: ${value}`;
    }, 300);
  }
}

it('updates result after 300ms debounce', fakeAsync(() => {
  fixture.detectChanges();

  const input = fixture.nativeElement.querySelector('input');
  input.value = 'angular';
  input.dispatchEvent(new Event('input'));

  // Result hasn't updated yet — timer is pending
  expect(component.result).toBe('');

  tick(300); // advance fake clock by 300ms
  fixture.detectChanges();

  expect(component.result).toBe('Results for: angular');
}));

Use flush() instead of tick(N) when you don't care about the exact duration — it drains all pending timers.

waitForAsync

waitForAsync handles promise-based async. It wraps the test in a special zone that waits for all pending async operations to complete.

import { waitForAsync } from '@angular/core/testing';

it('loads user data on init', waitForAsync(() => {
  const mockUser = { name: 'Bob', email: 'bob@example.com' };
  userServiceSpy.getUser.and.returnValue(Promise.resolve(mockUser));

  fixture.detectChanges(); // triggers ngOnInit

  fixture.whenStable().then(() => {
    fixture.detectChanges();
    const nameEl = fixture.nativeElement.querySelector('[data-testid="user-name"]');
    expect(nameEl.textContent).toBe('Bob');
  });
}));

fixture.whenStable() returns a promise that resolves when all pending async activity in the component settles. Chain assertions off it rather than adding arbitrary delays.

Testing with Dependencies

Most components depend on services. Provide mock implementations through TestBed to avoid hitting real APIs.

@Injectable({ providedIn: 'root' })
export class ProductService {
  getProducts(): Observable<Product[]> {
    return this.http.get<Product[]>('/api/products');
  }
}

@Component({
  selector: 'app-product-list',
  template: `
    <div *ngIf="loading">Loading...</div>
    <ul *ngIf="!loading">
      <li *ngFor="let p of products" [attr.data-testid]="'product-' + p.id">
        {{ p.name }}
      </li>
    </ul>
    <p *ngIf="error" class="error">{{ error }}</p>
  `
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  loading = true;
  error = '';

  constructor(private productService: ProductService) {}

  ngOnInit() {
    this.productService.getProducts().subscribe({
      next: (products) => {
        this.products = products;
        this.loading = false;
      },
      error: () => {
        this.error = 'Failed to load products';
        this.loading = false;
      }
    });
  }
}

describe('ProductListComponent', () => {
  let fixture: ComponentFixture<ProductListComponent>;
  let productServiceSpy: jasmine.SpyObj<ProductService>;

  beforeEach(async () => {
    productServiceSpy = jasmine.createSpyObj('ProductService', ['getProducts']);

    await TestBed.configureTestingModule({
      declarations: [ProductListComponent],
      imports: [CommonModule],
      providers: [
        { provide: ProductService, useValue: productServiceSpy }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(ProductListComponent);
  });

  it('renders products returned by the service', () => {
    const mockProducts = [
      { id: 1, name: 'Widget A' },
      { id: 2, name: 'Widget B' },
    ];
    productServiceSpy.getProducts.and.returnValue(of(mockProducts));

    fixture.detectChanges();

    const items = fixture.debugElement.queryAll(By.css('li'));
    expect(items.length).toBe(2);
    expect(items[0].nativeElement.textContent.trim()).toBe('Widget A');
  });

  it('shows an error message when the service fails', () => {
    productServiceSpy.getProducts.and.returnValue(
      throwError(() => new Error('Network error'))
    );

    fixture.detectChanges();

    const error = fixture.nativeElement.querySelector('.error');
    expect(error.textContent).toBe('Failed to load products');
  });

  it('hides the loading indicator after data loads', () => {
    productServiceSpy.getProducts.and.returnValue(of([]));

    fixture.detectChanges();

    const loading = fixture.nativeElement.querySelector('div');
    expect(loading).toBeNull();
  });
});

Create spy objects with jasmine.createSpyObj and provide them with useValue. The spy replaces the real service — no HTTP calls, no side effects, deterministic behavior.

Shallow vs Deep Rendering

By default, TestBed compiles all child components. If a component uses <app-avatar> inside, you need to declare AvatarComponent too — or mock it.

Use NO_ERRORS_SCHEMA to skip unknown elements entirely (shallow rendering):

import { NO_ERRORS_SCHEMA } from '@angular/core';

await TestBed.configureTestingModule({
  declarations: [ProductListComponent], // only the component under test
  schemas: [NO_ERRORS_SCHEMA], // ignore unknown child elements
  providers: [{ provide: ProductService, useValue: productServiceSpy }]
}).compileComponents();

This trades fidelity for isolation. Use it when you're testing the parent component's logic and don't care about child rendering. Use full compilation (no schema) when you want to verify integration between parent and child.

What to Test and What to Skip

Test the component's contract — what it renders given inputs, what events it emits given user actions, how it reacts to service responses. Skip implementation details like private method calls, internal state that doesn't affect the view, or framework internals.

A good rule: if you can change the implementation without breaking any test, the test was probably covering the wrong thing. If you can break the component's behavior without failing any test, you're missing coverage.


Angular component tests give you fast, deterministic feedback. They run in milliseconds and don't need a browser. But they can only verify what a component does in isolation — for verifying that your entire application works together end-to-end, you need something broader.

That's where tools like HelpMeTest come in. HelpMeTest runs end-to-end tests continuously against your deployed application, written in plain English. Angular unit tests catch regressions at the component level; HelpMeTest catches them at the user experience level — the two work together rather than compete.

Read more

Start now free