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 (0)