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 jsdomConfiguration
// 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:
- Unit tests for utility functions
- Component tests for critical UI
- 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

