Setting Up Vitest and Playwright in Angular Projects
This guide walks you through setting up modern testing tools for your Angular application: Vitest for fast unit tests and Playwright for reliable end-to-end tests.
Why Vitest and Playwright?
Traditional Angular Testing Stack
- Karma + Jasmine: Slow, browser-based, complex configuration
- Protractor: Deprecated, WebDriver-based, unreliable
Modern Testing Stack
- Vitest: Lightning-fast, ESM-native, HMR support, better DX
- Playwright: Modern, multi-browser, auto-wait, powerful debugging
Prerequisites
- Angular 15+ project
- Node.js 18+ and npm
- Basic understanding of TypeScript and Angular
Part 1: Setting Up Vitest for Unit Tests
Step 1: Install Vitest Dependencies
npm install -D vitest @vitest/ui jsdom
Package breakdown:
vitest: The test runner@vitest/ui: Interactive web UI for test resultsjsdom: DOM environment for testing (mimics browser)
Step 2: Create Vitest Configuration
Create vitest.config.ts in your project root:
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
// Enable global test APIs (describe, it, expect, vi)
globals: true,
// Use jsdom to simulate browser environment
environment: 'jsdom',
// Setup file runs before each test file
setupFiles: ['./vitest.setup.ts'],
// Include patterns for test files
include: ['src/**/*.{test,spec}.{js,ts}'],
// Exclude patterns
exclude: [
'node_modules',
'dist',
// Exclude Angular component tests that use TestBed
// TestBed + Zone.js conflicts with Vitest
'**/**.component.spec.ts',
],
// Pool configuration for isolation
pool: 'forks',
isolate: true,
singleFork: true,
// Coverage configuration
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test.ts',
'**/*.spec.ts',
'**/*.test.ts',
],
},
},
// Path aliases (match your tsconfig.json)
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@app': path.resolve(__dirname, './src/app'),
'@core': path.resolve(__dirname, './src/app/@core'),
'@theme': path.resolve(__dirname, './src/app/@theme'),
},
},
});
Step 3: Create Setup File
Create vitest.setup.ts in your project root:
import { vi } from 'vitest';
// Import Angular compiler for JIT compilation
import '@angular/compiler';
// Mock browser APIs
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
global.localStorage = localStorageMock as any;
const sessionStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
global.sessionStorage = sessionStorageMock as any;
// Mock window.matchMedia for responsive tests
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// NOTE: Vitest works best for:
// - Service tests without TestBed
// - Utility function tests
// - Simple unit tests without Angular-specific testing utilities
//
// For Angular component tests with TestBed, use Karma or convert to not use TestBed
Step 4: Add NPM Scripts
Add these scripts to package.json:
{
"scripts": {
"test:vitest": "vitest",
"test:vitest:ui": "vitest --ui",
"test:vitest:run": "vitest run",
"test:vitest:coverage": "vitest run --coverage"
}
}
Step 5: Write Your First Vitest Test
Create a simple service to test: src/app/calculator.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CalculatorService {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
multiply(a: number, b: number): number {
return a * b;
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
}
Create the test: src/app/calculator.service.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { CalculatorService } from './calculator.service';
describe('CalculatorService', () => {
let service: CalculatorService;
beforeEach(() => {
service = new CalculatorService();
});
describe('Basic Operations', () => {
it('should add two numbers correctly', () => {
expect(service.add(2, 3)).toBe(5);
expect(service.add(-1, 1)).toBe(0);
expect(service.add(0, 0)).toBe(0);
});
it('should subtract two numbers correctly', () => {
expect(service.subtract(5, 3)).toBe(2);
expect(service.subtract(0, 5)).toBe(-5);
});
it('should multiply two numbers correctly', () => {
expect(service.multiply(3, 4)).toBe(12);
expect(service.multiply(-2, 3)).toBe(-6);
expect(service.multiply(0, 100)).toBe(0);
});
it('should divide two numbers correctly', () => {
expect(service.divide(10, 2)).toBe(5);
expect(service.divide(7, 2)).toBe(3.5);
});
it('should throw error when dividing by zero', () => {
expect(() => service.divide(10, 0)).toThrow('Division by zero');
});
});
});
Step 6: Testing Angular Services with HTTP
Example: src/app/data.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DataService } from './data.service';
import { of, throwError } from 'rxjs';
import { firstValueFrom } from 'rxjs';
describe('DataService', () => {
let service: DataService;
let mockHttp: any;
beforeEach(() => {
// Create mock HttpClient
mockHttp = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
};
// Inject mock into service
service = new DataService(mockHttp);
});
it('should fetch user data successfully', async () => {
const mockUser = { id: 1, name: 'John Doe', email: 'john@example.com' };
mockHttp.get.mockReturnValue(of(mockUser));
const result = await firstValueFrom(service.getUser(1));
expect(result).toEqual(mockUser);
expect(mockHttp.get).toHaveBeenCalledWith('/api/users/1');
expect(mockHttp.get).toHaveBeenCalledTimes(1);
});
it('should handle HTTP errors gracefully', async () => {
const errorResponse = { status: 404, message: 'User not found' };
mockHttp.get.mockReturnValue(throwError(() => errorResponse));
await expect(
firstValueFrom(service.getUser(999))
).rejects.toMatchObject(errorResponse);
});
it('should create a new user', async () => {
const newUser = { name: 'Jane Doe', email: 'jane@example.com' };
const createdUser = { id: 2, ...newUser };
mockHttp.post.mockReturnValue(of(createdUser));
const result = await firstValueFrom(service.createUser(newUser));
expect(result).toEqual(createdUser);
expect(mockHttp.post).toHaveBeenCalledWith('/api/users', newUser);
});
});
Step 7: Run Tests
# Run tests in watch mode
npm run test:vitest
# Run tests once
npm run test:vitest:run
# Run with UI
npm run test:vitest:ui
# Run with coverage
npm run test:vitest:coverage
Part 2: Setting Up Playwright for E2E Tests
Step 1: Install Playwright
npm install -D @playwright/test
npx playwright install chromium
What gets installed:
@playwright/test: Test runner and assertion librarychromium: Browser binaries for testing
Step 2: Create Playwright Configuration
Create playwright.config.ts in your project root:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Test directory
testDir: './e2e',
// Run tests in files in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Opt out of parallel tests on CI
workers: process.env.CI ? 1 : undefined,
// Reporter to use
reporter: 'html',
// Shared settings for all projects
use: {
// Base URL for all tests
baseURL: 'http://localhost:4200',
// Collect trace when retrying failed tests
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'retain-on-failure',
},
// Configure projects for different browsers
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile viewports
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
// Run local dev server before tests
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});
Step 3: Create E2E Test Directory
mkdir e2e
Step 4: Write Your First E2E Test
Create e2e/home.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Home Page', () => {
test('should load the home page successfully', async ({ page }) => {
// Navigate to home page
await page.goto('/');
// Wait for the page to fully load
await page.waitForLoadState('networkidle');
// Check that the page title is set
await expect(page).toHaveTitle(/.*/, { timeout: 10000 });
// Verify page content
await expect(page.locator('body')).toBeVisible();
});
test('should display navigation menu', async ({ page }) => {
await page.goto('/');
// Check for navigation elements
const nav = page.locator('nav, header');
await expect(nav).toBeVisible();
});
test('should not have console errors on load', async ({ page }) => {
const errors: string[] = [];
// Listen for console errors
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Filter out known harmless errors
const significantErrors = errors.filter(err =>
!err.includes('favicon') &&
!err.includes('net::ERR_FAILED')
);
expect(significantErrors).toHaveLength(0);
});
});
Step 5: Test Navigation
Create e2e/navigation.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('should navigate to about page', async ({ page }) => {
// Click the about link
await page.click('text=About');
// Wait for navigation
await page.waitForURL('**/about');
// Verify URL changed
expect(page.url()).toContain('/about');
// Verify page content
await expect(page.locator('h1')).toContainText(/about/i);
});
test('should navigate using browser back button', async ({ page }) => {
// Navigate to another page
await page.click('text=Contact');
await page.waitForURL('**/contact');
// Go back
await page.goBack();
// Should be back on home page
expect(page.url()).not.toContain('/contact');
});
test('should handle direct URL navigation', async ({ page }) => {
// Navigate directly to a route
await page.goto('/dashboard');
// Verify we're on the dashboard
await expect(page.locator('h1')).toContainText(/dashboard/i);
});
});
Step 6: Test Forms and User Interactions
Create e2e/login.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Login Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('should display login form', async ({ page }) => {
await expect(page.locator('form')).toBeVisible();
await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('should show validation errors for empty form', async ({ page }) => {
// Try to submit empty form
await page.click('button[type="submit"]');
// Check for validation messages
await expect(page.locator('text=/email.*required/i')).toBeVisible();
await expect(page.locator('text=/password.*required/i')).toBeVisible();
});
test('should show error for invalid email format', async ({ page }) => {
// Enter invalid email
await page.fill('input[type="email"]', 'invalid-email');
await page.fill('input[type="password"]', 'password123');
await page.click('button[type="submit"]');
// Check for email validation error
await expect(page.locator('text=/valid.*email/i')).toBeVisible();
});
test('should successfully login with valid credentials', async ({ page }) => {
// Fill in the form
await page.fill('input[type="email"]', 'test@example.com');
await page.fill('input[type="password"]', 'Password123!');
// Submit form
await page.click('button[type="submit"]');
// Should redirect to dashboard
await page.waitForURL('**/dashboard', { timeout: 5000 });
expect(page.url()).toContain('/dashboard');
});
test('should toggle password visibility', async ({ page }) => {
const passwordInput = page.locator('input[type="password"]');
const toggleButton = page.locator('[aria-label="Toggle password visibility"]');
// Initially password should be hidden
await expect(passwordInput).toHaveAttribute('type', 'password');
// Click toggle
await toggleButton.click();
// Password should be visible
await expect(passwordInput).toHaveAttribute('type', 'text');
});
});
Step 7: Test Responsive Design
Create e2e/responsive.spec.ts:
import { test, expect, devices } from '@playwright/test';
test.describe('Responsive Design', () => {
test('should display mobile menu on small screens', async ({ page }) => {
// Set mobile viewport
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
// Mobile menu icon should be visible
const mobileMenuIcon = page.locator('[aria-label="Menu"]');
await expect(mobileMenuIcon).toBeVisible();
// Desktop menu should be hidden
const desktopMenu = page.locator('nav.desktop-menu');
await expect(desktopMenu).toBeHidden();
});
test('should display desktop menu on large screens', async ({ page }) => {
// Set desktop viewport
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto('/');
// Desktop menu should be visible
const desktopMenu = page.locator('nav.desktop-menu');
await expect(desktopMenu).toBeVisible();
// Mobile menu icon should be hidden
const mobileMenuIcon = page.locator('[aria-label="Menu"]');
await expect(mobileMenuIcon).toBeHidden();
});
test('should adapt layout on tablet screens', async ({ page }) => {
// Set tablet viewport
await page.setViewportSize({ width: 768, height: 1024 });
await page.goto('/dashboard');
// Check that layout adapts (example: cards stack vertically)
const cards = page.locator('.card');
const firstCard = cards.first();
const secondCard = cards.nth(1);
const firstBox = await firstCard.boundingBox();
const secondBox = await secondCard.boundingBox();
// On tablet, cards should stack (y positions different)
if (firstBox && secondBox) {
expect(secondBox.y).toBeGreaterThan(firstBox.y);
}
});
});
Step 8: Test Performance
Create e2e/performance.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('Performance', () => {
test('should load home page within 3 seconds', async ({ page }) => {
const startTime = Date.now();
await page.goto('/');
await page.waitForLoadState('networkidle');
const loadTime = Date.now() - startTime;
// Page should load within 3 seconds
expect(loadTime).toBeLessThan(3000);
console.log(`Page loaded in ${loadTime}ms`);
});
test('should have good performance metrics', async ({ page }) => {
await page.goto('/');
// Get performance metrics
const metrics = await page.evaluate(() => {
const perfData = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return {
domContentLoaded: perfData.domContentLoadedEventEnd - perfData.domContentLoadedEventStart,
loadComplete: perfData.loadEventEnd - perfData.loadEventStart,
domInteractive: perfData.domInteractive - perfData.fetchStart,
};
});
console.log('Performance metrics:', metrics);
// Assert reasonable times
expect(metrics.domContentLoaded).toBeLessThan(2000);
expect(metrics.loadComplete).toBeLessThan(3000);
});
test('should not have excessive bundle size', async ({ page }) => {
// Track network requests
const jsFiles: number[] = [];
page.on('response', async (response) => {
const url = response.url();
if (url.endsWith('.js')) {
const size = (await response.body()).length;
jsFiles.push(size);
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Calculate total JS size
const totalSize = jsFiles.reduce((sum, size) => sum + size, 0);
const totalMB = (totalSize / 1024 / 1024).toFixed(2);
console.log(`Total JS size: ${totalMB} MB`);
// Assert reasonable bundle size (adjust based on your app)
expect(totalSize).toBeLessThan(5 * 1024 * 1024); // 5 MB
});
});
Step 9: Add NPM Scripts
Add these to package.json:
{
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report"
}
}
Step 10: Run E2E Tests
# Run all tests (headless)
npm run test:e2e
# Run with browser visible
npm run test:e2e:headed
# Run in UI mode (interactive)
npm run test:e2e:ui
# Debug mode (step through tests)
npm run test:e2e:debug
# View HTML report
npm run test:e2e:report
Part 3: Docker/DevContainer Setup
For CI/CD and development consistency, set up Playwright in Docker.
Update .devcontainer/startup.sh:
#!/bin/bash
set -e
echo "=== Starting frontend-ng startup.sh ==="
# Install system dependencies for Playwright
echo "Installing system dependencies for Playwright..."
apt-get update && apt-get install -y --no-install-recommends \
chromium \
chromium-driver \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libwayland-client0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
# Set environment variables
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=0
export PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
cd /workspace/frontend-ng
# Install npm dependencies
if [ ! -d "node_modules" ] || [ "package-lock.json" -nt "node_modules" ]; then
echo "Installing npm dependencies..."
npm ci
fi
# Install Vitest and Playwright
echo "Installing test dependencies..."
npm install -D vitest @vitest/ui @playwright/test jsdom
# Install Playwright browsers
echo "Installing Playwright browsers..."
npx playwright install chromium --with-deps
# Install Angular CLI globally
if ! command -v ng &>/dev/null; then
npm install -g @angular/cli
fi
echo "=== Setup complete ==="
Part 4: Best Practices and Tips
Vitest Best Practices
- Keep tests fast: Avoid TestBed, use direct instantiation
- Mock external dependencies: HTTP, LocalStorage, Services
- Use descriptive test names: “should calculate total correctly”
- Group related tests: Use
describeblocks - Test one thing at a time: Each test should verify one behavior
Playwright Best Practices
- Use auto-waiting: Playwright waits automatically
- Use user-facing selectors:
text=,role=, avoid CSS selectors - Keep tests independent: Each test should work alone
- Use page object model: For complex pages
- Test critical user journeys: Focus on important flows
Common Gotchas
Vitest:
- Zone.js conflicts with Vitest → Don’t use TestBed
- RxJS observables → Use
firstValueFrom()for async - Angular compiler needed → Import in setup file
Playwright:
- Start dev server before tests → Use
webServerconfig - Flaky selectors → Use data-testid attributes
- Timing issues → Use
waitForLoadState(),waitForURL()
Part 5: Example GitHub Actions CI/CD
Create .github/workflows/test.yml:
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm run test:vitest:run
- run: npm run test:vitest:coverage
- uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
- uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: playwright-report/
Conclusion
You now have a modern testing setup with:
- ⚡ Fast unit tests with Vitest
- 🎭 Reliable E2E tests with Playwright
- 🐳 Docker/DevContainer support
- 🚀 CI/CD ready configuration
Next Steps
- Migrate existing Karma tests to Vitest
- Replace Protractor tests with Playwright
- Add test coverage requirements
- Set up visual regression testing
- Configure test reports in CI/CD
Resources
Happy Testing! 🧪
