Angular Testing Guide: Unit Tests with TestBed, Component Tests, and Playwright E2E

Angular Testing Guide: Unit Tests with TestBed, Component Tests, and Playwright E2E

Angular ships with a complete testing setup: Jasmine as the test framework, Karma as the test runner, and TestBed as the Angular testing module that lets you configure and instantiate components and services the same way the Angular runtime does. For E2E tests, the Angular team now recommends Playwright over Protractor (which is deprecated). This guide covers all three layers.

Key Takeaways

TestBed is Angular's dependency injection container for tests. It creates a test module where you declare components, provide services, and import Angular modules — just like a real NgModule. Without TestBed, you can't properly test components that depend on DI.

compileComponents() must be awaited for external templates. If your component uses templateUrl or styleUrls, call await TestBed.compileComponents() in beforeEach. Skip it only for inline templates.

fixture.detectChanges() runs change detection. Angular doesn't auto-update the DOM in tests. Call detectChanges() after setting component state or triggering actions to see the result in the template.

fakeAsync + tick() handles async code without real timers. Wrap your test in fakeAsync, use tick(ms) to advance virtual time. No more setTimeout(() => {}, 0) hacks.

Karma is being replaced. The Angular team is phasing out Karma. New projects should use @angular-builders/jest (Jest) or @analogjs/vite-plugin-angular (Vitest) instead.

Angular Testing Architecture

Angular's testing stack has three distinct layers:

  1. Unit tests — isolated logic tests using Jasmine + Karma (or Jest)
  2. Component tests — TestBed + ComponentFixture for component behavior
  3. E2E tests — Playwright for full application flows in a browser

New Angular projects use ng generate to scaffold test files alongside components, services, and pipes. Every generated file gets a .spec.ts companion.

Running Tests

ng test                  <span class="hljs-comment"># Run unit tests (Karma, watch mode)
ng <span class="hljs-built_in">test --no-watch       <span class="hljs-comment"># Single run (CI)
ng <span class="hljs-built_in">test --code-coverage  <span class="hljs-comment"># With Istanbul coverage
ng e2e                   <span class="hljs-comment"># E2E tests (requires e2e setup)

Unit Testing with TestBed

Component Setup

// src/app/components/user-card/user-card.component.ts
@Component({
  selector: 'app-user-card',
  template: `
    <div class="user-card">
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
      <button (click)="onDelete()">Delete</button>
    </div>
  `,
})
export class UserCardComponent {
  @Input() user!: { id: string; name: string; email: string };
  @Output() delete = new EventEmitter<string>();

  onDelete() {
    this.delete.emit(this.user.id);
  }
}
// src/app/components/user-card/user-card.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserCardComponent } from './user-card.component';

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

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

    fixture = TestBed.createComponent(UserCardComponent);
    component = fixture.componentInstance;
    
    // Set required input
    component.user = { id: '1', name: 'Alice', email: 'alice@example.com' };
    fixture.detectChanges();  // triggers ngOnInit and initial rendering
  });

  it('displays the user name', () => {
    const h2 = fixture.nativeElement.querySelector('h2');
    expect(h2.textContent).toBe('Alice');
  });

  it('displays the user email', () => {
    const p = fixture.nativeElement.querySelector('p');
    expect(p.textContent).toBe('alice@example.com');
  });

  it('emits delete event with user id on button click', () => {
    const deleteSpy = jasmine.createSpy('delete');
    component.delete.subscribe(deleteSpy);

    fixture.nativeElement.querySelector('button').click();
    fixture.detectChanges();

    expect(deleteSpy).toHaveBeenCalledWith('1');
  });
});

Testing with Standalone Components (Angular 14+)

// Standalone component (no NgModule)
@Component({
  standalone: true,
  selector: 'app-badge',
  imports: [CommonModule],
  template: `<span [class]="'badge badge--' + variant">{{ label }}</span>`,
})
export class BadgeComponent {
  @Input() label = '';
  @Input() variant: 'success' | 'warning' | 'error' = 'success';
}
beforeEach(async () => {
  await TestBed.configureTestingModule({
    imports: [BadgeComponent],  // Import, not declare standalone components
  }).compileComponents();
  
  // ... rest of setup
});

Testing Services

// src/app/services/user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
  constructor(private http: HttpClient) {}

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>('/api/users');
  }

  getUserById(id: string): Observable<User> {
    return this.http.get<User>(`/api/users/${id}`);
  }
}
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService],
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    httpMock.verify();  // Ensure no outstanding requests
  });

  it('returns users from GET /api/users', () => {
    const mockUsers = [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
    
    service.getUsers().subscribe(users => {
      expect(users).toEqual(mockUsers);
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);
  });

  it('handles HTTP errors', () => {
    service.getUsers().subscribe({
      error: (error) => {
        expect(error.status).toBe(404);
      },
    });

    const req = httpMock.expectOne('/api/users');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });
  });
});

Testing Async Code with fakeAsync

Angular's fakeAsync zone lets you control async execution:

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

it('shows success message after 2 seconds', fakeAsync(() => {
  component.submitForm();
  
  // No observable yet — we're in fakeAsync
  expect(component.showSuccess).toBe(false);
  
  tick(2000);  // advance 2 seconds
  fixture.detectChanges();
  
  expect(component.showSuccess).toBe(true);
}));

it('debounces search input', fakeAsync(() => {
  component.searchTerm = 'angular';
  
  // Search is debounced by 300ms
  tick(299);
  expect(mockSearchService.search).not.toHaveBeenCalled();
  
  tick(1);  // total: 300ms
  expect(mockSearchService.search).toHaveBeenCalledWith('angular');
}));

Use flush() instead of tick() when you don't know the exact delay:

it('resolves all pending timers', fakeAsync(() => {
  component.loadData();
  flush();  // resolves all timers
  fixture.detectChanges();
  expect(component.data).toBeTruthy();
}));

Testing Router Navigation

import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';

describe('LoginComponent', () => {
  let router: Router;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [
        RouterTestingModule.withRoutes([
          { path: 'dashboard', component: DashboardComponent },
        ]),
      ],
      declarations: [LoginComponent],
    }).compileComponents();

    router = TestBed.inject(Router);
  });

  it('navigates to dashboard after successful login', fakeAsync(async () => {
    const navigateSpy = spyOn(router, 'navigate');
    
    component.email = 'alice@example.com';
    component.password = 'password123';
    
    await component.onSubmit();
    
    expect(navigateSpy).toHaveBeenCalledWith(['/dashboard']);
  }));
});

E2E Testing with Playwright

Add Playwright to an Angular project:

npm install --save-dev @playwright/test
npx playwright install

playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:4200',
  },
  webServer: {
    command: 'ng serve',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
  },
});

E2E tests:

// e2e/users.spec.ts
import { test, expect } from '@playwright/test';

test('displays user list on the users page', async ({ page }) => {
  await page.goto('/users');
  
  await expect(page.getByTestId('user-list')).toBeVisible();
  await expect(page.getByTestId('user-card')).toHaveCount(3);  // seeded data
});

test('creates a new user', async ({ page }) => {
  await page.goto('/users');
  
  await page.getByTestId('create-user-btn').click();
  await page.getByLabel('Name').fill('Bob Smith');
  await page.getByLabel('Email').fill('bob@example.com');
  await page.getByTestId('submit-btn').click();
  
  await expect(page.getByText('Bob Smith')).toBeVisible();
});

What's Covered in This Series

This guide provides the foundation. The other posts in this cluster go deeper:

  • TestBed deep dive — module setup, DI, fakeAsync, marble testing
  • Component testing — inputs/outputs, ChangeDetectionStrategy, template bindings
  • Service testing — HttpClientTestingModule, mocking dependencies
  • Spectator — reducing TestBed boilerplate
  • Playwright for Angular — full E2E testing guide

HelpMeTest monitors your deployed Angular application 24/7 with automated tests. Start free — 10 tests, no credit card.

Read more