DEV Community

Cover image for Angular 21 Vitest Testing Revolution: Complete Karma-to-Vitest Migration Guide, ICOV Coverage, Faster DX & Modern Testing
Rajat
Rajat

Posted on

Angular 21 Vitest Testing Revolution: Complete Karma-to-Vitest Migration Guide, ICOV Coverage, Faster DX & Modern Testing

How a Lightning-Fast Testing Framework is Making Karma + Jasmine Obsolete—And What It Means for Your Angular Projects


Have You Ever Waited 10 Minutes for Your Test Suite to Run?

If you're an Angular developer, you've probably experienced this pain. You make a small change, run your tests, grab coffee, check your phone, and maybe start wondering if you chose the wrong career. Meanwhile, your CI/CD pipeline is burning through minutes (and money) running Karma with Jasmine.

Here's the good news: Angular 21 just changed the game entirely.

The Angular team officially adopted Vitest as the default testing framework, leaving behind the decade-old Karma + Jasmine combo. And this isn't just a minor upgrade—it's a complete paradigm shift in how we think about testing Angular applications.

In this article, you'll discover:

  • Why Angular dumped Karma after all these years
  • How Vitest makes your tests run 10x faster (seriously)
  • Step-by-step migration guide from Jasmine to Vitest
  • Real-world examples for testing components, async operations, and browser APIs
  • The new ICOV coverage format and why it matters
  • Best practices that will make your team's testing workflow smoother

Whether you're maintaining a legacy Angular app or starting fresh with Angular 21, this guide will show you exactly how to leverage Vitest's power. Let's dive in.


What is Vitest? (And Why Angular Finally Made the Switch)

Vitest is a blazing-fast unit testing framework built on top of Vite. Think of it as the modern successor to Jest, but designed specifically for the Vite ecosystem with native ESM support, instant hot module replacement, and TypeScript built right in.

The Karma Problem

Let's be honest—Karma served us well for years, but it was showing its age:

  • Slow startup times: Launching a real browser for every test run
  • Poor watch mode: Waiting seconds for file changes to register
  • Complex configuration: Remember wrestling with webpack configs?
  • Flaky tests: Browser timing issues causing random failures
  • No native ESM: Forced transpilation slowing everything down

Why Vitest Won

The Angular team didn't make this decision lightly. Here's what tipped the scales:

1. Speed That Actually Matters Vitest runs tests in a Node.js environment with happy-dom or jsdom, eliminating browser overhead. We're talking 10-20x faster execution.

2. Developer Experience That Doesn't Suck Hot module reload in watch mode means instant feedback. Change a test, save, and see results before you even look at the terminal.

3. Modern JavaScript Native Built for ESM from the ground up. No more transpilation gymnastics.

4. TypeScript Without the Headache TypeScript support isn't bolted on—it's core to Vitest's architecture.

5. Future-Proof Architecture As Angular moves toward ESM-only and standalone components, Vitest aligns perfectly with this direction.

Think about it this way: Karma was designed when Internet Explorer 9 was still relevant. Vitest was designed for the JavaScript ecosystem we have today.


Key Features of Vitest in Angular 21

Let's explore what makes Vitest a game-changer with actual code examples.

1. Lightning-Fast Execution

Here's a simple component test to see the speed difference:

// user-profile.component.ts
import { Component, input, output } from '@angular/core';

@Component({
  selector: 'app-user-profile',
  template: `
    <div class="profile-card">
      <h2>{{ name() }}</h2>
      <p>{{ email() }}</p>
      <button (click)="handleEdit()">Edit Profile</button>
    </div>
  `,
  styles: [`
    .profile-card { padding: 1rem; border: 1px solid #ddd; }
  `]
})
export class UserProfileComponent {
  name = input.required<string>();
  email = input.required<string>();
  editClicked = output<void>();

  handleEdit() {
    this.editClicked.emit();
  }
}

Enter fullscreen mode Exit fullscreen mode

Vitest Test:

// user-profile.component.spec.ts
import { describe, it, expect, vi } from 'vitest';
import { ComponentFixture } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { UserProfileComponent } from './user-profile.component';

describe('UserProfileComponent', () => {
  let component: UserProfileComponent;
  let fixture: ComponentFixture<UserProfileComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [UserProfileComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(UserProfileComponent);
    component = fixture.componentInstance;
  });

  it('should render user information correctly', () => {
    fixture.componentRef.setInput('name', 'John Doe');
    fixture.componentRef.setInput('email', 'john@example.com');
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('h2')?.textContent).toBe('John Doe');
    expect(compiled.querySelector('p')?.textContent).toBe('john@example.com');
  });

  it('should emit event when edit button is clicked', () => {
    const editSpy = vi.fn();
    component.editClicked.subscribe(editSpy);

    fixture.componentRef.setInput('name', 'Jane Smith');
    fixture.componentRef.setInput('email', 'jane@example.com');
    fixture.detectChanges();

    const button = fixture.nativeElement.querySelector('button');
    button.click();

    expect(editSpy).toHaveBeenCalledTimes(1);
  });
});

Enter fullscreen mode Exit fullscreen mode

With Karma, this test might take 3-5 seconds to run. With Vitest? Usually under 100ms.

2. Hot Module Reload in Watch Mode

When you run tests in watch mode, Vitest only re-runs tests related to changed files:

ng test --watch

Enter fullscreen mode Exit fullscreen mode

Change your component, save, and boom—instant feedback. No full test suite re-run.

3. Snapshot Testing Made Simple

Snapshot testing helps catch unexpected UI changes:

// navigation.component.spec.ts
import { describe, it, expect } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { NavigationComponent } from './navigation.component';

describe('NavigationComponent Snapshots', () => {
  it('should match snapshot for default state', () => {
    const fixture = TestBed.createComponent(NavigationComponent);
    fixture.detectChanges();

    expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
  });
});

Enter fullscreen mode Exit fullscreen mode

First run creates the snapshot. Subsequent runs compare against it.

4. Built-in Mocking with vi

Vitest's vi utility replaces Jasmine's spy system:

// data.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class DataService {
  private http = inject(HttpClient);

  fetchUsers(): Observable<any[]> {
    return this.http.get<any[]>('/api/users');
  }
}

// data.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { DataService } from './data.service';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';

describe('DataService', () => {
  let service: DataService;
  let httpMock: any;

  beforeEach(() => {
    httpMock = {
      get: vi.fn()
    };

    TestBed.configureTestingModule({
      providers: [
        { provide: HttpClient, useValue: httpMock }
      ]
    });

    service = TestBed.inject(DataService);
  });

  it('should fetch users from API', (done) => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];

    httpMock.get.mockReturnValue(of(mockUsers));

    service.fetchUsers().subscribe(users => {
      expect(users).toEqual(mockUsers);
      expect(httpMock.get).toHaveBeenCalledWith('/api/users');
      done();
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

What just happened here? We used vi.fn() to create a mock function—cleaner and more flexible than Jasmine spies.

5. Browser-Like Environment with happy-dom

Vitest uses happy-dom by default, providing DOM APIs without a real browser:

// storage.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';

describe('LocalStorage Mocking', () => {
  beforeEach(() => {
    // Clear storage before each test
    localStorage.clear();
  });

  it('should save and retrieve data from localStorage', () => {
    localStorage.setItem('theme', 'dark');
    expect(localStorage.getItem('theme')).toBe('dark');
  });

  it('should handle missing keys gracefully', () => {
    expect(localStorage.getItem('nonexistent')).toBeNull();
  });
});

Enter fullscreen mode Exit fullscreen mode

Happy-dom provides localStorage, sessionStorage, and other browser APIs out of the box.

Have you tried running Angular tests with instant hot reload yet? If not, you're missing out on one of the best DX improvements in years.


Migration from Karma/Jasmine to Vitest: Step-by-Step Guide

Migrating might sound daunting, but Angular 21 makes it surprisingly smooth. Let's walk through it.

Step 1: Update Angular to Version 21

ng update @angular/core@21 @angular/cli@21

Enter fullscreen mode Exit fullscreen mode

Step 2: Add Vitest to Your Project

ng add @angular/vitest

Enter fullscreen mode Exit fullscreen mode

This command automatically:

  • Installs Vitest and related dependencies
  • Creates vitest.config.ts
  • Updates angular.json test configuration
  • Migrates existing test setup files

Step 3: Understanding the Configuration

Your new vitest.config.ts will look like this:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@analogjs/vite-plugin-angular';

export default defineConfig({
  plugins: [angular()],
  test: {
    globals: true,
    environment: 'happy-dom',
    setupFiles: ['src/test-setup.ts'],
    include: ['**/*.spec.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov', 'icov'],
      exclude: [
        'node_modules/',
        'src/test-setup.ts',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80
      }
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

Step 4: Migrating Test Syntax

Here's a comparison table of common patterns:

Jasmine Vitest
jasmine.createSpy() vi.fn()
spyOn(obj, 'method') vi.spyOn(obj, 'method')
spyOn().and.returnValue() vi.fn().mockReturnValue()
expect().toHaveBeenCalled() expect().toHaveBeenCalled() (same)
fakeAsync() vi.useFakeTimers()
tick(100) await vi.advanceTimersByTimeAsync(100)
flush() await vi.runAllTimersAsync()

Step 5: Real Migration Example

Before (Jasmine):

// auth.service.spec.ts (Jasmine)
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';

describe('AuthService - Jasmine', () => {
  let service: AuthService;
  let httpSpy: jasmine.SpyObj<HttpClient>;

  beforeEach(() => {
    const spy = jasmine.createSpyObj('HttpClient', ['post']);

    TestBed.configureTestingModule({
      providers: [
        AuthService,
        { provide: HttpClient, useValue: spy }
      ]
    });

    service = TestBed.inject(AuthService);
    httpSpy = TestBed.inject(HttpClient) as jasmine.SpyObj<HttpClient>;
  });

  it('should login user successfully', (done) => {
    const mockResponse = { token: 'abc123', user: { id: 1, name: 'John' } };
    httpSpy.post.and.returnValue(of(mockResponse));

    service.login('john@example.com', 'password').subscribe(response => {
      expect(response.token).toBe('abc123');
      expect(httpSpy.post).toHaveBeenCalledWith('/api/auth/login', {
        email: 'john@example.com',
        password: 'password'
      });
      done();
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

After (Vitest):

// auth.service.spec.ts (Vitest)
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';

describe('AuthService - Vitest', () => {
  let service: AuthService;
  let httpMock: any;

  beforeEach(() => {
    httpMock = {
      post: vi.fn()
    };

    TestBed.configureTestingModule({
      providers: [
        AuthService,
        { provide: HttpClient, useValue: httpMock }
      ]
    });

    service = TestBed.inject(AuthService);
  });

  it('should login user successfully', async () => {
    const mockResponse = { token: 'abc123', user: { id: 1, name: 'John' } };
    httpMock.post.mockReturnValue(of(mockResponse));

    const response = await service.login('john@example.com', 'password');

    expect(response.token).toBe('abc123');
    expect(httpMock.post).toHaveBeenCalledWith('/api/auth/login', {
      email: 'john@example.com',
      password: 'password'
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

Key Changes:

  • Import from vitest instead of relying on globals
  • Use vi.fn() instead of jasmine.createSpyObj()
  • Use mockReturnValue() instead of and.returnValue()
  • Can use async/await instead of done callback

Step 6: Migrating Async Tests

Jasmine fakeAsync:

it('should handle delayed response', fakeAsync(() => {
  let result: string;
  setTimeout(() => result = 'done', 1000);

  tick(1000);

  expect(result).toBe('done');
}));

Enter fullscreen mode Exit fullscreen mode

Vitest equivalent:

it('should handle delayed response', async () => {
  vi.useFakeTimers();

  let result: string;
  setTimeout(() => result = 'done', 1000);

  await vi.advanceTimersByTimeAsync(1000);

  expect(result).toBe('done');

  vi.useRealTimers();
});

Enter fullscreen mode Exit fullscreen mode

What's your biggest concern about migrating your existing test suite? Is it the time investment or the learning curve?


Coverage in Vitest & Angular 21

Code coverage isn't just a metric—it's a conversation about quality. Here's how to make it work for you in Angular 21.

Enabling Coverage

Run tests with coverage:

ng test --coverage

Enter fullscreen mode Exit fullscreen mode

Or configure it to run automatically in vitest.config.ts:

export default defineConfig({
  test: {
    coverage: {
      enabled: true,
      provider: 'v8', // or 'istanbul'
      reporter: ['text', 'json', 'html', 'lcov', 'icov'],
      reportsDirectory: './coverage',
      exclude: [
        'node_modules/',
        'src/test-setup.ts',
        '**/*.spec.ts',
        '**/*.config.ts',
        '**/index.ts'
      ],
      thresholds: {
        lines: 80,
        functions: 75,
        branches: 70,
        statements: 80
      }
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

Understanding Coverage Providers

Vitest supports two coverage providers:

1. v8 (Default)

  • Faster execution
  • Native to V8 JavaScript engine
  • Better for large codebases

2. Istanbul (c8)

  • More detailed reports
  • Better source map support
  • Industry standard

Setting Coverage Thresholds

Make your CI/CD fail if coverage drops:

coverage: {
  thresholds: {
    lines: 80,
    functions: 80,
    branches: 75,
    statements: 80,
    // Per-file thresholds
    perFile: true
  }
}

Enter fullscreen mode Exit fullscreen mode

Reading Coverage Reports

After running tests with coverage, open coverage/index.html:

Coverage Summary
================
Statements   : 87.5% ( 350/400 )
Branches     : 82.3% ( 142/172 )
Functions    : 91.2% ( 104/114 )
Lines        : 88.1% ( 339/385 )

Enter fullscreen mode Exit fullscreen mode

Red files need attention. Green files are well-tested. Focus on the red.


ICOV File Support in Angular 21: The Game-Changer

This is one of the most underrated features in Angular 21. Let me explain why it matters.

What is ICOV?

ICOV (Improved Coverage) is a new coverage format introduced by Microsoft. Think of it as LCOV's smarter, more efficient cousin.

Traditional LCOV Format:

TN:
SF:/src/app/services/user.service.ts
FN:10,fetchUser
FN:25,updateUser
FNDA:5,fetchUser
FNDA:2,updateUser
DA:10,5
DA:11,5
DA:12,4

Enter fullscreen mode Exit fullscreen mode

New ICOV Format:

{
  "version": "1.0.0",
  "files": {
    "/src/app/services/user.service.ts": {
      "functions": {
        "fetchUser": { "line": 10, "count": 5 },
        "updateUser": { "line": 25, "count": 2 }
      },
      "branches": {},
      "lines": {
        "10": 5,
        "11": 5,
        "12": 4
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Why Angular Introduced ICOV Support

1. Better Tooling Integration Azure DevOps, GitHub Actions, and modern CI/CD platforms prefer JSON-based formats for easier parsing.

2. Smaller File Sizes JSON compression works better than LCOV's text format—important for large monorepos.

3. Richer Metadata ICOV can include source maps, branch details, and function coverage in a structured way.

4. Future-Proof As tools like SonarQube and CodeCov adopt ICOV, Angular is ready.

How Angular 21 Generates ICOV

Simply add 'icov' to your coverage reporters:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      reporter: ['text', 'html', 'lcov', 'icov'], // Add 'icov'
      reportsDirectory: './coverage'
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

After running ng test --coverage, you'll find:

  • coverage/lcov.info (traditional)
  • coverage/coverage.icov (new format)

Where ICOV is Used

Azure DevOps:

- task: PublishCodeCoverageResults@1
  inputs:
    codeCoverageTool: 'icov'
    summaryFileLocation: '$(Build.SourcesDirectory)/coverage/coverage.icov'

Enter fullscreen mode Exit fullscreen mode

GitHub Actions:

- name: Upload Coverage
  uses: codecov/codecov-action@v3
  with:
    files: ./coverage/coverage.icov
    format: icov

Enter fullscreen mode Exit fullscreen mode

SonarQube (Future Support):

sonar.javascript.icov.reportPaths=coverage/coverage.icov

Enter fullscreen mode Exit fullscreen mode

Sample ICOV Output

{
  "version": "1.0.0",
  "timestamp": "2025-11-29T10:30:00.000Z",
  "files": {
    "/src/app/components/user-list.component.ts": {
      "lines": {
        "5": 10,
        "8": 10,
        "12": 8,
        "15": 0,
        "20": 6
      },
      "functions": {
        "ngOnInit": { "line": 5, "count": 10 },
        "loadUsers": { "line": 12, "count": 8 },
        "deleteUser": { "line": 15, "count": 0 }
      },
      "branches": {
        "12:0": { "count": 5 },
        "12:1": { "count": 3 }
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

What this tells us:

  • Line 15 (deleteUser function) was never executed—needs test coverage
  • Branch at line 12 has both paths tested
  • ngOnInit and loadUsers are well-covered

Terminal Output in Vitest: What You'll Actually See

Let's look at real terminal output so you know what to expect.

Single File Test Summary

$ ng test user.service.spec.ts

 ✓ src/app/services/user.service.spec.ts (4)
   ✓ UserService (4)
     ✓ should be created
     ✓ should fetch users from API
     ✓ should handle API errors gracefully
     ✓ should cache user data correctly

 Test Files  1 passed (1)
      Tests  4 passed (4)
   Start at  10:30:45
   Duration  127ms

Enter fullscreen mode Exit fullscreen mode

Overall Test Summary

$ ng test

 ✓ src/app/components/user-list.component.spec.ts (6) 89ms
 ✓ src/app/services/user.service.spec.ts (4) 127ms
 ✓ src/app/pipes/format-date.pipe.spec.ts (3) 45ms
 ✓ src/app/guards/auth.guard.spec.ts (5) 156ms

 Test Files  4 passed (4)
      Tests  18 passed (18)
   Start at  10:30:45
   Duration  412ms (transform 89ms, setup 12ms, collect 134ms, tests 177ms)

Enter fullscreen mode Exit fullscreen mode

Coverage Summary

$ ng test --coverage

 % Coverage report from v8
------------------------------------|---------|----------|---------|---------|
File                                | % Stmts | % Branch | % Funcs | % Lines |
------------------------------------|---------|----------|---------|---------|
All files                           |   87.50 |    82.35 |   91.17 |   88.12 |
 src/app/components                 |   92.30 |    88.88 |   95.00 |   93.10 |
  user-list.component.ts            |   95.45 |    91.66 |  100.00 |   96.29 |
  user-detail.component.ts          |   89.47 |    85.71 |   90.00 |   90.47 |
 src/app/services                   |   84.21 |    77.77 |   88.23 |   85.00 |
  user.service.ts                   |   91.66 |    87.50 |   93.75 |   92.30 |
  auth.service.ts                   |   76.92 |    66.66 |   83.33 |   78.26 |
 src/app/guards                     |   80.00 |    75.00 |   85.00 |   81.81 |
  auth.guard.ts                     |   80.00 |    75.00 |   85.00 |   81.81 |
------------------------------------|---------|----------|---------|---------|

Enter fullscreen mode Exit fullscreen mode

Failed Tests Output

$ ng test

 ✓ src/app/services/user.service.spec.ts (3)
 ✗ src/app/components/user-list.component.spec.ts (1 failed)

 FAIL  src/app/components/user-list.component.spec.ts > UserListComponent > should display user names
AssertionError: expected 'John Smith' to be 'John Doe'
 ❯ src/app/components/user-list.component.spec.ts:45:52
    43|     fixture.detectChanges();
    44|     const nameElement = fixture.nativeElement.querySelector('.user-name');
    45|     expect(nameElement.textContent).toBe('John Doe');
      |                                     ^
    46|   });
    47| });

 Test Files  1 failed | 1 passed (2)
      Tests  1 failed | 3 passed (4)

Enter fullscreen mode Exit fullscreen mode

Slow Tests Warning

 SLOW  src/app/services/heavy-computation.service.spec.ts > should calculate complex data
       Duration: 2.5s (threshold: 1s)

Enter fullscreen mode Exit fullscreen mode

Vitest automatically highlights tests that take longer than expected—super helpful for identifying performance bottlenecks.


Handling Edge Cases in Vitest: Real Angular Examples

This is where Vitest really shines. Let's tackle the tricky stuff.

1. Testing Async Operations

Async/Await Pattern:

// notification.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class NotificationService {
  private http = inject(HttpClient);

  async sendNotification(message: string): Promise<{ success: boolean }> {
    const response = await firstValueFrom(
      this.http.post<{ success: boolean }>('/api/notifications', { message })
    );
    return response;
  }

  async getNotifications(): Promise<any[]> {
    return firstValueFrom(this.http.get<any[]>('/api/notifications'));
  }
}

// notification.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { NotificationService } from './notification.service';
import { HttpClient } from '@angular/common/http';
import { of } from 'rxjs';

describe('NotificationService - Async Tests', () => {
  let service: NotificationService;
  let httpMock: any;

  beforeEach(() => {
    httpMock = {
      post: vi.fn(),
      get: vi.fn()
    };

    TestBed.configureTestingModule({
      providers: [
        { provide: HttpClient, useValue: httpMock }
      ]
    });

    service = TestBed.inject(NotificationService);
  });

  it('should send notification successfully', async () => {
    httpMock.post.mockReturnValue(of({ success: true }));

    const result = await service.sendNotification('Hello World');

    expect(result.success).toBe(true);
    expect(httpMock.post).toHaveBeenCalledWith('/api/notifications', {
      message: 'Hello World'
    });
  });

  it('should handle network errors gracefully', async () => {
    httpMock.post.mockReturnValue(
      new Promise((_, reject) => reject(new Error('Network error')))
    );

    await expect(service.sendNotification('Test')).rejects.toThrow('Network error');
  });
});

Enter fullscreen mode Exit fullscreen mode

2. Testing Timers with Fake Timers

setTimeout/setInterval:

// polling.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class PollingService {
  private intervalId?: number;
  public callCount = 0;

  startPolling(callback: () => void, interval: number): void {
    this.intervalId = window.setInterval(() => {
      this.callCount++;
      callback();
    }, interval);
  }

  stopPolling(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = undefined;
    }
  }
}

// polling.service.spec.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { PollingService } from './polling.service';

describe('PollingService - Timer Tests', () => {
  let service: PollingService;

  beforeEach(() => {
    vi.useFakeTimers();
    TestBed.configureTestingModule({});
    service = TestBed.inject(PollingService);
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('should call callback every 1000ms', async () => {
    const callback = vi.fn();

    service.startPolling(callback, 1000);

    // Advance time by 3000ms
    await vi.advanceTimersByTimeAsync(3000);

    expect(callback).toHaveBeenCalledTimes(3);
    expect(service.callCount).toBe(3);

    service.stopPolling();
  });

  it('should stop polling when stopPolling is called', async () => {
    const callback = vi.fn();

    service.startPolling(callback, 1000);
    await vi.advanceTimersByTimeAsync(2000);

    service.stopPolling();

    await vi.advanceTimersByTimeAsync(2000);

    // Should still be 2, not 4
    expect(callback).toHaveBeenCalledTimes(2);
  });
});

Enter fullscreen mode Exit fullscreen mode

3. Mocking Browser APIs

LocalStorage:

// theme.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    const savedTheme = localStorage.getItem('theme');
    if (savedTheme === 'dark' || savedTheme === 'light') {
      this.theme.set(savedTheme);
    }
  }

  toggleTheme(): void {
    const newTheme = this.theme() === 'light' ? 'dark' : 'light';
    this.theme.set(newTheme);
    localStorage.setItem('theme', newTheme);
  }
}

// theme.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';

describe('ThemeService - LocalStorage Tests', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('should initialize with light theme by default', () => {
    const service = TestBed.inject(ThemeService);
    expect(service.theme()).toBe('light');
  });

  it('should load saved theme from localStorage', () => {
    localStorage.setItem('theme', 'dark');

    const service = TestBed.inject(ThemeService);
    expect(service.theme()).toBe('dark');
  });

  it('should save theme to localStorage when toggled', () => {
    const service = TestBed.inject(ThemeService);

    service.toggleTheme();

    expect(localStorage.getItem('theme')).toBe('dark');
    expect(service.theme()).toBe('dark');
  });
});

Enter fullscreen mode Exit fullscreen mode

Window.location:

// navigation.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';

describe('Window Location Mocking', () => {
  beforeEach(() => {
    // Mock window.location
    delete (window as any).location;
    window.location = {
      href: 'http://localhost:4200/',
      pathname: '/',
      search: '',
      hash: ''
    } as any;
  });

  it('should navigate to external URL', () => {
    const navigateSpy = vi.spyOn(window.location, 'href', 'set');

    window.location.href = 'https://example.com';

    expect(navigateSpy).toHaveBeenCalledWith('https://example.com');
  });
});

Enter fullscreen mode Exit fullscreen mode

IntersectionObserver:

// lazy-load.directive.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';

describe('IntersectionObserver Mocking', () => {
  let observeMock: any;
  let unobserveMock: any;

  beforeEach(() => {
    observeMock = vi.fn();
    unobserveMock = vi.fn();

    global.IntersectionObserver = vi.fn().mockImplementation((callback) => ({
      observe: observeMock,
      unobserve: unobserveMock,
      disconnect: vi.fn(),
    }));
  });

  it('should observe element for lazy loading', () => {
    const element = document.createElement('img');
    const observer = new IntersectionObserver(() => {});

    observer.observe(element);

    expect(observeMock).toHaveBeenCalledWith(element);
  });
});

Enter fullscreen mode Exit fullscreen mode

4. Testing DOM Interactions with Happy-DOM

// dropdown.component.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-dropdown',
  template: `
    <div class="dropdown">
      <button (click)="toggle()" class="dropdown-toggle">
        {{ isOpen() ? 'Close' : 'Open' }} Menu
      </button>
      @if (isOpen()) {
        <ul class="dropdown-menu">
          <li><a href="/profile">Profile</a></li>
          <li><a href="/settings">Settings</a></li>
          <li><a href="/logout">Logout</a></li>
        </ul>
      }
    </div>
  `,
  styles: [`
    .dropdown { position: relative; }
    .dropdown-menu { position: absolute; top: 100%; }
  `]
})
export class DropdownComponent {
  isOpen = signal(false);

  toggle(): void {
    this.isOpen.update(value => !value);
  }
}

// dropdown.component.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DropdownComponent } from './dropdown.component';

describe('DropdownComponent - DOM Interactions', () => {
  let component: DropdownComponent;
  let fixture: ComponentFixture<DropdownComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingComponent({
      imports: [DropdownComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(DropdownComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should toggle dropdown menu on button click', () => {
    const button = fixture.nativeElement.querySelector('.dropdown-toggle');

    // Initially closed
    expect(component.isOpen()).toBe(false);
    expect(fixture.nativeElement.querySelector('.dropdown-menu')).toBeNull();

    // Click to open
    button.click();
    fixture.detectChanges();

    expect(component.isOpen()).toBe(true);
    expect(fixture.nativeElement.querySelector('.dropdown-menu')).not.toBeNull();

    // Click to close
    button.click();
    fixture.detectChanges();

    expect(component.isOpen()).toBe(false);
    expect(fixture.nativeElement.querySelector('.dropdown-menu')).toBeNull();
  });

  it('should display correct button text based on state', () => {
    const button = fixture.nativeElement.querySelector('.dropdown-toggle');

    expect(button.textContent.trim()).toBe('Open Menu');

    button.click();
    fixture.detectChanges();

    expect(button.textContent.trim()).toBe('Close Menu');
  });

  it('should render menu items when open', () => {
    component.isOpen.set(true);
    fixture.detectChanges();

    const menuItems = fixture.nativeElement.querySelectorAll('.dropdown-menu li');
    expect(menuItems.length).toBe(3);
    expect(menuItems[0].textContent).toBe('Profile');
    expect(menuItems[1].textContent).toBe('Settings');
    expect(menuItems[2].textContent).toBe('Logout');
  });
});

Enter fullscreen mode Exit fullscreen mode

Have you run into issues testing browser APIs in the past? This approach makes it way simpler.


Component Testing in Angular 21 + Vitest: Complete Example

Let's build a realistic component test using Angular Testing Library integration.

The Component

// todo-list.component.ts
import { Component, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

@Component({
  selector: 'app-todo-list',
  imports: [FormsModule],
  template: `
    <div class="todo-container">
      <h2>My Todo List</h2>

      <div class="todo-input">
        <input
          type="text"
          [(ngModel)]="newTodoText"
          (keyup.enter)="addTodo()"
          placeholder="Add a new todo..."
          data-testid="todo-input"
        />
        <button
          (click)="addTodo()"
          [disabled]="!newTodoText().trim()"
          data-testid="add-button"
        >
          Add
        </button>
      </div>

      <div class="todo-stats">
        <span data-testid="total-count">Total: {{ totalCount() }}</span>
        <span data-testid="completed-count">Completed: {{ completedCount() }}</span>
        <span data-testid="pending-count">Pending: {{ pendingCount() }}</span>
      </div>

      <ul class="todo-list">
        @for (todo of todos(); track todo.id) {
          <li [class.completed]="todo.completed" [attr.data-testid]="'todo-' + todo.id">
            <input
              type="checkbox"
              [checked]="todo.completed"
              (change)="toggleTodo(todo.id)"
              [attr.data-testid]="'checkbox-' + todo.id"
            />
            <span class="todo-text">{{ todo.text }}</span>
            <button
              (click)="deleteTodo(todo.id)"
              [attr.data-testid]="'delete-' + todo.id"
              class="delete-btn"
            >
              Delete
            </button>
          </li>
        }
      </ul>

      @if (todos().length === 0) {
        <p class="empty-state" data-testid="empty-state">
          No todos yet. Add one above!
        </p>
      }
    </div>
  `,
  styles: [`
    .todo-container { max-width: 600px; margin: 0 auto; padding: 20px; }
    .todo-input { display: flex; gap: 10px; margin-bottom: 20px; }
    .todo-input input { flex: 1; padding: 8px; }
    .todo-stats { display: flex; gap: 15px; margin-bottom: 20px; color: #666; }
    .todo-list { list-style: none; padding: 0; }
    .todo-list li { display: flex; align-items: center; gap: 10px; padding: 10px; border-bottom: 1px solid #eee; }
    .todo-list li.completed .todo-text { text-decoration: line-through; color: #999; }
    .delete-btn { margin-left: auto; color: red; }
    .empty-state { text-align: center; color: #999; padding: 40px; }
  `]
})
export class TodoListComponent {
  todos = signal<Todo[]>([]);
  newTodoText = signal('');
  private nextId = 1;

  totalCount = computed(() => this.todos().length);
  completedCount = computed(() => this.todos().filter(t => t.completed).length);
  pendingCount = computed(() => this.todos().filter(t => !t.completed).length);

  addTodo(): void {
    const text = this.newTodoText().trim();
    if (!text) return;

    this.todos.update(todos => [
      ...todos,
      { id: this.nextId++, text, completed: false }
    ]);
    this.newTodoText.set('');
  }

  toggleTodo(id: number): void {
    this.todos.update(todos =>
      todos.map(todo =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }

  deleteTodo(id: number): void {
    this.todos.update(todos => todos.filter(todo => todo.id !== id));
  }
}

Enter fullscreen mode Exit fullscreen mode

Comprehensive Test Suite

// todo-list.component.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TodoListComponent } from './todo-list.component';

describe('TodoListComponent', () => {
  let component: TodoListComponent;
  let fixture: ComponentFixture<TodoListComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [TodoListComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(TodoListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  describe('Initial Rendering', () => {
    it('should create the component', () => {
      expect(component).toBeTruthy();
    });

    it('should display empty state when no todos', () => {
      const emptyState = fixture.nativeElement.querySelector('[data-testid="empty-state"]');
      expect(emptyState).toBeTruthy();
      expect(emptyState.textContent).toContain('No todos yet');
    });

    it('should display correct initial stats', () => {
      const total = fixture.nativeElement.querySelector('[data-testid="total-count"]');
      const completed = fixture.nativeElement.querySelector('[data-testid="completed-count"]');
      const pending = fixture.nativeElement.querySelector('[data-testid="pending-count"]');

      expect(total.textContent).toBe('Total: 0');
      expect(completed.textContent).toBe('Completed: 0');
      expect(pending.textContent).toBe('Pending: 0');
    });
  });

  describe('Adding Todos', () => {
    it('should add a new todo when clicking add button', () => {
      const input = fixture.nativeElement.querySelector('[data-testid="todo-input"]');
      const button = fixture.nativeElement.querySelector('[data-testid="add-button"]');

      input.value = 'Buy groceries';
      input.dispatchEvent(new Event('input'));
      fixture.detectChanges();

      button.click();
      fixture.detectChanges();

      const todoItems = fixture.nativeElement.querySelectorAll('.todo-list li');
      expect(todoItems.length).toBe(1);
      expect(todoItems[0].textContent).toContain('Buy groceries');
    });

    it('should add todo when pressing Enter key', () => {
      const input = fixture.nativeElement.querySelector('[data-testid="todo-input"]');

      input.value = 'Write tests';
      input.dispatchEvent(new Event('input'));
      fixture.detectChanges();

      const enterEvent = new KeyboardEvent('keyup', { key: 'Enter' });
      input.dispatchEvent(enterEvent);
      fixture.detectChanges();

      expect(component.todos().length).toBe(1);
      expect(component.todos()[0].text).toBe('Write tests');
    });

    it('should not add empty todos', () => {
      const button = fixture.nativeElement.querySelector('[data-testid="add-button"]');

      component.newTodoText.set('   ');
      fixture.detectChanges();

      button.click();
      fixture.detectChanges();

      expect(component.todos().length).toBe(0);
    });

    it('should clear input after adding todo', () => {
      const input = fixture.nativeElement.querySelector('[data-testid="todo-input"]');
      const button = fixture.nativeElement.querySelector('[data-testid="add-button"]');

      input.value = 'Test todo';
      input.dispatchEvent(new Event('input'));
      component.newTodoText.set('Test todo');
      fixture.detectChanges();

      button.click();
      fixture.detectChanges();

      expect(component.newTodoText()).toBe('');
    });

    it('should update stats after adding todos', () => {
      component.newTodoText.set('First todo');
      component.addTodo();
      component.newTodoText.set('Second todo');
      component.addTodo();
      fixture.detectChanges();

      const total = fixture.nativeElement.querySelector('[data-testid="total-count"]');
      const pending = fixture.nativeElement.querySelector('[data-testid="pending-count"]');

      expect(total.textContent).toBe('Total: 2');
      expect(pending.textContent).toBe('Pending: 2');
    });
  });

  describe('Toggling Todos', () => {
    beforeEach(() => {
      component.newTodoText.set('Test todo');
      component.addTodo();
      fixture.detectChanges();
    });

    it('should toggle todo completion on checkbox click', () => {
      const checkbox = fixture.nativeElement.querySelector('[data-testid="checkbox-1"]');

      expect(component.todos()[0].completed).toBe(false);

      checkbox.click();
      fixture.detectChanges();

      expect(component.todos()[0].completed).toBe(true);
    });

    it('should apply completed class when todo is completed', () => {
      const checkbox = fixture.nativeElement.querySelector('[data-testid="checkbox-1"]');

      checkbox.click();
      fixture.detectChanges();

      const todoItem = fixture.nativeElement.querySelector('[data-testid="todo-1"]');
      expect(todoItem.classList.contains('completed')).toBe(true);
    });

    it('should update completed count when toggling', () => {
      const checkbox = fixture.nativeElement.querySelector('[data-testid="checkbox-1"]');

      checkbox.click();
      fixture.detectChanges();

      const completedCount = fixture.nativeElement.querySelector('[data-testid="completed-count"]');
      const pendingCount = fixture.nativeElement.querySelector('[data-testid="pending-count"]');

      expect(completedCount.textContent).toBe('Completed: 1');
      expect(pendingCount.textContent).toBe('Pending: 0');
    });
  });

  describe('Deleting Todos', () => {
    beforeEach(() => {
      component.newTodoText.set('Todo 1');
      component.addTodo();
      component.newTodoText.set('Todo 2');
      component.addTodo();
      fixture.detectChanges();
    });

    it('should delete todo when clicking delete button', () => {
      const deleteButton = fixture.nativeElement.querySelector('[data-testid="delete-1"]');

      expect(component.todos().length).toBe(2);

      deleteButton.click();
      fixture.detectChanges();

      expect(component.todos().length).toBe(1);
      expect(component.todos()[0].text).toBe('Todo 2');
    });

    it('should show empty state after deleting all todos', () => {
      const delete1 = fixture.nativeElement.querySelector('[data-testid="delete-1"]');
      const delete2 = fixture.nativeElement.querySelector('[data-testid="delete-2"]');

      delete1.click();
      fixture.detectChanges();

      delete2.click();
      fixture.detectChanges();

      const emptyState = fixture.nativeElement.querySelector('[data-testid="empty-state"]');
      expect(emptyState).toBeTruthy();
    });

    it('should update stats after deletion', () => {
      const deleteButton = fixture.nativeElement.querySelector('[data-testid="delete-1"]');

      deleteButton.click();
      fixture.detectChanges();

      const total = fixture.nativeElement.querySelector('[data-testid="total-count"]');
      expect(total.textContent).toBe('Total: 1');
    });
  });

  describe('Computed Properties', () => {
    it('should correctly calculate total count', () => {
      component.todos.set([
        { id: 1, text: 'Todo 1', completed: false },
        { id: 2, text: 'Todo 2', completed: true },
        { id: 3, text: 'Todo 3', completed: false }
      ]);

      expect(component.totalCount()).toBe(3);
    });

    it('should correctly calculate completed count', () => {
      component.todos.set([
        { id: 1, text: 'Todo 1', completed: false },
        { id: 2, text: 'Todo 2', completed: true },
        { id: 3, text: 'Todo 3', completed: true }
      ]);

      expect(component.completedCount()).toBe(2);
    });

    it('should correctly calculate pending count', () => {
      component.todos.set([
        { id: 1, text: 'Todo 1', completed: false },
        { id: 2, text: 'Todo 2', completed: true },
        { id: 3, text: 'Todo 3', completed: false }
      ]);

      expect(component.pendingCount()).toBe(2);
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

This test suite covers:

  • Initial rendering and empty states
  • Adding todos via button and Enter key
  • Input validation and clearing
  • Toggling completion status
  • Deleting todos
  • Stats calculations
  • Edge cases like empty strings and multiple operations

Integration with CI/CD: Making It Production-Ready

Let's make sure your tests run smoothly in your pipeline.

GitHub Actions Example

# .github/workflows/test.yml
name: Run Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests with coverage
        run: npm run test:ci

      - name: Check coverage thresholds
        run: npm run test:coverage:check

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage.icov,./coverage/lcov.info
          format: icov
          fail_ci_if_error: true

      - name: Upload coverage artifacts
        uses: actions/upload-artifact@v3
        with:
          name: coverage-report
          path: coverage/

Enter fullscreen mode Exit fullscreen mode

Package.json Scripts

{
  "scripts": {
    "test": "ng test",
    "test:ci": "ng test --run --coverage",
    "test:coverage": "ng test --coverage",
    "test:coverage:check": "vitest --run --coverage --coverage.thresholds.lines=80"
  }
}

Enter fullscreen mode Exit fullscreen mode

Azure DevOps Pipeline

# azure-pipelines.yml
trigger:
  - main
  - develop

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'
    displayName: 'Install Node.js'

  - script: npm ci
    displayName: 'Install dependencies'

  - script: npm run test:ci
    displayName: 'Run Vitest tests'

  - task: PublishCodeCoverageResults@1
    inputs:
      codeCoverageTool: 'icov'
      summaryFileLocation: '$(Build.SourcesDirectory)/coverage/coverage.icov'
      pathToSources: '$(Build.SourcesDirectory)/src'
    displayName: 'Publish coverage results'

  - task: PublishTestResults@2
    inputs:
      testResultsFormat: 'JUnit'
      testResultsFiles: '**/junit.xml'
      failTaskOnFailedTests: true
    displayName: 'Publish test results'

Enter fullscreen mode Exit fullscreen mode

Fail on Coverage Threshold

Update your vitest.config.ts:

export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        lines: 80,
        functions: 75,
        branches: 70,
        statements: 80
      },
      // Fail CI if thresholds aren't met
      thresholdAutoUpdate: false
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

When coverage drops below threshold:

ERROR: Coverage for lines (78.5%) does not meet global threshold (80%)

Enter fullscreen mode Exit fullscreen mode

Your CI/CD pipeline will fail, preventing low-quality code from merging.


Best Practices and Recommendations

Let me share some hard-won wisdom from real-world Angular testing.

1. Folder Structure

src/
├── app/
│   ├── components/
│   │   ├── user-list/
│   │   │   ├── user-list.component.ts
│   │   │   ├── user-list.component.spec.ts
│   │   │   └── user-list.component.css
│   ├── services/
│   │   ├── user.service.ts
│   │   ├── user.service.spec.ts
│   ├── guards/
│   │   ├── auth.guard.ts
│   │   ├── auth.guard.spec.ts
│   ├── pipes/
│   │   ├── format-date.pipe.ts
│   │   ├── format-date.pipe.spec.ts
├── test/
│   ├── helpers/
│   │   ├── mock-data.ts
│   │   ├── test-utils.ts
│   │   └── custom-matchers.ts
│   └── fixtures/
│       ├── user-fixtures.ts
│       └── api-responses.ts
├── test-setup.ts
└── vitest.config.ts

Enter fullscreen mode Exit fullscreen mode

Why this works:

  • Tests live next to the code they test
  • Shared test utilities in dedicated folder
  • Easy to find and maintain

2. Naming Conventions

// Good: Descriptive, action-oriented
it('should display error message when API call fails', () => {});
it('should disable submit button when form is invalid', () => {});

// Bad: Vague, implementation-focused
it('should work', () => {});
it('should test the method', () => {});

Enter fullscreen mode Exit fullscreen mode

Pattern to follow:

  • Start with "should"
  • Describe the behavior, not the implementation
  • Include the condition and expected outcome

3. Organize Test Utilities

// test/helpers/test-utils.ts
import { ComponentFixture } from '@angular/core/testing';

export function findByTestId<T>(
  fixture: ComponentFixture<T>,
  testId: string
): HTMLElement {
  return fixture.nativeElement.querySelector(`[data-testid="${testId}"]`);
}

export function clickButton<T>(
  fixture: ComponentFixture<T>,
  testId: string
): void {
  const button = findByTestId(fixture, testId);
  button.click();
  fixture.detectChanges();
}

export async function fillInput<T>(
  fixture: ComponentFixture<T>,
  testId: string,
  value: string
): Promise<void> {
  const input = findByTestId(fixture, testId) as HTMLInputElement;
  input.value = value;
  input.dispatchEvent(new Event('input'));
  fixture.detectChanges();
  await fixture.whenStable();
}

// Usage in tests
import { findByTestId, clickButton, fillInput } from '../../../test/helpers/test-utils';

it('should add todo', async () => {
  await fillInput(fixture, 'todo-input', 'New todo');
  clickButton(fixture, 'add-button');

  const todoItem = findByTestId(fixture, 'todo-1');
  expect(todoItem).toBeTruthy();
});

Enter fullscreen mode Exit fullscreen mode

4. Mocking Guidelines

Do mock:

  • External HTTP calls
  • Browser APIs (localStorage, navigator, etc.)
  • Third-party services
  • Time-dependent code (setTimeout, Date.now())

Don't mock:

  • Angular core functionality (TestBed, ComponentFixture)
  • Simple utility functions
  • Your own business logic (test it instead)
// Good: Mock external dependency
const httpMock = {
  get: vi.fn().mockReturnValue(of(mockData))
};

// Bad: Mocking your own logic defeats the purpose
const userServiceMock = {
  calculateAge: vi.fn().mockReturnValue(25) // Just test the real calculateAge!
};

Enter fullscreen mode Exit fullscreen mode

5. Avoid Snapshot Overuse

Snapshots are useful but can become a maintenance nightmare.

Use snapshots for:

  • Complex, static HTML structures
  • Generated content that rarely changes
  • Third-party component output

Don't use snapshots for:

  • Dynamic content
  • Forms with user interaction
  • Anything that changes frequently
// Good: Targeted assertion
it('should display user name', () => {
  expect(fixture.nativeElement.querySelector('.user-name').textContent).toBe('John');
});

// Questionable: Entire component snapshot
it('should render correctly', () => {
  expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
});

Enter fullscreen mode Exit fullscreen mode

6. Test Behavior, Not Implementation

// Bad: Testing implementation details
it('should call ngOnInit', () => {
  const spy = vi.spyOn(component, 'ngOnInit');
  component.ngOnInit();
  expect(spy).toHaveBeenCalled();
});

// Good: Testing behavior
it('should load users on component initialization', () => {
  // ngOnInit is called automatically by TestBed
  expect(component.users().length).toBeGreaterThan(0);
});

Enter fullscreen mode Exit fullscreen mode

7. Keep Tests Independent

// Bad: Tests depend on each other
describe('UserService', () => {
  let users: User[];

  it('should add user', () => {
    users = service.addUser({ name: 'John' });
    expect(users.length).toBe(1);
  });

  it('should find user', () => {
    // Depends on previous test!
    const user = service.findUser('John');
    expect(user).toBeTruthy();
  });
});

// Good: Each test is independent
describe('UserService', () => {
  beforeEach(() => {
    service.reset();
  });

  it('should add user', () => {
    const users = service.addUser({ name: 'John' });
    expect(users.length).toBe(1);
  });

  it('should find user', () => {
    service.addUser({ name: 'John' });
    const user = service.findUser('John');
    expect(user).toBeTruthy();
  });
});

Enter fullscreen mode Exit fullscreen mode

8. Use Descriptive Test Data

// Bad: Magic numbers and strings
it('should calculate discount', () => {
  expect(service.calculate(100, 0.1)).toBe(90);
});

// Good: Named constants with context
it('should apply 10% discount correctly', () => {
  const originalPrice = 100;
  const discountRate = 0.1;
  const expectedPrice = 90;

  expect(service.calculate(originalPrice, discountRate)).toBe(expectedPrice);
});

Enter fullscreen mode Exit fullscreen mode

Bonus Tips: Level Up Your Vitest Game

Here are some insider tricks that most developers miss.

Tip 1: Use Custom Matchers

// test/helpers/custom-matchers.ts
import { expect } from 'vitest';

expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    return {
      pass,
      message: () =>
        pass
          ? `expected ${received} not to be within range ${floor} - ${ceiling}`
          : `expected ${received} to be within range ${floor} - ${ceiling}`
    };
  }
});

// Usage
expect(response.time).toBeWithinRange(100, 200);

Enter fullscreen mode Exit fullscreen mode

Tip 2: Parallel Test Execution

// vitest.config.ts
export default defineConfig({
  test: {
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
        isolate: true
      }
    },
    maxConcurrency: 5 // Run up to 5 test files in parallel
  }
});

Enter fullscreen mode Exit fullscreen mode

Tip 3: Test-Specific Timeouts

// For slow integration tests
it('should handle large dataset', async () => {
  await service.processLargeFile();
  expect(service.processed).toBe(true);
}, 10000); // 10 second timeout for this test only

Enter fullscreen mode Exit fullscreen mode

Tip 4: Debug Failing Tests

// vitest.config.ts
export default defineConfig({
  test: {
    // Stop on first failure
    bail: 1,

    // Retry flaky tests
    retry: 2
  }
});

Enter fullscreen mode Exit fullscreen mode

Run specific test file:

ng test src/app/services/user.service.spec.ts

Enter fullscreen mode Exit fullscreen mode

Run tests matching pattern:

ng test --reporter=verbose --grep="should handle errors"

Enter fullscreen mode Exit fullscreen mode

Tip 5: Environment Variables for Tests

// test-setup.ts
import { beforeAll } from 'vitest';

beforeAll(() => {
  process.env.API_URL = 'http://localhost:4200/api';
  process.env.NODE_ENV = 'test';
});

// Usage in tests
it('should use test API URL', () => {
  expect(service.apiUrl).toBe('http://localhost:4200/api');
});

Enter fullscreen mode Exit fullscreen mode

Recap: Why Vitest is Angular's Future

Let's bring it all together.

What we've covered:

1. The Why: Angular dropped Karma because Vitest is faster, more modern, and built for today's JavaScript ecosystem.

2. Key Features: Instant hot reload, native TypeScript, powerful mocking with vi, and browser-like environments without the browser overhead.

3. Migration: Step-by-step guide from Jasmine to Vitest, including syntax changes and real code examples.

4. Coverage: How to enable ICOV format, set thresholds, and integrate with modern CI/CD pipelines.

5. Real-World Testing: Async operations, timers, browser APIs, DOM interactions—all the tricky stuff you actually need.

6. Best Practices: Folder structure, naming conventions, when to mock, and how to keep tests maintainable.

The Bottom Line:

Vitest isn't just a testing framework—it's a developer experience upgrade. Your tests run 10x faster. Your feedback loop is instant. Your CI/CD pipeline finishes before you context-switch.

If you're starting a new Angular 21 project, Vitest is a no-brainer. If you have an existing app, the migration effort pays for itself in saved time within weeks.

The Angular team made the right call. Now it's your turn.


What Did You Think?

I'd genuinely love to hear your experience with Vitest. Have you migrated from Karma yet? What challenges did you face? Drop a comment below—especially if you've run into edge cases not covered here.

Found this helpful? Give it a clap (or five) so other Angular developers can discover this guide.

Want more Angular insights? Follow me for weekly deep-dives into Angular best practices, performance optimization, and modern development techniques. I also share exclusive tips in my newsletter—join the community of developers who want to level up their Angular game.

Action Points:

  1. Try Vitest in a small project first—get comfortable before migrating your main app
  2. Set up CI/CD integration early—automated testing saves teams
  3. Share this with your team—discuss migration timeline and strategy
  4. Bookmark this guide—you'll reference it during migration

What topic should I cover next? Vote in the comments:

  • A) Advanced Vitest patterns and custom utilities
  • B) Angular Signals testing strategies
  • C) E2E testing with Playwright in Angular 21

Let's keep the conversation going. See you in the comments.


🎯 Your Turn, Devs!

👀 Did this article spark new ideas or help solve a real problem?

💬 I'd love to hear about it!

✅ Are you already using this technique in your Angular or frontend project?

🧠 Got questions, doubts, or your own twist on the approach?

Drop them in the comments below — let’s learn together!


🙌 Let’s Grow Together!

If this article added value to your dev journey:

🔁 Share it with your team, tech friends, or community — you never know who might need it right now.

📌 Save it for later and revisit as a quick reference.


🚀 Follow Me for More Angular & Frontend Goodness:

I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.

  • 💼 LinkedIn — Let’s connect professionally
  • 🎥 Threads — Short-form frontend insights
  • 🐦 X (Twitter) — Developer banter + code snippets
  • 👥 BlueSky — Stay up to date on frontend trends
  • 🌟 GitHub Projects — Explore code in action
  • 🌐 Website — Everything in one place
  • 📚 Medium Blog — Long-form content and deep-dives
  • 💬 Dev Blog — Free Long-form content and deep-dives
  • ✉️ Substack — Weekly frontend stories & curated resources
  • 🧩 Portfolio — Projects, talks, and recognitions
  • ✍️ Hashnode — Developer blog posts & tech discussions

🎉 If you found this article valuable:

  • Leave a 👏 Clap
  • Drop a 💬 Comment
  • Hit 🔔 Follow for more weekly frontend insights

Let’s build cleaner, faster, and smarter web apps — together.

Stay tuned for more Angular tips, patterns, and performance tricks! 🧪🧠

✨ Share Your Thoughts To 📣 Set Your Notification Preference

Top comments (0)