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');
component.addTask();
expect(component.newTaskText()).toBe('');
});
it('should not add task with empty text', () => {
component.newTaskText.set('');
component.addTask();
expect(component.tasks()).toHaveLength(0);
});
it('should not add task with whitespace-only text', () => {
component.newTaskText.set(' ');
component.addTask();
expect(component.tasks()).toHaveLength(0);
});
it('should trim whitespace from task text', () => {
component.newTaskText.set(' Go to the store ');
component.addTask();
expect(component.tasks()[0].text).toBe('Go to the store');
});
});
The AAA Pattern (Arrange, Act, Assert) makes tests self-documenting:
- Arrange: Set up conditions
- Act: Execute the action
- Assert: Verify the outcome
Understanding fixture.detectChanges()
What Happens Without It
it('should display task in DOM after adding', () => {
component.newTaskText.set('Go to the store');
component.addTask();
const taskItems = compiled.querySelectorAll('[data-testid^="task-item-"]');
expect(taskItems).toHaveLength(1); // FAILS!
});
The component state updated (component.tasks() shows the task), but the DOM stayed empty. Why? Angular's change detection hasn't run yet.
The Fix
it('should display task in DOM after adding', () => {
component.newTaskText.set('Go to the store');
component.addTask();
fixture.detectChanges(); // Trigger change detection
const taskItems = compiled.querySelectorAll('[data-testid^="task-item-"]');
expect(taskItems).toHaveLength(1); // Passes
expect(taskItems[0].textContent).toContain('Go to the store');
});
The Rule
Call fixture.detectChanges() after state changes when testing DOM updates.
In real apps, Angular runs change detection automatically. In tests, you trigger it manually.
When to Use It
// YES - After state changes before checking DOM
component.addTask();
fixture.detectChanges();
const tasks = compiled.querySelectorAll('[data-testid^="task-item-"]');
// YES - After signal updates
component.filter.set('active');
fixture.detectChanges();
// YES - Already in beforeEach for initial render
beforeEach(async () => {
fixture.detectChanges(); // Initial render
});
// NO - When testing component properties only
expect(component.tasks()).toHaveLength(1); // No DOM involved
Using data-testid for Reliable Tests
Notice our test queries use data-testid:
const taskItems = compiled.querySelectorAll('[data-testid^="task-item-"]');
const addButton = compiled.querySelector('[data-testid="add-button"]');
Why?
Unstable approach:
// Breaks when CSS changes
const button = compiled.querySelector('.btn-primary.add-task');
// Breaks when HTML structure changes
const task = compiled.querySelector('ul li:first-child');
Stable approach:
// Only changes when you intentionally update the test ID
const button = compiled.querySelector('[data-testid="add-button"]');
In Your Template
<!-- Static elements -->
<input data-testid="task-input" />
<button data-testid="add-button">Add</button>
<!-- Dynamic elements -->
<li [attr.data-testid]="'task-item-' + task.id">
<input
type="checkbox"
[attr.data-testid]="'task-checkbox-' + task.id"
/>
</li>
This decouples tests from styling and structure, making them resilient to UI changes.
Testing Computed Signals
Computed signals auto-recalculate when dependencies change. Test this behavior:
describe('Computed Signals', () => {
it('should calculate active tasks count correctly', () => {
component.newTaskText.set('Task 1');
component.addTask();
component.newTaskText.set('Task 2');
component.addTask();
expect(component.activeTasksCount()).toBe(2);
expect(component.completedTasksCount()).toBe(0);
});
it('should recalculate counts when task is toggled', () => {
component.newTaskText.set('Task 1');
component.addTask();
expect(component.activeTasksCount()).toBe(1);
expect(component.completedTasksCount()).toBe(0);
// Toggle to completed
const taskId = component.tasks()[0].id;
component.toggleTask(taskId);
expect(component.activeTasksCount()).toBe(0);
expect(component.completedTasksCount()).toBe(1);
});
it('should update filtered tasks when filter changes', () => {
component.newTaskText.set('Active Task');
component.addTask();
component.newTaskText.set('Completed Task');
component.addTask();
component.toggleTask(component.tasks()[1].id);
component.setFilter('all');
expect(component.filteredTasks()).toHaveLength(2);
component.setFilter('active');
expect(component.filteredTasks()).toHaveLength(1);
expect(component.filteredTasks()[0].completed).toBe(false);
component.setFilter('completed');
expect(component.filteredTasks()).toHaveLength(1);
expect(component.filteredTasks()[0].completed).toBe(true);
});
});
Computed signals just work, no manual triggers needed:
const activeCount = component.activeTasksCount(); // Always current
const filtered = component.filteredTasks(); // Auto-filtered
Testing Task Toggling
describe('Toggling Tasks', () => {
beforeEach(() => {
component.newTaskText.set('Test task');
component.addTask();
});
it('should mark task as completed', () => {
const taskId = component.tasks()[0].id;
component.toggleTask(taskId);
expect(component.tasks()[0].completed).toBe(true);
});
it('should unmark completed task', () => {
const taskId = component.tasks()[0].id;
component.toggleTask(taskId); // Complete
component.toggleTask(taskId); // Uncomplete
expect(component.tasks()[0].completed).toBe(false);
});
it('should toggle correct task when multiple exist', () => {
component.newTaskText.set('Second task');
component.addTask();
const secondTaskId = component.tasks()[1].id;
component.toggleTask(secondTaskId);
expect(component.tasks()[0].completed).toBe(false);
expect(component.tasks()[1].completed).toBe(true);
});
it('should update DOM when task is toggled', () => {
const taskId = component.tasks()[0].id;
component.toggleTask(taskId);
fixture.detectChanges();
const checkbox = compiled.querySelector(
`[data-testid="task-checkbox-${taskId}"]`
) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
});
Note: The beforeEach inside this describe block runs before each test in this block, reducing repetition.
Testing Task Deletion
describe('Deleting Tasks', () => {
it('should delete a task', () => {
component.newTaskText.set('Task to delete');
component.addTask();
const taskId = component.tasks()[0].id;
component.deleteTask(taskId);
expect(component.tasks()).toHaveLength(0);
});
it('should delete correct task from list', () => {
component.newTaskText.set('Task 1');
component.addTask();
component.newTaskText.set('Task 2');
component.addTask();
component.newTaskText.set('Task 3');
component.addTask();
const middleTaskId = component.tasks()[1].id;
component.deleteTask(middleTaskId);
expect(component.tasks()).toHaveLength(2);
expect(component.tasks()[0].text).toBe('Task 1');
expect(component.tasks()[1].text).toBe('Task 3');
});
it('should remove task from DOM', () => {
component.newTaskText.set('Task to delete');
component.addTask();
fixture.detectChanges();
const taskId = component.tasks()[0].id;
component.deleteTask(taskId);
fixture.detectChanges();
const taskElement = compiled.querySelector(`[data-testid="task-item-${taskId}"]`);
expect(taskElement).toBeNull();
});
});
Testing Filters
describe('Filtering Tasks', () => {
beforeEach(() => {
component.newTaskText.set('Active 1');
component.addTask();
component.newTaskText.set('Completed 1');
component.addTask();
component.newTaskText.set('Active 2');
component.addTask();
component.toggleTask(component.tasks()[1].id);
});
it('should show all tasks by default', () => {
expect(component.filter()).toBe('all');
expect(component.filteredTasks()).toHaveLength(3);
});
it('should filter to active tasks only', () => {
component.setFilter('active');
expect(component.filteredTasks()).toHaveLength(2);
expect(component.filteredTasks().every(t => !t.completed)).toBe(true);
});
it('should filter to completed tasks only', () => {
component.setFilter('completed');
expect(component.filteredTasks()).toHaveLength(1);
expect(component.filteredTasks().every(t => t.completed)).toBe(true);
});
it('should update filter button active state in DOM', () => {
component.setFilter('active');
fixture.detectChanges();
const activeButton = compiled.querySelector('[data-testid="filter-active"]');
expect(activeButton?.classList.contains('active')).toBe(true);
});
});
Testing Angular's Control Flow
Angular's @if and @empty syntax requires testing by checking element existence.
Testing @if Blocks
describe('Conditional Rendering', () => {
it('should hide footer when no tasks exist', () => {
const footer = compiled.querySelector('[data-testid="task-footer"]');
expect(footer).toBeNull();
});
it('should show footer when tasks exist', () => {
component.newTaskText.set('Task 1');
component.addTask();
fixture.detectChanges();
const footer = compiled.querySelector('[data-testid="task-footer"]');
expect(footer).toBeTruthy();
});
it('should show clear completed button only when completed tasks exist', () => {
component.newTaskText.set('Active Task');
component.addTask();
fixture.detectChanges();
let clearButton = compiled.querySelector('[data-testid="clear-completed"]');
expect(clearButton).toBeNull();
component.toggleTask(component.tasks()[0].id);
fixture.detectChanges();
clearButton = compiled.querySelector('[data-testid="clear-completed"]');
expect(clearButton).toBeTruthy();
});
});
Testing @empty Blocks
describe('Empty States', () => {
it('should show empty state for "all" filter when no tasks', () => {
component.setFilter('all');
fixture.detectChanges();
const emptyState = compiled.querySelector('[data-testid="empty-state"]');
expect(emptyState?.textContent).toContain('No tasks yet');
});
it('should show empty state for "active" filter when no active tasks', () => {
component.newTaskText.set('Completed Task');
component.addTask();
component.toggleTask(component.tasks()[0].id);
component.setFilter('active');
fixture.detectChanges();
const emptyState = compiled.querySelector('[data-testid="empty-state"]');
expect(emptyState?.textContent).toContain('No active tasks');
});
it('should show empty state for "completed" filter when no completed tasks', () => {
component.newTaskText.set('Active Task');
component.addTask();
component.setFilter('completed');
fixture.detectChanges();
const emptyState = compiled.querySelector('[data-testid="empty-state"]');
expect(emptyState?.textContent).toContain('No completed tasks');
});
});
Testing Edge Cases
Real users find creative ways to break things. Test for them:
describe('Edge Cases', () => {
it('should handle adding multiple tasks rapidly', () => {
for (let i = 1; i <= 5; i++) {
component.newTaskText.set(`Task ${i}`);
component.addTask();
}
expect(component.tasks()).toHaveLength(5);
});
it('should handle toggling task multiple times', () => {
component.newTaskText.set('Toggle test');
component.addTask();
const taskId = component.tasks()[0].id;
component.toggleTask(taskId);
component.toggleTask(taskId);
component.toggleTask(taskId);
expect(component.tasks()[0].completed).toBe(true);
});
it('should handle deleting non-existent task gracefully', () => {
component.newTaskText.set('Task 1');
component.addTask();
component.deleteTask(9999); // Non-existent ID
expect(component.tasks()).toHaveLength(1);
});
it('should maintain filter when adding new tasks', () => {
component.setFilter('active');
component.newTaskText.set('New Task');
component.addTask();
expect(component.filter()).toBe('active');
expect(component.filteredTasks()).toHaveLength(1);
});
it('should clear all completed tasks at once', () => {
for (let i = 1; i <= 3; i++) {
component.newTaskText.set(`Task ${i}`);
component.addTask();
if (i % 2 === 0) {
component.toggleTask(component.tasks()[i - 1].id);
}
}
component.clearCompleted();
expect(component.tasks().every(t => !t.completed)).toBe(true);
});
});
Common Issues and Solutions
Router Dependencies Missing
Problem:
NG0201: No provider found for `ActivatedRoute`
Your component uses RouterLink or RouterOutlet, but tests don't automatically provide routing services.
Solution:
import { provideRouter } from '@angular/router';
describe('AppComponent', () => {
let component: AppComponent;
let fixture: ComponentFixture<AppComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter([])]
}).compileComponents();
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
});
provideRouter([]) provides all routing services without needing actual routes. This replaces the deprecated RouterTestingModule.
DOM Not Updating in Tests
Problem:
it('should display new task', () => {
component.newTaskText.set('New Task');
component.addTask();
const tasks = compiled.querySelectorAll('[data-testid^="task-item-"]');
expect(tasks).toHaveLength(1); // Fails - length is 0
});
Solution:
Add fixture.detectChanges() after state changes:
it('should display new task', () => {
component.newTaskText.set('New Task');
component.addTask();
fixture.detectChanges(); // Required
const tasks = compiled.querySelectorAll('[data-testid^="task-item-"]');
expect(tasks).toHaveLength(1); // Passes
});
Testing Dynamic Content
Problem: How to test "1 item" vs "2 items"?
Solution: Test the actual text:
describe('Task Count Display', () => {
it('should display singular form for one task', () => {
component.newTaskText.set('Task 1');
component.addTask();
fixture.detectChanges();
const countDisplay = compiled.querySelector('[data-testid="task-count"]');
expect(countDisplay?.textContent).toContain('1 item left');
});
it('should display plural form for multiple tasks', () => {
component.newTaskText.set('Task 1');
component.addTask();
component.newTaskText.set('Task 2');
component.addTask();
fixture.detectChanges();
const countDisplay = compiled.querySelector('[data-testid="task-count"]');
expect(countDisplay?.textContent).toContain('2 items left');
});
});
Testing Checkbox State
Problem: hasAttribute('checked') doesn't work for checkboxes.
Wrong:
const checkbox = compiled.querySelector('[data-testid="task-checkbox"]');
expect(checkbox?.hasAttribute('checked')).toBe(true); // Doesn't work
Right:
const checkbox = compiled.querySelector(
'[data-testid="task-checkbox"]'
) as HTMLInputElement;
expect(checkbox.checked).toBe(true); // Works
Cast to HTMLInputElement to access the checked property.
Angular CLI Flags
Wrong flag:
ng g c my-component --skip-test # Error: Unknown option
Right flag:
ng g c my-component --skip-tests # Plural
For proper naming:
ng g c my-component --type=component
Without --type=component, you get my-component.ts instead of my-component.component.ts.
Quick Reference
Signals
// Read
expect(component.tasks()).toEqual([]);
// Write
component.newTaskText.set('Go to the store');
// Computed (auto-updates)
expect(component.activeTasksCount()).toBe(2);
DOM Testing
// Always call detectChanges after state changes
component.addTask();
fixture.detectChanges();
// Use data-testid for queries
const tasks = compiled.querySelectorAll('[data-testid^="task-item-"]');
Test Organization
// AAA Pattern
it('should add task', () => {
// Arrange
component.newTaskText.set('Task');
// Act
component.addTask();
// Assert
expect(component.tasks()).toHaveLength(1);
});
// beforeEach for setup
beforeEach(() => {
component.newTaskText.set('Test');
component.addTask();
});
Router Testing
providers: [provideRouter([])]
Control Flow
// Test @if by checking existence
expect(compiled.querySelector('[data-testid="footer"]')).toBeNull();
expect(compiled.querySelector('[data-testid="footer"]')).toBeTruthy();
// Test @empty by checking empty state
expect(emptyState?.textContent).toContain('No tasks yet');
What's Next
This covered component testing with signals. The series continues with:
- Service Testing: Dependency injection and mocking
- HTTP Testing: Testing API calls with HttpClient
- Form Testing: Reactive and signal-based forms
- Integration Testing: Multiple components together
Found an edge case I missed? Open a discussion.
Olayinka Akeju
github.com/olayeancarh
angular-vitest-testing-guide
Top comments (2)
Amazing doc to structure ai driven testing as quick references, aaa pattern etc. I hope that the series continue. Actually only Quick Reference and patterns are necessary so would be nice and beneficial to end the series as much as possible with core advises.
Very good explaint. But I was wondering how to set up global providers for testing. In your example you provide the provideRouter within the component test file. But how do I do this in a global scale for a lot of services I need to provide for almost all my component and service tests? And of course using Angular 21+ and Vitest.