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
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
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"]
}
}
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"]
}
}
Notice these changes:
- Builder changed from
@angular-devkit/build-angular:karmato@angular/build:unit-test - Removed the
karmaConfigoption - 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
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
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)
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', () => {});
Use Vitest's syntax:
// New Vitest way
describe.only('Focused suite', () => {});
it.only('Focused test', () => {});
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"]
}
After:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["vitest/globals"]
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}
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();
});
});
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
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'
);
}
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');
});
});
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)
);
}
}
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);
});
});
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',
]
}
}
});
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()
);
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']
}
});
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();
});
});
Run browser tests:
ng test --browser
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"]
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();
});
Bonus Tips for Maximum Productivity
Tip 1: Watch Mode
Run tests in watch mode for instant feedback:
ng test --watch
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
});
Tip 3: Skip Tests Temporarily
it.skip('will implement this later', () => {
// Skipped
});
Tip 4: Code Coverage
Generate coverage reports:
ng test --coverage
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
}
}
}
});
Recap: What We Learned
Let's summarize the key takeaways:
- Installation: Added Vitest, jsdom, and Playwright to replace Karma/Jasmine
- Configuration: Updated angular.json to use the new unit-test builder
- Cleanup: Removed Karma dependencies and configuration files
- TypeScript: Fixed typings by updating tsconfig.spec.json
- Modern Syntax: Wrote tests using signal-based inputs and standalone components
- Advanced Setup: Configured vitest.config.ts for custom behavior
- Browser Testing: Set up Playwright for real browser testing
- 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:
- Back up your current test configuration
- Follow the steps above in a feature branch
- Run your existing tests and fix any issues
- Gradually adopt explicit imports and modern patterns
- 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)