Testes Vitest + Playwright
O MegaShop entrou no ar, mas cada release teve bugs — erros de cálculo no carrinho de compras, falhas ocasionais de login, e erros 404 nas páginas de produtos. Charlie decidiu configurar um sistema de testes automatizados, usando Vitest para testes unitários e Playwright para testes ponta a ponta (E2E), para garantir qualidade confiável a cada release.
1. O Que Você Vai Aprender
- @nuxt/test-utils: O toolchain de testes oficial do Nuxt 3
- Testes Unitários Vitest: Testando Componentes, Composables e Stores
- Testes E2E Playwright: Interação de Página + Validação SSR
- Testes de API: $fetch testa a API do servidor
- Fluxo E2E do Carrinho de Compras do MegaShop + Teste Unitário usePriceFormat
2. Uma História Real de um Arquiteto
(1) Ponto Crítico: Testes manuais não cobrem todos os cenários
Antes de cada release, Bob testa manualmente o MegaShop — página inicial, páginas de produtos, carrinho de compras, checkout e painel administrativo. 5 navegadores x 3 idiomas x 20 páginas = 300 testes manuais. Com uma lacuna de cobertura de teste de 30%, pelo menos 2 bugs em produção são encontrados a cada release.
(2) Solução com Testes Automatizados
Vitest + Playwright: Automatizando a Execução de Testes:
// Vitest: teste unitário para usePriceFormat
test('formats USD price correctly', () => {
const price = ref(2999.99)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$2,999.99')
})
(3) Resultados: 0 testes manuais + 0 bugs em produção
Testes automatizados cobrem processos essenciais; CI executa automaticamente a cada commit, reduzindo a taxa de omissão de testes para 2% e trazendo o número de bugs em produção para 0.
3. A Pirâmide de Testes
(1) Hierarquia de Testes
graph TB
A[E2E Tests - Playwright<br/>Critical user flows] --> B[Integration Tests<br/>API + Component interactions]
B --> C[Unit Tests - Vitest<br/>Composables / Stores / Utils]
style A fill:#f96,stroke:#333
style B fill:#ff9,stroke:#333
style C fill:#9f9,stroke:#333
(2) Comparação de Tipos de Testes
| Dimensão | Teste Unitário | Teste de Integração | Teste E2E |
|---|---|---|---|
| Ferramentas | Vitest | Vitest | Playwright |
| Escopo | Função/Componente Único | Interação Entre Múltiplos Módulos | Fluxo Completo do Usuário |
| Velocidade | ⚡⚡⚡ Rápido | ⚡⚡ Médio | 🐢 Lento |
| Quantidade | Alto | Médio | Baixo |
| Valor | Correção Lógica | Consistência de Interface | Garantia do Fluxo do Usuário |
| Exemplo MegaShop | usePriceFormat | API Routes | Cart → Checkout |
4. Configuração do Vitest e Testes Unitários
(1) ▶Exemplo: Instalação e Configuração
npm install -D vitest @nuxt/test-utils @vue/test-utils happy-dom
Saída:
# Comando executado com sucesso
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules', '.nuxt']
}
}
})
(2) ▶Exemplo: Testes Unitários de Composable
// tests/composables/usePriceFormat.test.ts
import { ref } from 'vue'
describe('usePriceFormat', () => {
test('formats USD price with comma separator', () => {
const price = ref(2999.99)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$2,999.99')
})
test('formats JPY price with no decimals', () => {
const price = ref(29999)
const { formatted } = usePriceFormat(price, { currency: 'JPY', locale: 'ja-JP' })
expect(formatted.value).toBe('¥29,999')
})
test('reactively updates when price changes', () => {
const price = ref(100)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$100.00')
price.value = 200
expect(formatted.value).toBe('$200.00')
})
test('handles zero price', () => {
const price = ref(0)
const { formatted } = usePriceFormat(price)
expect(formatted.value).toBe('$0.00')
})
test('handles raw number input (not ref)', () => {
const { formatted } = usePriceFormat(50.5)
expect(formatted.value).toBe('$50.50')
})
})
Saída:
// Execução Bem-sucedida
(3) ▶Exemplo: Teste da Pinia Store
// tests/stores/cart.test.ts
import { setActivePinia, createPinia } from 'pinia'
describe('useCartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('adds item to cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
expect(store.items.length).toBe(1)
expect(store.totalItems).toBe(1)
})
test('increments quantity for existing item', () => {
const store = useCartStore()
const product = { id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' }
store.addItem(product)
store.addItem(product)
expect(store.items.length).toBe(1)
expect(store.items[0].quantity).toBe(2)
})
test('removes item from cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'Headphones', price: 299.99, image: '/img.jpg' })
store.removeItem(1)
expect(store.items.length).toBe(0)
})
test('calculates total price correctly', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
store.addItem({ id: 2, name: 'B', price: 200, image: '/b.jpg' })
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' }) // quantidade = 2
expect(store.totalPrice).toBe(400) // 100*2 + 200
})
test('clears cart', () => {
const store = useCartStore()
store.addItem({ id: 1, name: 'A', price: 100, image: '/a.jpg' })
store.clearCart()
expect(store.items.length).toBe(0)
expect(store.totalPrice).toBe(0)
})
})
Saída:
// Execução Bem-sucedida
5. Testes de Componentes
(1) ▶Exemplo: Testando o componente mountSuspended
// tests/components/ProductCard.test.ts
import { mountSuspended } from '@nuxt/test-utils/runtime'
describe('ProductCard', () => {
const mockProduct = {
id: 1, name: 'Premium Headphones', price: 299.99,
image: '/headphones.jpg', inStock: true
}
test('renders product name and price', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct }
})
expect(wrapper.text()).toContain('Premium Headphones')
expect(wrapper.text()).toContain('299.99')
})
test('emits add-to-cart event on button click', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct }
})
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('add-to-cart')).toBeTruthy()
expect(wrapper.emitted('add-to-cart')[0][0]).toEqual(mockProduct)
})
test('shows out of stock when not available', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: { ...mockProduct, inStock: false } }
})
expect(wrapper.text()).toContain('Out of Stock')
})
})
Saída:
// Execução Bem-sucedida
6. Testes E2E com Playwright
(1) ▶Exemplo: Configuração do Playwright
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
baseURL: 'http://localhost:3000',
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI
},
use: {
headless: true,
screenshot: 'only-on-failure'
}
})
Saída:
// Execução Bem-sucedida
(2) ▶Exemplo: Fluxo E2E do Carrinho de Compras
// e2e/cart-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Shopping Cart Flow', () => {
test('add product to cart and verify', async ({ page }) => {
// Navegar para lista de produtos
await page.goto('/products')
// Esperar produtos carregarem
await page.waitForSelector('.product-card')
// Clicar no botão "Add to Cart" do primeiro produto
await page.locator('.product-card:first-child button').click()
// Verificar contagem do carrinho atualizada
const cartCount = page.locator('.cart-count')
await expect(cartCount).toHaveText('1')
// Navegar para a página do carrinho
await page.goto('/cart')
// Verificar item no carrinho
await expect(page.locator('.cart-item')).toHaveCount(1)
})
test('complete checkout flow', async ({ page }) => {
// Login primeiro
await page.goto('/login')
await page.fill('input[type="email"]', 'alice@example.com')
await page.fill('input[type="password"]', 'password123')
await page.click('button[type="submit"]')
// Adicionar item e ir para checkout
await page.goto('/products/1')
await page.click('button:has-text("Add to Cart")')
await page.click('a:has-text("Cart")')
await page.click('a:has-text("Proceed to Checkout")')
// Verificar página de checkout
await expect(page.locator('h1')).toHaveText(/checkout/i)
})
})
Saída:
// Execução Bem-sucedida
7. Testes de API
(1) ▶Exemplo: Teste de API do Servidor
// tests/api/products.test.ts
import { setupTest } from '@nuxt/test-utils'
describe('Products API', () => {
setupTest()
test('GET /api/products returns product list', async () => {
const response = await $fetch('/api/products')
expect(response.items).toBeDefined()
expect(response.total).toBeGreaterThan(0)
expect(response.page).toBe(1)
})
test('GET /api/products/:id returns product detail', async () => {
const product = await $fetch('/api/products/1')
expect(product.id).toBe(1)
expect(product.name).toBeDefined()
expect(product.price).toBeDefined()
})
test('GET /api/products/:id returns 404 for invalid id', async () => {
await expect($fetch('/api/products/99999')).rejects.toThrow('404')
})
test('GET /api/products supports pagination', async () => {
const page1 = await $fetch('/api/products?page=1&limit=5')
const page2 = await $fetch('/api/products?page=2&limit=5')
expect(page1.items.length).toBeLessThanOrEqual(5)
expect(page2.items[0].id).not.toBe(page1.items[0].id)
})
test('GET /api/products supports category filter', async () => {
const result = await $fetch('/api/products?category=electronics')
result.items.forEach((item: any) => {
expect(item.category).toBe('electronics')
})
})
})
Saída:
// Execução Bem-sucedida
8. Exemplo Completo: O Framework de Testes do MegaShop
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:all": "npm run test && npm run test:e2e"
}
}
| Tipo de Teste | Comando | Cobertura | Quando Executar |
|---|---|---|---|
| Testes Unitários | vitest | Composable/Store/Utils | A cada commit |
| Testes de Componentes | vitest | Vue Components | A cada commit |
| Testes de API | vitest | Server API | A cada submissão |
| Testes E2E | Playwright | Fluxo do Usuário | Antes de merge do PR |
| Cobertura | vitest --coverage | Todos | Pipeline CI |
❓Perguntas Frequentes
mountSuspended e mount?mount é a função básica de montagem fornecida pelo Vue Test Utils, enquanto mountSuspended é fornecida por @nuxt/test-utils e suporta o contexto completo, incluindo plugins do Nuxt, auto-imports e Composables.$fetch de @nuxt/test-utils para requisitar diretamente o HTML renderizado pelo servidor e verificar se o HTML contém os dados. Você também pode usar o Playwright para desabilitar JavaScript e inspecionar a página.$fetch chamada dentro da Store precisa ser mockada. Use vitest.mock para interceptar $fetch, ou extraia a chamada de API em um Composable para facilitar o mock.📖Resumo
- Pirâmide de Testes: Extensos testes unitários + testes de integração moderados + testes E2E mínimos
- Vitest + @nuxt/test-utils para testar Composables, Stores e componentes; suporta o contexto Nuxt
- mountSuspended monta componentes em um ambiente Nuxt; auto-import e plugins estão automaticamente disponíveis
- Playwright E2E testa fluxos essenciais do usuário; verificação SSR pode desabilitar JavaScript
- Use
$fetchpara validar diretamente a resposta da API do servidor durante testes de API
📝Exercícios
- Exercício Básico (Dificuldade ⭐): Instale Vitest e @nuxt/test-utils, e escreva 5 testes unitários para
usePriceFormat. - Exercício Avançado (Dificuldade: ⭐⭐): Escreva um teste CRUD completo para
cartStoree um teste de componente paraProductCard. - Desafio (Dificuldade: ⭐⭐⭐): Escreva um teste E2E completo para um carrinho de compras usando Playwright: Navegar produtos → Adicionar ao carrinho → Ver carrinho → Checkout
---|



