Setting Up Vitest and Playwright in Angular Projects

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 results
  • jsdom: 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 library
  • chromium: 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

  1. Keep tests fast: Avoid TestBed, use direct instantiation
  2. Mock external dependencies: HTTP, LocalStorage, Services
  3. Use descriptive test names: “should calculate total correctly”
  4. Group related tests: Use describe blocks
  5. Test one thing at a time: Each test should verify one behavior

Playwright Best Practices

  1. Use auto-waiting: Playwright waits automatically
  2. Use user-facing selectors: text=, role=, avoid CSS selectors
  3. Keep tests independent: Each test should work alone
  4. Use page object model: For complex pages
  5. 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 webServer config
  • 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

  1. Migrate existing Karma tests to Vitest
  2. Replace Protractor tests with Playwright
  3. Add test coverage requirements
  4. Set up visual regression testing
  5. Configure test reports in CI/CD

Resources


Happy Testing! 🧪