DEV Community

Cover image for Migrating from Jasmine/Karma to Vitest in Angular 21: A Step-by-Step Guide Developer's Complete Guide
Rajat
Rajat

Posted on

Migrating from Jasmine/Karma to Vitest in Angular 21: A Step-by-Step Guide Developer's Complete Guide

Say goodbye to Karma, hello to lightning-fast tests with Vitest


Introduction

Are you still waiting minutes for your Angular test suite to run? What if I told you that you could cut that time by 50% or more?

With Angular 21, the framework has officially moved away from Jasmine and Karma in favor of Vitest as the default testing framework. If you're working on an existing Angular project, you might be wondering: "How do I migrate without breaking everything?"

In this guide, you'll learn:

  • How to manually configure Vitest in your existing Angular project
  • Step-by-step migration from Jasmine/Karma to Vitest
  • Common pitfalls and how to avoid them
  • Advanced configuration tips for real browser testing
  • How to write modern unit tests with the latest Angular syntax

By the end of this article, you'll have a fully functional Vitest setup that runs faster, provides better developer experience, and supports modern testing patterns.

Let's dive in.


Why Vitest? Understanding the Shift

Angular 21 marks a significant shift in the testing landscape. The Angular team has deprecated Karma and Jasmine in favor of Vitest for several compelling reasons:

Speed: Vitest is built on Vite, offering near-instant test startup and hot module replacement. Your tests run in milliseconds, not seconds.

Modern API: While maintaining compatibility with Jasmine-style syntax, Vitest offers a more modern, flexible API that aligns with current JavaScript testing practices.

Browser and Node Support: Unlike Karma, Vitest can run tests in both Node environments (with jsdom) and real browsers using Playwright.

Better Developer Experience: Built-in watch mode, clear error messages, and seamless TypeScript support make debugging a breeze.

Have you ever felt frustrated waiting for Karma to spin up? That frustration ends here.


Prerequisites

Before we start, make sure you have:

  • An existing Angular project (any version, but we'll focus on 21+)
  • Node.js 18+ installed
  • Basic familiarity with Angular testing concepts

Don't worry if you're new to Vitestβ€”we'll cover everything you need to know.


Step 1: Installing Vitest and Dependencies

First, let's install Vitest and the necessary dependencies:

npm install -D vitest @vitest/browser jsdom

Enter fullscreen mode Exit fullscreen mode

Here's what each package does:

  • vitest: The core testing framework
  • @vitest/browser: Enables browser-based testing with Playwright
  • jsdom: Simulates a browser environment in Node.js

If you plan to use real browser testing (recommended for component tests), also install Playwright:

npm install -D @vitest/browser playwright

Enter fullscreen mode Exit fullscreen mode

Quick tip: Check your package.json to ensure these are added under devDependencies.


Step 2: Configuring angular.json

This is where the magic happens. Open your angular.json file and locate your project's test configuration. We need to replace the Karma builder with Angular's new Vitest builder.

Before (Karma configuration):

"test": {
  "builder": "@angular-devkit/build-angular:karma",
  "options": {
    "karmaConfig": "karma.conf.js",
    "polyfills": ["zone.js", "zone.js/testing"],
    "tsConfig": "tsconfig.spec.json",
    "assets": ["src/favicon.ico", "src/assets"]
  }
}

Enter fullscreen mode Exit fullscreen mode

After (Vitest configuration):

"test": {
  "builder": "@angular/build:unit-test",
  "options": {
    "polyfills": ["zone.js", "zone.js/testing"],
    "tsConfig": "tsconfig.spec.json",
    "assets": ["src/favicon.ico", "src/assets"]
  }
}

Enter fullscreen mode Exit fullscreen mode

Notice these changes:

  1. Builder changed from @angular-devkit/build-angular:karma to @angular/build:unit-test
  2. Removed the karmaConfig option
  3. Kept essential options like polyfills, tsConfig, and assets

Save the file and let's move forward.


Step 3: Removing Karma

Time to clean up. Let's uninstall all Karma-related packages:

npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter @types/jasmine jasmine-core

Enter fullscreen mode Exit fullscreen mode

Next, delete these files from your project root if they exist:

  • karma.conf.js
  • src/test.ts (if you have it)

Pro tip: Use your version control to double-check what you're removing. You can always revert if needed.


Step 4: Running Your First Test with Vitest

Here's the beautiful part: your existing Jasmine tests will mostly work without changes. Let's see it in action.

Run your tests:

ng test

Enter fullscreen mode Exit fullscreen mode

Vitest will start, and you'll see output similar to:

RUN  v2.1.8

βœ“ src/app/app.component.spec.ts (4 tests) 234ms
βœ“ src/app/services/data.service.spec.ts (3 tests) 156ms

Test Files  2 passed (2)
     Tests  7 passed (7)

Enter fullscreen mode Exit fullscreen mode

Notice how much faster it is compared to Karma?

API Compatibility:

Vitest maintains compatibility with most Jasmine functions:

  • describe() - Test suites
  • it() - Individual tests
  • beforeEach() - Setup before each test
  • beforeAll() - Setup before all tests
  • afterEach() - Cleanup after each test
  • expect() - Assertions

Key Differences:

Instead of Jasmine's focused tests:

// Old Jasmine way
fdescribe('Focused suite', () => {});
fit('Focused test', () => {});

Enter fullscreen mode Exit fullscreen mode

Use Vitest's syntax:

// New Vitest way
describe.only('Focused suite', () => {});
it.only('Focused test', () => {});

Enter fullscreen mode Exit fullscreen mode

Step 5: Fixing TypeScript Typings

You might notice TypeScript errors about describe, it, or expect not being defined. This happens because Vitest doesn't pollute the global namespace by default (which is actually a good thing).

Update your tsconfig.spec.json:

Before:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["jasmine"]
  },
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}

Enter fullscreen mode Exit fullscreen mode

After:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["vitest/globals"]
  },
  "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}

Enter fullscreen mode Exit fullscreen mode

Changed "types": ["jasmine"] to "types": ["vitest/globals"].

Better Approach - Explicit Imports:

Instead of relying on global types, import Vitest functions explicitly in your test files:

import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';

describe('AppComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent]
    }).compileComponents();
  });

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.componentInstance;
    expect(app).toBeTruthy();
  });
});

Enter fullscreen mode Exit fullscreen mode

This approach is cleaner, more explicit, and enables better tree-shaking.

Automated Migration:

Angular provides an experimental schematic to add imports automatically:

ng test --migrate --add-imports

Enter fullscreen mode Exit fullscreen mode

This will scan your test files and add the necessary imports. However, review the changes carefully.


Step 6: Writing Modern Angular Tests with Vitest

Let's write a complete example using Angular 21's latest syntax with standalone components and signal-based inputs.

Component to Test:

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

@Component({
  selector: 'app-user-profile',
  template: `
    <div class="profile">
      <h2>{{ displayName() }}</h2>
      @if (isAdmin()) {
        <span class="badge">Admin</span>
      }
      <p>Email: {{ email() }}</p>
    </div>
  `,
  styles: [`
    .profile { padding: 20px; }
    .badge { color: red; font-weight: bold; }
  `]
})
export class UserProfileComponent {
  firstName = input.required<string>();
  lastName = input.required<string>();
  email = input.required<string>();
  role = input<string>('user');

  displayName = computed(() =>
    `${this.firstName()} ${this.lastName()}`
  );

  isAdmin = computed(() =>
    this.role() === 'admin'
  );
}

Enter fullscreen mode Exit fullscreen mode

Unit Test:

// user-profile.component.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { ComponentFixture, 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 create the component', () => {
    expect(component).toBeTruthy();
  });

  it('should display full name correctly', () => {
    fixture.componentRef.setInput('firstName', 'John');
    fixture.componentRef.setInput('lastName', 'Doe');
    fixture.componentRef.setInput('email', 'john@example.com');
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('h2').textContent).toBe('John Doe');
  });

  it('should show admin badge when role is admin', () => {
    fixture.componentRef.setInput('firstName', 'Jane');
    fixture.componentRef.setInput('lastName', 'Smith');
    fixture.componentRef.setInput('email', 'jane@example.com');
    fixture.componentRef.setInput('role', 'admin');
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    const badge = compiled.querySelector('.badge');
    expect(badge).toBeTruthy();
    expect(badge.textContent).toBe('Admin');
  });

  it('should not show admin badge for regular users', () => {
    fixture.componentRef.setInput('firstName', 'Bob');
    fixture.componentRef.setInput('lastName', 'Wilson');
    fixture.componentRef.setInput('email', 'bob@example.com');
    fixture.componentRef.setInput('role', 'user');
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    const badge = compiled.querySelector('.badge');
    expect(badge).toBeNull();
  });

  it('should compute display name from first and last name', () => {
    fixture.componentRef.setInput('firstName', 'Alice');
    fixture.componentRef.setInput('lastName', 'Johnson');
    fixture.componentRef.setInput('email', 'alice@example.com');

    expect(component.displayName()).toBe('Alice Johnson');
  });
});

Enter fullscreen mode Exit fullscreen mode

Key Points:

  • Using imports: [UserProfileComponent] instead of declarations (standalone architecture)
  • Using fixture.componentRef.setInput() to set signal-based inputs
  • Testing computed signals directly
  • Testing template control flow with @if

Step 7: Testing Services with Vitest

Let's test a service that uses signals and modern Angular features:

Service:

// user.service.ts
import { Injectable, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export interface User {
  id: number;
  name: string;
  email: string;
}

@Injectable({ providedIn: 'root' })
export class UserService {
  private users = signal<User[]>([]);

  allUsers = this.users.asReadonly();
  userCount = computed(() => this.users().length);

  constructor(private http: HttpClient) {}

  loadUsers() {
    return this.http.get<User[]>('/api/users').subscribe(
      users => this.users.set(users)
    );
  }

  addUser(user: User) {
    this.users.update(current => [...current, user]);
  }

  removeUser(id: number) {
    this.users.update(current =>
      current.filter(u => u.id !== id)
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Unit Test:

// user.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { UserService, User } from './user.service';

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [UserService]
    });

    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should be created', () => {
    expect(service).toBeTruthy();
  });

  it('should load users from API', () => {
    const mockUsers: User[] = [
      { id: 1, name: 'John', email: 'john@test.com' },
      { id: 2, name: 'Jane', email: 'jane@test.com' }
    ];

    service.loadUsers();

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush(mockUsers);

    expect(service.allUsers()).toEqual(mockUsers);
    expect(service.userCount()).toBe(2);
  });

  it('should add a new user', () => {
    const newUser: User = {
      id: 3,
      name: 'Bob',
      email: 'bob@test.com'
    };

    service.addUser(newUser);

    expect(service.allUsers()).toContain(newUser);
    expect(service.userCount()).toBe(1);
  });

  it('should remove a user by id', () => {
    const users: User[] = [
      { id: 1, name: 'John', email: 'john@test.com' },
      { id: 2, name: 'Jane', email: 'jane@test.com' }
    ];

    users.forEach(user => service.addUser(user));
    expect(service.userCount()).toBe(2);

    service.removeUser(1);

    expect(service.userCount()).toBe(1);
    expect(service.allUsers().find(u => u.id === 1)).toBeUndefined();
    expect(service.allUsers().find(u => u.id === 2)).toBeTruthy();
  });

  it('should compute user count correctly', () => {
    expect(service.userCount()).toBe(0);

    service.addUser({ id: 1, name: 'Test', email: 'test@test.com' });
    expect(service.userCount()).toBe(1);

    service.addUser({ id: 2, name: 'Test2', email: 'test2@test.com' });
    expect(service.userCount()).toBe(2);
  });
});

Enter fullscreen mode Exit fullscreen mode

Notice how we're testing signal-based state management and computed signals directly.


Step 8: Advanced Configuration with vitest.config.ts

For more control over your testing environment, create a vitest.config.ts file in your project root:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: false, // Disable global APIs
    environment: 'jsdom', // Use jsdom for DOM simulation
    setupFiles: ['src/test-setup.ts'], // Global setup file
    include: ['src/**/*.spec.ts'], // Test file pattern
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'src/test-setup.ts',
      ]
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

Configuration Options Explained:

  • globals: false - Requires explicit imports (cleaner, more maintainable)
  • environment: 'jsdom' - Fast Node-based DOM simulation
  • setupFiles - Code to run before all tests
  • coverage - Configure code coverage reporting

Create setup file:

// src/test-setup.ts
import 'zone.js';
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

// Initialize Angular testing environment
getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);

Enter fullscreen mode Exit fullscreen mode

Step 9: Real Browser Testing with Playwright

For end-to-end component testing in real browsers, configure Vitest to use Playwright:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: false,
    browser: {
      enabled: true,
      name: 'chromium', // or 'firefox', 'webkit'
      provider: 'playwright',
      headless: true,
      screenshotOnFailure: true
    },
    include: ['src/**/*.spec.ts']
  }
});

Enter fullscreen mode Exit fullscreen mode

Benefits of Browser Testing:

  • Tests run in actual browser environments
  • More accurate DOM behavior
  • Better debugging with browser DevTools
  • Support for Chrome, Firefox, and Safari

Example browser test:

import { describe, it, expect } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { AppComponent } from './app.component';

describe('AppComponent (Browser)', () => {
  it('should render in real browser', async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent]
    }).compileComponents();

    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();

    const compiled = fixture.nativeElement;
    expect(compiled.querySelector('h1')).toBeTruthy();
  });
});

Enter fullscreen mode Exit fullscreen mode

Run browser tests:

ng test --browser

Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

Pitfall 1: Global type conflicts

If you see errors like "Cannot find name 'describe'", you likely have conflicting types.

Solution: Use explicit imports and set globals: false in your config.

Pitfall 2: Zone.js issues

Some async tests might fail due to Zone.js timing.

Solution: Ensure zone.js polyfills are loaded in your test configuration:

"polyfills": ["zone.js", "zone.js/testing"]

Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Slow test startup

If tests still feel slow, you might be running in browser mode unnecessarily.

Solution: Use jsdom for unit tests, reserve browser mode for integration tests.

Pitfall 4: Missing TestBed configuration

Forgetting to configure TestBed properly is a common mistake.

Solution: Always initialize in beforeEach:

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

Enter fullscreen mode Exit fullscreen mode

Bonus Tips for Maximum Productivity

Tip 1: Watch Mode

Run tests in watch mode for instant feedback:

ng test --watch

Enter fullscreen mode Exit fullscreen mode

Vitest will re-run only affected tests when you save files.

Tip 2: Run Specific Tests

Focus on specific tests during development:

it.only('should test this specific case', () => {
  // Only this test runs
});

Enter fullscreen mode Exit fullscreen mode

Tip 3: Skip Tests Temporarily

it.skip('will implement this later', () => {
  // Skipped
});

Enter fullscreen mode Exit fullscreen mode

Tip 4: Code Coverage

Generate coverage reports:

ng test --coverage

Enter fullscreen mode Exit fullscreen mode

Open coverage/index.html to see detailed reports.

Tip 5: Parallel Execution

Vitest runs tests in parallel by default. For sequential execution:

// vitest.config.ts
export default defineConfig({
  test: {
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: true
      }
    }
  }
});

Enter fullscreen mode Exit fullscreen mode

Recap: What We Learned

Let's summarize the key takeaways:

  1. Installation: Added Vitest, jsdom, and Playwright to replace Karma/Jasmine
  2. Configuration: Updated angular.json to use the new unit-test builder
  3. Cleanup: Removed Karma dependencies and configuration files
  4. TypeScript: Fixed typings by updating tsconfig.spec.json
  5. Modern Syntax: Wrote tests using signal-based inputs and standalone components
  6. Advanced Setup: Configured vitest.config.ts for custom behavior
  7. Browser Testing: Set up Playwright for real browser testing
  8. Best Practices: Learned common pitfalls and productivity tips

Migration to Vitest is straightforward, and the benefits are immediate: faster tests, better DX, and modern tooling. Your test suite will run in a fraction of the time, and you'll enjoy a smoother development workflow.


Take Action Now

Ready to supercharge your Angular testing workflow? Here's what to do:

  1. Back up your current test configuration
  2. Follow the steps above in a feature branch
  3. Run your existing tests and fix any issues
  4. Gradually adopt explicit imports and modern patterns
  5. Share your migration experience with the community

What challenges did you face during migration? Did this guide help you get unstuck?

Drop a comment below with your biggest testing pain point, and let's solve it together.

Found this helpful? Give it a clap so other developers can discover this guide too.

Want more Angular tips, performance tricks, and modern development practices? Follow me for weekly insights that will level up your skills.

Have a specific topic you'd like me to cover next? Let me know in the comments. I read every single one and love hearing what you want to learn.

Happy testing, and may your test suites run blazingly fast!


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

Top comments (0)