DEV Community

Cover image for Testing Angular 21 Components with Vitest: A Complete Guide
Olayinka Akeju
Olayinka Akeju

Posted on

Testing Angular 21 Components with Vitest: A Complete Guide

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
Enter fullscreen mode Exit fullscreen mode

Verify Vitest is Installed

Check package.json:

{
  "devDependencies": {
    "@angular/build": "^21.0.0",
    "vitest": "^4.0.8"
  }
}
Enter fullscreen mode Exit fullscreen mode

Check angular.json:

{
  "projects": {
    "your-project-name": {
      "architect": {
        "test": {
          "builder": "@angular/build:unit-test"
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Run Tests

# Watch mode (default)
ng test

# Single run for CI
ng test --no-watch
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Optional: Code Coverage

Coverage needs an extra package:

npm install @vitest/coverage-v8 --save-dev
ng test --coverage
Enter fullscreen mode Exit fullscreen mode

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

Full component code on GitHub

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('');
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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!
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Stable approach:

// Only changes when you intentionally update the test ID
const button = compiled.querySelector('[data-testid="add-button"]');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

Computed signals just work, no manual triggers needed:

const activeCount = component.activeTasksCount(); // Always current
const filtered = component.filteredTasks(); // Auto-filtered
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

Common Issues and Solutions

Router Dependencies Missing

Problem:

NG0201: No provider found for `ActivatedRoute`
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Right:

const checkbox = compiled.querySelector(
  '[data-testid="task-checkbox"]'
) as HTMLInputElement;

expect(checkbox.checked).toBe(true); // Works
Enter fullscreen mode Exit fullscreen mode

Cast to HTMLInputElement to access the checked property.

Angular CLI Flags

Wrong flag:

ng g c my-component --skip-test  # Error: Unknown option
Enter fullscreen mode Exit fullscreen mode

Right flag:

ng g c my-component --skip-tests  # Plural
Enter fullscreen mode Exit fullscreen mode

For proper naming:

ng g c my-component --type=component
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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-"]');
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Router Testing

providers: [provideRouter([])]
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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

Full code on GitHub.

Found an edge case I missed? Open a discussion.

Olayinka Akeju
github.com/olayeancarh
angular-vitest-testing-guide

Top comments (0)