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();
}
}
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);
});
});
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
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();
});
});
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();
});
});
});
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();
});
});
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
Step 2: Add Vitest to Your Project
ng add @angular/vitest
This command automatically:
- Installs Vitest and related dependencies
- Creates
vitest.config.ts - Updates
angular.jsontest 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
}
}
}
});
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();
});
});
});
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'
});
});
});
Key Changes:
- Import from
vitestinstead of relying on globals - Use
vi.fn()instead ofjasmine.createSpyObj() - Use
mockReturnValue()instead ofand.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');
}));
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();
});
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
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
}
}
}
});
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
}
}
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 )
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
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
}
}
}
}
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'
}
}
});
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'
GitHub Actions:
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage.icov
format: icov
SonarQube (Future Support):
sonar.javascript.icov.reportPaths=coverage/coverage.icov
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 }
}
}
}
}
What this tells us:
- Line 15 (
deleteUserfunction) was never executed—needs test coverage - Branch at line 12 has both paths tested
-
ngOnInitandloadUsersare 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
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)
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 |
------------------------------------|---------|----------|---------|---------|
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)
Slow Tests Warning
SLOW src/app/services/heavy-computation.service.spec.ts > should calculate complex data
Duration: 2.5s (threshold: 1s)
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');
});
});
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);
});
});
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');
});
});
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');
});
});
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);
});
});
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');
});
});
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));
}
}
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);
});
});
});
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/
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"
}
}
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'
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
}
}
});
When coverage drops below threshold:
ERROR: Coverage for lines (78.5%) does not meet global threshold (80%)
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
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', () => {});
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();
});
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!
};
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();
});
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);
});
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();
});
});
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);
});
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);
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
}
});
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
Tip 4: Debug Failing Tests
// vitest.config.ts
export default defineConfig({
test: {
// Stop on first failure
bail: 1,
// Retry flaky tests
retry: 2
}
});
Run specific test file:
ng test src/app/services/user.service.spec.ts
Run tests matching pattern:
ng test --reporter=verbose --grep="should handle errors"
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');
});
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:
- Try Vitest in a small project first—get comfortable before migrating your main app
- Set up CI/CD integration early—automated testing saves teams
- Share this with your team—discuss migration timeline and strategy
- 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! 🧪🧠
Top comments (0)