Angular 21 replaced Karma with Vitest as the default testing framework. If you're used to Karma or Jest, this shift means new syntax, different patterns, and a fresh approach to testing, especially with Angular's signals and new control flow.
What You'll Learn
- Setting up Vitest in Angular 21
- Testing standalone components with signals and computed signals
- Working with Angular's control flow syntax (
@for,@if,@empty) - Understanding
fixture.detectChanges()for DOM updates - Testing user interactions and edge cases
We're testing a full-featured task manager: add tasks, mark them complete, filter by status, and delete them. All code is on GitHub.
Project Setup
Angular 21 includes Vitest out of the box. Create a new project and you're ready to go:
ng new angular-vitest-testing-guide
cd angular-vitest-testing-guide
Verify Vitest is Installed
Check package.json:
{
"devDependencies": {
"@angular/build": "^21.0.0",
"vitest": "^4.0.8"
}
}
Check angular.json:
{
"projects": {
"your-project-name": {
"architect": {
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
Run Tests
# Watch mode (default)
ng test
# Single run for CI
ng test --no-watch
Output should look like:
✓ angular-vitest-testing-guide src/app/app.spec.ts (2 tests) 130ms
✓ App (2)
✓ should create the app 93ms
✓ should render title 35ms
Test Files 1 passed (1)
Tests 2 passed (2)
Optional: Code Coverage
Coverage needs an extra package:
npm install @vitest/coverage-v8 --save-dev
ng test --coverage
Coverage reports land in the coverage/ directory.
The Component We're Testing
The TaskList component handles everything you'd expect in a real task manager: adding tasks with validation, marking complete, filtering, and deleting. This mirrors production scenarios.
Features:
- Add tasks (rejects empty/whitespace-only input)
- Toggle completion status
- Delete tasks
- Filter by status (all, active, completed)
- Clear all completed tasks
- Show task counts
- Display filter-specific empty states
Core implementation:
export class TaskListComponent {
tasks = signal<Task[]>([]);
filter = signal<TaskFilter>('all');
// Computed signals auto-update when dependencies change
activeTasksCount = computed(() =>
this.tasks().filter(t => !t.completed).length
);
filteredTasks = computed(() => {
switch (this.filter()) {
case 'active': return this.tasks().filter(t => !t.completed);
case 'completed': return this.tasks().filter(t => t.completed);
default: return this.tasks();
}
});
addTask() {
const text = this.newTaskText().trim();
if (!text) return;
this.tasks.update(tasks => [...tasks, {
id: Date.now(),
text,
completed: false,
createdAt: new Date()
}]);
this.newTaskText.set('');
}
}
Writing Your First Tests
Generate the component with its test file:
ng g c features/week-01-basics/task-list/components/task-list --type=component
Important: The --type=component flag adds the .component suffix and keeps Component in the class name. Without it, you get task-list.ts instead of task-list.component.ts, and TaskList instead of TaskListComponent.
Test File Structure
Basic Vitest setup for Angular components:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TaskListComponent } from './task-list.component';
describe('TaskListComponent', () => {
let component: TaskListComponent;
let fixture: ComponentFixture<TaskListComponent>;
let compiled: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TaskListComponent] // Standalone = imports, not declarations
}).compileComponents();
fixture = TestBed.createComponent(TaskListComponent);
component = fixture.componentInstance;
compiled = fixture.nativeElement as HTMLElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
Key pieces:
-
ComponentFixture- Wrapper with testing utilities -
component- Direct access to the TypeScript class -
compiled- The actual rendered HTML -
fixture.detectChanges()- Triggers change detection (more on this soon)
Testing Component Initialization
Verify the component starts in the correct state:
describe('Component Setup', () => {
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should initialize with empty tasks array', () => {
expect(component.tasks()).toEqual([]);
});
it('should initialize with empty input text', () => {
expect(component.newTaskText()).toBe('');
});
it('should initialize with "all" filter', () => {
expect(component.filter()).toBe('all');
});
it('should display empty state message initially', () => {
const emptyState = compiled.querySelector('[data-testid="empty-state"]');
expect(emptyState).toBeTruthy();
expect(emptyState?.textContent).toContain('No tasks yet');
});
});
Testing Signals
Signals are functions, you call them to read, use .set() or .update() to write:
// Read a signal value
expect(component.tasks()).toEqual([]);
// Signals are just functions with ()
const currentTasks = component.tasks();
const currentFilter = component.filter();
Much simpler than Observables with their subscriptions and async complexity.
Testing User Interactions
Test the main feature: adding tasks.
describe('Adding Tasks', () => {
it('should add a task with valid text', () => {
// ARRANGE - Set up test data
component.newTaskText.set('Go to the store');
// ACT - Perform the action
component.addTask();
// ASSERT - Verify the result
expect(component.tasks()).toHaveLength(1);
expect(component.tasks()[0].text).toBe('Go to the store');
expect(component.tasks()[0].completed).toBe(false);
});
it('should clear input after adding task', () => {
component.newTaskText.set('Go to the store