Testes Automatizados no Front-end: Guia Completo com Vitest e Playwright
Olá HaWkers, testes automatizados são uma das habilidades mais valorizadas para desenvolvedores front-end em 2025. Com aplicações cada vez mais complexas, garantir qualidade sem testes manuais exaustivos se tornou impossível.
Você já lançou uma feature achando que estava tudo certo, só para descobrir que quebrou algo em outra parte da aplicação? Vamos resolver isso de vez.
Por Que Testar o Front-end em 2025
O ecossistema de testes JavaScript amadureceu significativamente. Ferramentas modernas como Vitest e Playwright tornaram os testes mais rápidos e agradáveis de escrever.
O Custo de Não Testar
Estatísticas de mercado:
- Bugs em produção custam 10x mais para corrigir que em desenvolvimento
- 40% do tempo de desenvolvimento é gasto corrigindo bugs
- Empresas com boa cobertura de testes lançam features 2x mais rápido
- Code reviews são 50% mais rápidas quando há testes
A pirâmide de testes:
| Tipo | Quantidade | Velocidade | Custo |
|---|---|---|---|
| Unitários | Muitos | Muito rápido | Baixo |
| Integração | Moderado | Rápido | Médio |
| E2E | Poucos | Lento | Alto |
Configurando Vitest
Vitest se tornou o padrão para testes unitários e de integração em projetos JavaScript modernos. Ele é compatível com a API do Jest mas significativamente mais rápido.
Setup Inicial
# Instalar Vitest e dependências
npm install -D vitest @vitest/coverage-v8
# Para React
npm install -D @testing-library/react @testing-library/jest-dom jsdom
# Para Vue
npm install -D @vue/test-utils @testing-library/vue jsdomConfiguração
// 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';
// Limpa o DOM após cada teste
afterEach(() => {
cleanup();
});
Testes Unitários com Vitest
Testando Funções Utilitárias
// src/utils/formatters.ts
export function formatCurrency(value: number, locale = 'pt-BR'): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: locale === 'pt-BR' ? 'BRL' : 'USD',
}).format(value);
}
export function formatDate(date: Date | string, locale = 'pt-BR'): 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('formata valores em Real brasileiro por padrão', () => {
expect(formatCurrency(1234.56)).toBe('R$ 1.234,56');
});
it('formata valores em Dólar americano', () => {
expect(formatCurrency(1234.56, 'en-US')).toBe('$1,234.56');
});
it('formata valores negativos corretamente', () => {
expect(formatCurrency(-500)).toBe('-R$ 500,00');
});
it('formata zero corretamente', () => {
expect(formatCurrency(0)).toBe('R$ 0,00');
});
});
describe('formatDate', () => {
it('formata Date object em pt-BR', () => {
const date = new Date('2025-11-22');
expect(formatDate(date)).toBe('22/11/2025');
});
it('formata string ISO em pt-BR', () => {
expect(formatDate('2025-11-22')).toBe('22/11/2025');
});
it('formata em en-US quando especificado', () => {
expect(formatDate('2025-11-22', 'en-US')).toBe('11/22/2025');
});
});
describe('slugify', () => {
it('converte texto simples para slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('remove acentos', () => {
expect(slugify('Programação em Português')).toBe('programacao-em-portugues');
});
it('remove caracteres especiais', () => {
expect(slugify('Test@#$%String!')).toBe('teststring');
});
it('normaliza múltiplos espaços e hífens', () => {
expect(slugify('Multiple Spaces---Here')).toBe('multiple-spaces-here');
});
});
Testando Componentes React
// 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>
Carregando...
</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('renderiza o texto do children', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('chama onClick quando clicado', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('não chama onClick quando disabled', () => {
const handleClick = vi.fn();
render(
<Button onClick={handleClick} disabled>
Click me
</Button>
);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).not.toHaveBeenCalled();
});
it('mostra estado de loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByText('Carregando...')).toBeInTheDocument();
expect(screen.queryByText('Submit')).not.toBeInTheDocument();
});
it('desabilita o botão quando isLoading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('aplica classes de variante corretamente', () => {
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');
});
});
Testando Hooks Customizados
// 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('retorna valor inicial quando localStorage está vazio', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'default'));
expect(result.current[0]).toBe('default');
});
it('retorna valor do localStorage quando existe', () => {
localStorage.setItem('test-key', JSON.stringify('stored-value'));
const { result } = renderHook(() => useLocalStorage('test-key', 'default'));
expect(result.current[0]).toBe('stored-value');
});
it('atualiza valor no state e 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('suporta função de atualização', () => {
const { result } = renderHook(() => useLocalStorage('counter', 0));
act(() => {
result.current[1]((prev) => prev + 1);
});
expect(result.current[0]).toBe(1);
});
it('funciona com objetos complexos', () => {
const initialUser = { name: 'João', age: 25 };
const { result } = renderHook(() => useLocalStorage('user', initialUser));
act(() => {
result.current[1]({ ...result.current[0], age: 26 });
});
expect(result.current[0]).toEqual({ name: 'João', age: 26 });
});
});
Testes E2E com Playwright
Playwright é a ferramenta mais completa para testes end-to-end em 2025. Suporta múltiplos browsers e é significativamente mais rápido que o Cypress.
Configuração
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,
},
});Testes E2E Práticos
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Autenticação', () => {
test('usuário pode fazer login com credenciais válidas', async ({ page }) => {
await page.goto('/login');
// Preenche formulário
await page.getByLabel('Email').fill('usuario@exemplo.com');
await page.getByLabel('Senha').fill('senha123');
// Submete
await page.getByRole('button', { name: 'Entrar' }).click();
// Verifica redirecionamento
await expect(page).toHaveURL('/dashboard');
// Verifica elemento de usuário logado
await expect(page.getByText('Bem-vindo, Usuário')).toBeVisible();
});
test('mostra erro com credenciais inválidas', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('usuario@exemplo.com');
await page.getByLabel('Senha').fill('senhaerrada');
await page.getByRole('button', { name: 'Entrar' }).click();
await expect(page.getByText('Email ou senha inválidos')).toBeVisible();
await expect(page).toHaveURL('/login');
});
test('redireciona usuário não autenticado para 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('Carrinho de Compras', () => {
test.beforeEach(async ({ page }) => {
// Login antes de cada teste
await page.goto('/login');
await page.getByLabel('Email').fill('usuario@exemplo.com');
await page.getByLabel('Senha').fill('senha123');
await page.getByRole('button', { name: 'Entrar' }).click();
await expect(page).toHaveURL('/dashboard');
});
test('adiciona produto ao carrinho', async ({ page }) => {
await page.goto('/produtos');
// Clica no primeiro produto
await page.getByTestId('produto-card').first().click();
// Adiciona ao carrinho
await page.getByRole('button', { name: 'Adicionar ao Carrinho' }).click();
// Verifica contador do carrinho
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('atualiza quantidade no carrinho', async ({ page }) => {
// Adiciona produto primeiro
await page.goto('/produtos/1');
await page.getByRole('button', { name: 'Adicionar ao Carrinho' }).click();
// Vai para o carrinho
await page.goto('/carrinho');
// Aumenta quantidade
await page.getByRole('button', { name: 'Aumentar quantidade' }).click();
// Verifica quantidade atualizada
await expect(page.getByTestId('item-quantity')).toHaveValue('2');
});
test('completa checkout com sucesso', async ({ page }) => {
// Setup: adiciona produto
await page.goto('/produtos/1');
await page.getByRole('button', { name: 'Adicionar ao Carrinho' }).click();
await page.goto('/carrinho');
// Inicia checkout
await page.getByRole('button', { name: 'Finalizar Compra' }).click();
// Preenche dados de entrega
await page.getByLabel('CEP').fill('01310-100');
await page.getByRole('button', { name: 'Buscar CEP' }).click();
await expect(page.getByLabel('Rua')).toHaveValue(/Paulista/);
await page.getByLabel('Número').fill('1000');
// Seleciona forma de pagamento
await page.getByLabel('Cartão de Crédito').check();
await page.getByLabel('Número do Cartão').fill('4111111111111111');
await page.getByLabel('Validade').fill('12/28');
await page.getByLabel('CVV').fill('123');
// Confirma
await page.getByRole('button', { name: 'Confirmar Pedido' }).click();
// Verifica sucesso
await expect(page).toHaveURL(/\/pedido\/\d+/);
await expect(page.getByText('Pedido realizado com sucesso')).toBeVisible();
});
});
Padrões e Boas Práticas
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('Senha');
this.submitButton = page.getByRole('button', { name: 'Entrar' });
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();
}
}
// Uso no teste
test('login com page object', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('usuario@exemplo.com', 'senha123');
await expect(page).toHaveURL('/dashboard');
});Fixtures Customizados
// 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: faz login
await page.goto('/login');
await page.getByLabel('Email').fill('usuario@exemplo.com');
await page.getByLabel('Senha').fill('senha123');
await page.getByRole('button', { name: 'Entrar' }).click();
await page.waitForURL('/dashboard');
// Executa o teste
await use();
// Cleanup: pode fazer logout ou limpar dados
},
});
export { expect } from '@playwright/test';Conclusão
Testes automatizados no front-end deixaram de ser um luxo para se tornarem necessidade. Com Vitest e Playwright, você tem ferramentas modernas, rápidas e agradáveis de usar.
Comece com:
- Testes unitários para funções utilitárias
- Testes de componentes para UI crítica
- Alguns testes E2E para fluxos principais
Não tente ter 100% de cobertura imediatamente. Foque em testar o que mais importa e vá expandindo gradualmente.
Se você quer aprofundar seus conhecimentos em JavaScript e suas melhores práticas, recomendo que dê uma olhada em outro artigo: ECMAScript 2025: Os Novos Recursos do JavaScript onde você vai descobrir as novidades da linguagem.
Bora pra cima! 🦅
📚 Quer Aprofundar Seus Conhecimentos em JavaScript?
Este artigo cobriu testes, mas há muito mais para explorar no mundo do desenvolvimento moderno.
Desenvolvedores que investem em conhecimento sólido e estruturado tendem a ter mais oportunidades no mercado.
Material de Estudo Completo
Se você quer dominar JavaScript do básico ao avançado, preparei um guia completo:
Opções de investimento:
- 1x de R$9,90 no cartão
- ou R$9,90 à vista
💡 Material atualizado com as melhores práticas do mercado

