Back to blog

Automated Testing in Frontend: Complete Guide with Vitest and Playwright

Hello HaWkers, automated testing is one of the most valued skills for frontend developers in 2025. With increasingly complex applications, ensuring quality without exhaustive manual testing has become impossible.

Have you ever released a feature thinking everything was fine, only to discover it broke something in another part of the application? Let's solve that for good.

Why Test Frontend in 2025

The JavaScript testing ecosystem has matured significantly. Modern tools like Vitest and Playwright have made tests faster and more pleasant to write.

The Cost of Not Testing

Market statistics:

  • Bugs in production cost 10x more to fix than in development
  • 40% of development time is spent fixing bugs
  • Companies with good test coverage release features 2x faster
  • Code reviews are 50% faster when there are tests

The testing pyramid:

Type Quantity Speed Cost
Unit Many Very fast Low
Integration Moderate Fast Medium
E2E Few Slow High

Setting Up Vitest

Vitest has become the standard for unit and integration tests in modern JavaScript projects. It's compatible with Jest's API but significantly faster.

Initial Setup

# Install Vitest and dependencies
npm install -D vitest @vitest/coverage-v8

# For React
npm install -D @testing-library/react @testing-library/jest-dom jsdom

# For Vue
npm install -D @vue/test-utils @testing-library/vue jsdom

Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
      ],
    },
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// Clean up DOM after each test
afterEach(() => {
  cleanup();
});

Unit Tests with Vitest

Testing Utility Functions

// src/utils/formatters.ts
export function formatCurrency(value: number, locale = 'en-US'): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency: locale === 'en-US' ? 'USD' : 'BRL',
  }).format(value);
}

export function formatDate(date: Date | string, locale = 'en-US'): string {
  const d = typeof date === 'string' ? new Date(date) : date;
  return new Intl.DateTimeFormat(locale, {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
  }).format(d);
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^\w\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .trim();
}
// src/utils/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, formatDate, slugify } from './formatters';

describe('formatCurrency', () => {
  it('formats values in US dollars by default', () => {
    expect(formatCurrency(1234.56)).toBe('$1,234.56');
  });

  it('formats values in Brazilian Real', () => {
    expect(formatCurrency(1234.56, 'pt-BR')).toBe('R$ 1.234,56');
  });

  it('formats negative values correctly', () => {
    expect(formatCurrency(-500)).toBe('-$500.00');
  });

  it('formats zero correctly', () => {
    expect(formatCurrency(0)).toBe('$0.00');
  });
});

describe('formatDate', () => {
  it('formats Date object in en-US', () => {
    const date = new Date('2025-11-22');
    expect(formatDate(date)).toBe('11/22/2025');
  });

  it('formats ISO string in en-US', () => {
    expect(formatDate('2025-11-22')).toBe('11/22/2025');
  });

  it('formats in pt-BR when specified', () => {
    expect(formatDate('2025-11-22', 'pt-BR')).toBe('22/11/2025');
  });
});

describe('slugify', () => {
  it('converts simple text to slug', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });

  it('removes accents', () => {
    expect(slugify('Programação em Português')).toBe('programacao-em-portugues');
  });

  it('removes special characters', () => {
    expect(slugify('Test@#$%String!')).toBe('teststring');
  });

  it('normalizes multiple spaces and hyphens', () => {
    expect(slugify('Multiple   Spaces---Here')).toBe('multiple-spaces-here');
  });
});

Testing React Components

// src/components/Button.tsx
import { ButtonHTMLAttributes, ReactNode } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  isLoading?: boolean;
  children: ReactNode;
}

export function Button({
  variant = 'primary',
  isLoading = false,
  children,
  disabled,
  ...props
}: ButtonProps) {
  const variants = {
    primary: 'bg-blue-500 hover:bg-blue-600 text-white',
    secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
    danger: 'bg-red-500 hover:bg-red-600 text-white',
  };

  return (
    <button
      className={`px-4 py-2 rounded font-medium transition-colors ${variants[variant]} ${
        (disabled || isLoading) && 'opacity-50 cursor-not-allowed'
      }`}
      disabled={disabled || isLoading}
      {...props}
    >
      {isLoading ? (
        <span className="flex items-center gap-2">
          <span className="animate-spin">⏳</span>
          Loading...
        </span>
      ) : (
        children
      )}
    </button>
  );
}
// src/components/Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('renders children text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>Click me</Button>);

    fireEvent.click(screen.getByText('Click me'));

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('does not call onClick when disabled', () => {
    const handleClick = vi.fn();
    render(
      <Button onClick={handleClick} disabled>
        Click me
      </Button>
    );

    fireEvent.click(screen.getByText('Click me'));

    expect(handleClick).not.toHaveBeenCalled();
  });

  it('shows loading state', () => {
    render(<Button isLoading>Submit</Button>);

    expect(screen.getByText('Loading...')).toBeInTheDocument();
    expect(screen.queryByText('Submit')).not.toBeInTheDocument();
  });

  it('disables button when isLoading', () => {
    render(<Button isLoading>Submit</Button>);

    expect(screen.getByRole('button')).toBeDisabled();
  });

  it('applies variant classes correctly', () => {
    const { rerender } = render(<Button variant="primary">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-blue-500');

    rerender(<Button variant="danger">Test</Button>);
    expect(screen.getByRole('button')).toHaveClass('bg-red-500');
  });
});

Testing Custom Hooks

// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value: T | ((val: T) => T)) => {
    try {
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue] as const;
}
// src/hooks/useLocalStorage.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
    vi.clearAllMocks();
  });

  it('returns initial value when localStorage is empty', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'default'));

    expect(result.current[0]).toBe('default');
  });

  it('returns value from localStorage when exists', () => {
    localStorage.setItem('test-key', JSON.stringify('stored-value'));

    const { result } = renderHook(() => useLocalStorage('test-key', 'default'));

    expect(result.current[0]).toBe('stored-value');
  });

  it('updates value in state and localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));

    act(() => {
      result.current[1]('updated');
    });

    expect(result.current[0]).toBe('updated');
    expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('updated');
  });

  it('supports update function', () => {
    const { result } = renderHook(() => useLocalStorage('counter', 0));

    act(() => {
      result.current[1]((prev) => prev + 1);
    });

    expect(result.current[0]).toBe(1);
  });

  it('works with complex objects', () => {
    const initialUser = { name: 'John', age: 25 };
    const { result } = renderHook(() => useLocalStorage('user', initialUser));

    act(() => {
      result.current[1]({ ...result.current[0], age: 26 });
    });

    expect(result.current[0]).toEqual({ name: 'John', age: 26 });
  });
});

E2E Tests with Playwright

Playwright is the most complete tool for end-to-end testing in 2025. It supports multiple browsers and is significantly faster than Cypress.

Configuration

npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Practical E2E Tests

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('user can login with valid credentials', async ({ page }) => {
    await page.goto('/login');

    // Fill form
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');

    // Submit
    await page.getByRole('button', { name: 'Sign In' }).click();

    // Verify redirect
    await expect(page).toHaveURL('/dashboard');

    // Verify logged in user element
    await expect(page.getByText('Welcome, User')).toBeVisible();
  });

  test('shows error with invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('wrongpassword');
    await page.getByRole('button', { name: 'Sign In' }).click();

    await expect(page.getByText('Invalid email or password')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });

  test('redirects unauthenticated user to login', async ({ page }) => {
    await page.goto('/dashboard');

    await expect(page).toHaveURL('/login?redirect=/dashboard');
  });
});
// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Shopping Cart', () => {
  test.beforeEach(async ({ page }) => {
    // Login before each test
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await expect(page).toHaveURL('/dashboard');
  });

  test('adds product to cart', async ({ page }) => {
    await page.goto('/products');

    // Click first product
    await page.getByTestId('product-card').first().click();

    // Add to cart
    await page.getByRole('button', { name: 'Add to Cart' }).click();

    // Verify cart counter
    await expect(page.getByTestId('cart-count')).toHaveText('1');
  });

  test('updates quantity in cart', async ({ page }) => {
    // Add product first
    await page.goto('/products/1');
    await page.getByRole('button', { name: 'Add to Cart' }).click();

    // Go to cart
    await page.goto('/cart');

    // Increase quantity
    await page.getByRole('button', { name: 'Increase quantity' }).click();

    // Verify updated quantity
    await expect(page.getByTestId('item-quantity')).toHaveValue('2');
  });

  test('completes checkout successfully', async ({ page }) => {
    // Setup: add product
    await page.goto('/products/1');
    await page.getByRole('button', { name: 'Add to Cart' }).click();
    await page.goto('/cart');

    // Start checkout
    await page.getByRole('button', { name: 'Checkout' }).click();

    // Fill shipping data
    await page.getByLabel('ZIP Code').fill('10001');
    await page.getByRole('button', { name: 'Find Address' }).click();
    await expect(page.getByLabel('Street')).toHaveValue(/Broadway/);

    await page.getByLabel('Number').fill('1000');

    // Select payment method
    await page.getByLabel('Credit Card').check();
    await page.getByLabel('Card Number').fill('4111111111111111');
    await page.getByLabel('Expiry').fill('12/28');
    await page.getByLabel('CVV').fill('123');

    // Confirm
    await page.getByRole('button', { name: 'Confirm Order' }).click();

    // Verify success
    await expect(page).toHaveURL(/\/order\/\d+/);
    await expect(page.getByText('Order placed successfully')).toBeVisible();
  });
});

Patterns and Best Practices

Page Object Model

// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign In' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// Usage in test
test('login with page object', async ({ page }) => {
  const loginPage = new LoginPage(page);

  await loginPage.goto();
  await loginPage.login('user@example.com', 'password123');

  await expect(page).toHaveURL('/dashboard');
});

Custom Fixtures

// e2e/fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

type Fixtures = {
  loginPage: LoginPage;
  authenticatedPage: void;
};

export const test = base.extend<Fixtures>({
  loginPage: async ({ page }, use) => {
    const loginPage = new LoginPage(page);
    await use(loginPage);
  },

  authenticatedPage: async ({ page }, use) => {
    // Setup: login
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await page.waitForURL('/dashboard');

    // Execute test
    await use();

    // Cleanup: can logout or clear data
  },
});

export { expect } from '@playwright/test';

Conclusion

Automated testing in frontend has gone from luxury to necessity. With Vitest and Playwright, you have modern, fast, and pleasant tools to use.

Start with:

  1. Unit tests for utility functions
  2. Component tests for critical UI
  3. Some E2E tests for main flows

Don't try to achieve 100% coverage immediately. Focus on testing what matters most and expand gradually.

If you want to deepen your knowledge in JavaScript and its best practices, I recommend checking out another article: ECMAScript 2025: New JavaScript Features where you'll discover the language's new features.

Let's go! 🦅

📚 Want to Deepen Your JavaScript Knowledge?

This article covered testing, but there's much more to explore in modern development.

Developers who invest in solid, structured knowledge tend to have more opportunities in the market.

Complete Study Material

If you want to master JavaScript from basics to advanced, I've prepared a complete guide:

Investment options:

  • 1x of $4.90 on card
  • or $4.90 at sight

👉 Learn About JavaScript Guide

💡 Material updated with industry best practices

Comments (0)

This article has no comments yet 😢. Be the first! 🚀🦅

Add comments