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:
- Unit tests — isolated logic tests using Jasmine + Karma (or Jest)
- Component tests — TestBed + ComponentFixture for component behavior
- 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 installplaywright.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.