React: Testes de unidade (Vitest + React Testing Library)
Última atualização: 2026-08-26
Ao refatorar um componente antigo, Tom alterou “acidentalmente” a lógica de estado interna, causando problemas na interface do usuário em três páginas que dependiam desse componente. Como não havia testes de unidade, esse problema só foi descoberto durante os testes de controle de qualidade, o que fez com que toda a equipe perdesse o tempo de iteração. Tom decidiu introduzir o Vitest e a React Testing Library no projeto para utilizar testes automatizados e garantir que nenhuma alteração prejudicasse a funcionalidade existente.
1. O que você vai aprender
- Instalação e configuração do Vitest (ambiente jsdom, setupFiles)
- Uso coordenado das três principais APIs de teste: render, screen e userEvent
- Testando a validação de propriedades de componentes e callbacks de eventos
- Verificação do estado de carregamento de componentes assíncronos (findBy / waitFor)
- Simular dependências externas (solicitações de API, módulos)
2. Diagramas conceituais
O diagrama a seguir ilustra o papel e as relações dos testes unitários no desenvolvimento de componentes do React:
flowchart LR
A[Creating Components] --> B[Writing Tests]
B --> C{Run Test}
C -->|Through| D[Submit Code]
C -->|Failure| E[Positioning Bug]
E --> F{Error Type}
F -->|Rendering Issues| G[getByText / getByRole]
F -->|Interaction Issues| H[userEvent.click]
F -->|Asynchronous Issues| I[findByText / waitFor]
F -->|External Dependencies| J[vi.mock / vi.fn]
G --> A
H --> A
I --> A
J --> A
style B fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. Um cenário da vida real
A equipe do Tom possui um componente UserCard que exibe informações do usuário. Ele aceita um objeto user e uma função de retorno onFollow, e altera o estilo do botão com base no estado isFollowing.
Durante uma refatoração, Tom alterou os valores iniciais do estado interno, fazendo com que isFollowing assumisse o valor padrão true — como resultado, todos os cartões dos usuários passaram a exibir “Seguindo” por padrão. Esse bug só foi descoberto durante os testes de aceitação realizados pelo gerente de produto.
Se os testes já estivessem em vigor naquela época, esse problema de regressão teria sido detectado em questão de segundos durante a fase npm test. Tom decidiu adicionar testes de unidade para todos os componentes principais.
(1) Configuração do ambiente — Configuração da infraestrutura de testes
Primeiro, instale todas as dependências:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Configurar o Vitest (adicionar o campo test ao vite.config.ts):
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// Usage jsdom Simulate a browser environment
environment: 'jsdom',
// Global Registration describe / test / expect,No manual import required
globals: true,
// Configuration files that run before the test starts
setupFiles: './src/test/setup.ts',
},
})
Crie um arquivo de instalação:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// This line allows toBeInTheDocument()、toHaveTextContent() Assertions are available
Adicione um script de teste ao package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
(2) Três APIs principais
A filosofia de testes da React Testing Library: Não teste detalhes de implementação; teste apenas o que os usuários podem ver e com o que podem interagir.
| API | Finalidade | Recursos |
|---|---|---|
render(component) |
Renderização de componentes no DOM virtual | Retorno de referências a contêineres e métodos auxiliares |
screen |
Ponto de entrada para pesquisa de elementos globais | Oferece três tipos de métodos: getBy, findBy e queryBy |
userEvent |
Simular ações do usuário | Mais realista do que fireEvent |
Princípio fundamental: Use
getByRoleprimeiro (semântico),getByTextem segundo lugar egetByTestIdpor último.
▶ Exemplo 1: Testando a renderização e a interatividade do componente Counter
Primeiro, vamos escrever um componente “Counter” simples:
// Counter.tsx
import { useState } from 'react'
interface CounterProps {
initialCount?: number
step?: number
label?: string
}
export function Counter({
initialCount = 0,
step = 1,
label = 'Count',
}: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>
{label}:{count}
</p>
<button onClick={() => setCount(c => c + step)}>+{step}</button>
<button onClick={() => setCount(c => c - step)} disabled={count <= 0}>
-{step}
</button>
{count >= 10 && (
<p role="alert">Maximum Value Reached Alert</p>
)}
</div>
)
}
Elaboração de provas:
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter Components', () => {
// Test 1:Initial Rendering
test('Display the initial counter value', () => {
render(<Counter initialCount={5} />)
// getByText — Search by text content
expect(screen.getByText('Count:5')).toBeInTheDocument()
})
// Test 2:Click the "Add" button
test('Click +1 The count increases after the button is pressed', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={1} />)
const incrementBtn = screen.getByRole('button', { name: '+1' })
await user.click(incrementBtn)
expect(screen.getByText('Count:1')).toBeInTheDocument()
})
// Test 3:The count is 0 "Time Decrease" Button Disabled
test('The count is 0 "Time Decrease" Button Disabled', () => {
render(<Counter initialCount={0} />)
const decrementBtn = screen.getByRole('button', { name: '-1' })
expect(decrementBtn).toBeDisabled()
})
// Test 4:Display a reminder when the threshold is reached
test('Count reached 10 Display reminders', async () => {
const user = userEvent.setup()
render(<Counter initialCount={9} step={1} />)
// No reminder at the beginning
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// Click to add 1
await user.click(screen.getByRole('button', { name: '+1' }))
// Now there's a reminder
expect(screen.getByRole('alert')).toHaveTextContent('Maximum Value Reached Alert')
})
// Test 5:Custom label and step
test('Supports customization label and step', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={5} label="Number of steps" />)
expect(screen.getByText('Number of steps:0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '+5' }))
expect(screen.getByText('Number of steps:5')).toBeInTheDocument()
})
})
Este teste demonstra quatro modos:
getByText— Encontrar elementos que contenham o texto especificado (o método mais simples e direto)getByRole— Pesquisa por função ARIA; a opçãonamefornece uma correspondência exata para o texto do botãoqueryByRole— Procura um elemento que pode não existir, retornandonullem vez de lançar uma exceçãotoBeDisabled()/toHaveTextContent()— Afirmações semânticas fornecidas pelo jest-dom
(3) Testando propriedades e callbacks de eventos
Os componentes geralmente recebem dados e funções de retorno de chamada por meio de props. O objetivo dos testes é verificar se as funções de retorno de chamada são chamadas corretamente e se os parâmetros estão corretos.
▶ Exemplo 2: Testando as propriedades e os eventos do componente TodoItem
// TodoItem.tsx
interface Todo {
id: number
text: string
completed: boolean
}
interface TodoItemProps {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
background: todo.completed ? '#f6ffed' : '#fff',
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`Mark ${todo.text}`}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#333',
}}
>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
aria-label={`Delete ${todo.text}`}
style={{
border: 'none',
background: '#ff4d4f',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
cursor: 'pointer',
fontSize: 12,
}}
>
Delete
</button>
</div>
)
}
// TodoItem.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoItem } from './TodoItem'
describe('TodoItem Components', () => {
const mockTodo = {
id: 42,
text: 'Study React Test',
completed: false,
}
test('Format the to-do list text', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
expect(screen.getByText('Study React Test')).toBeInTheDocument()
})
test('Completed items are displayed with a strikethrough', () => {
render(
<TodoItem
todo={{ ...mockTodo, completed: true }}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
expect(text).toHaveStyle('text-decoration: line-through')
})
test('Triggered by clicking the checkbox onToggle', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={onToggle}
onDelete={vi.fn()}
/>
)
await user.click(screen.getByRole('checkbox'))
expect(onToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledWith(42) // The validation parameters are todo.id
})
test('Triggered by clicking the Delete button onDelete', async () => {
const onDelete = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={onDelete}
/>
)
await user.click(screen.getByRole('button', { name: /Delete/ }))
expect(onDelete).toHaveBeenCalledWith(42)
})
test('Unfinished items are not struck through', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
// Note:Inline styles text-decoration as 'none',rather than not having this property
expect(text).not.toHaveStyle('text-decoration: line-through')
})
})
Principais usos da função Mock vi.fn():
| API | Função |
|---|---|
vi.fn() |
Criar uma função simulada vazia |
toHaveBeenCalledTimes(n) |
Verificado n vezes |
toHaveBeenCalledWith(...) |
Verificar os parâmetros durante a chamada |
vi.fn().mockResolvedValue(x) |
Simulação de resposta assíncrona de sucesso |
vi.fn().mockRejectedValue(e) |
Simulação de respostas de falha assíncronas |
(4) Testando componentes assíncronos
Muitos componentes exibem inicialmente “Carregando...” durante o carregamento e, em seguida, exibem o conteúdo assim que os dados chegam. É necessário usar findBy (espera assíncrona) para testar esses tipos de cenários.
▶ Exemplo 3: Testando o componente de carregamento assíncrono de dados
// UserProfile.tsx
interface UserProfileProps {
userId: number
}
interface UserData {
id: number
name: string
email: string
}
// Simulation API Call
async function fetchUser(id: number): Promise<UserData> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
export function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<UserData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const data = await fetchUser(userId)
if (!cancelled) setUser(data)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [userId])
if (loading) return <div aria-label="Loading...">Loading......</div>
if (error) return <div role="alert">Error:{error}</div>
if (!user) return <div>No data</div>
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
// Mock out fetchUser module
vi.mock('./UserProfile', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// Rewrite fetchUser Implementation
fetchUser: vi.fn(),
}
})
// A Better Approach:Separately mock API Module
// vi.mock('../api', () => ({
// fetchUser: vi.fn()
// }))
describe('UserProfile Asynchronous Components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('Display "Loading" while loading', () => {
// let fetch All along pending
vi.spyOn(global, 'fetch').mockImplementation(
() => new Promise(() => {}) // Never resolve
)
render(<UserProfile userId={1} />)
expect(screen.getByLabelText('Loading...')).toBeInTheDocument()
})
test('Display user information after successful loading', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
// Mock fetch Return successful data
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response)
render(<UserProfile userId={1} />)
// findByText — Waiting Asynchronously for an Element to Appear(Default Timeout 1000ms)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
test('Display an error message when loading fails', async () => {
// Mock fetch Back 500
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response)
render(<UserProfile userId={1} />)
// Wait for an error message to appear
expect(await screen.findByRole('alert')).toHaveTextContent('Error:')
})
test('The state is not updated when the component is unmounted(Preventing Memory Leaks)', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
let resolvePromise!: (value: any) => void
vi.spyOn(global, 'fetch').mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve
})
)
const { unmount } = render(<UserProfile userId={1} />)
// in fetch Uninstall Components Before Completion
unmount()
// At this moment resolve,But the component has been uninstalled,Probably not. setState
resolvePromise({
ok: true,
json: async () => mockUser,
} as Response)
// I didn't make a mistake = Test Passed
})
})
Uma comparação entre os três principais métodos de teste assíncrono:
| Método | Síncrono/Assíncrono | Quando o elemento não existe | Tempo limite padrão | Caso de uso |
|---|---|---|---|---|
getByText |
Síncrono | Lança um erro imediatamente | - | O elemento deve existir |
queryByText |
Sincronizar | Voltar para null |
- | O elemento não existe |
findByText |
Assíncrono (Promise) | Gerar um erro após o tempo limite | 1000 ms | Aguardar a renderização assíncrona |
(5) Melhores práticas para simular dependências externas
Em projetos reais, os componentes geralmente dependem de APIs, roteamento e gerenciamento de estado. Simular essas dependências é fundamental para os testes.
Método 1: Simular a variável global fetch
// Replace the global variable before each test fetch
beforeEach(() => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: 'mock' }),
} as Response)
})
afterEach(() => {
vi.restoreAllMocks() // Restore to Original fetch
})
Método 2: O módulo simulado
// api.ts — Real Module
export async function fetchUsers() {
const res = await fetch('/api/users')
return res.json()
}
// Testing... Mock
vi.mock('../api', () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Mock User' },
])
}))
Método 3: Simular o React Router
import { MemoryRouter } from 'react-router-dom'
test('Rendering in the route context', () => {
render(
<MemoryRouter initialEntries={['/users/1']}>
<UserDetailPage />
</MemoryRouter>
)
})
▶ Exemplo 4: Testando um hook personalizado
import { renderHook, act } from '@testing-library/react'
import { useState, useCallback } from 'react'
function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, increment, decrement, reset }
}
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.decrement())
expect(result.current.count).toBe(4)
})
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
})
▶ Exemplo 5: Testes de integração — Processo de envio de formulário
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function handleSubmit(e) {
e.preventDefault()
if (!email.includes('@')) { setError('Invalid email'); return }
if (password.length < 6) { setError('Password too short'); return }
setError('')
await onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" data-testid="email" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" data-testid="password" />
{error && <p data-testid="error">{error}</p>}
<button type="submit">Login</button>
</form>
)
}
describe('LoginForm integration', () => {
test('shows error for invalid email', async () => {
render(<LoginForm onSubmit={jest.fn()} />)
await userEvent.type(screen.getByTestId('email'), 'invalid')
await userEvent.type(screen.getByTestId('password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByTestId('error')).toHaveTextContent('Invalid email')
})
test('calls onSubmit with valid data', async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined)
render(<LoginForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByTestId('email'), 'alice@test.com')
await userEvent.type(screen.getByTestId('password'), 'secure123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@test.com', password: 'secure123' })
})
})
})
❓ Perguntas Frequentes
P: Qual é exatamente a diferença entre getBy, findBy e queryBy? R: Uma regra prática simples: use
getByquando houver garantia de que o elemento existe (um erro é lançado se ele não for encontrado); usequeryByquando o elemento pode não existir (retorna null); usefindByquando o elemento for renderizado de forma assíncrona (retorna uma Promise; um erro é lançado após o tempo limite). Cada método possui variantes correspondentes de Role/Text/TestId, comogetByRole,findByTextequeryByTestId.
P: O que devo usar,
userEventoufireEvent? R: UseuserEventsempre que possível.fireEventé uma API de baixo nível que aciona diretamente eventos DOM.userEventsimula uma sequência completa de ações do usuário com base emfireEvent(por exemplo, um “clique” inclui mousedown → mouseup → click), o que se aproxima mais do comportamento real do navegador. Somente para operações não suportadas poruserEventvocê deve recorrer afireEvent.
P: Deve-se simular componentes filhos nos testes? R: A filosofia da React Testing Library é “não simular componentes filhos”, pois os testes devem simular a perspectiva do usuário — o que o usuário vê é a árvore completa de componentes. Você só deve considerar a simulação de componentes filhos quando eles tiverem efeitos colaterais significativos (como bibliotecas de animação complexas ou componentes de gráficos de terceiros). Basta substituí-los por
vi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>).
P: Devo limpar o ambiente de teste no
beforeEach? R: Sim. Após cada teste, você deve limpar o DOM renderizado e os mocks. Recomendamos usarafterEach(() => { vi.clearAllMocks() }). O Vitest descarrega automaticamente os componentes após cada renderização, mas você deve limpar manualmente o estado do mock. Se estiver usando um mock de fetch global, usevi.restoreAllMocks()para restaurar a implementação original.
P: Qual nível de cobertura de teste é suficiente? R: Não existe um padrão único que sirva para todos, mas os parâmetros de referência do setor sugerem: 80% ou mais para a lógica de negócios principal, 90% ou mais para funções utilitárias genéricas e 60% ou mais para componentes da interface do usuário. Não busque 100% de cobertura — alguns códigos (como estilos CSS e passagem simples de propriedades) apresentam um ROI de teste muito baixo. O importante é cobrir os caminhos de código que teriam o “maior impacto caso ocorresse um bug”. O parâmetro
--coveragedo Vitest pode gerar um relatório de cobertura do Istanbul.
📖 Resumo
- A biblioteca de testes Vitest + React é a combinação padrão para testes de unidade no React
- As três principais APIs — render, screen e userEvent — abrangem todo o processo de renderização, pesquisa e interação dos componentes.
- Os três métodos de consulta — getBy (síncrono, com garantia de existência), queryBy (síncrono, pode não existir) e findBy (assíncrono, pendente) — têm, cada um, uma finalidade específica.
- vi.fn() cria uma função simulada para verificar chamadas de callback; vi.spyOn() simula funções globais
- Ao testar componentes assíncronos, use
findByouwaitForpara aguardar a renderização assíncrona da interface do usuário - Bons testes não se concentram em detalhes de implementação; eles apenas verificam o comportamento perceptível pelo usuário.
📝 Exercícios
- Escreva testes abrangentes para um componente
Button: verifique se clicar nele acionaonClickedisabled, se ele fica inativo para cliques, se o texto correto é exibido e se o className personalizado entra em vigor. Aborde pelo menos 4 casos de teste. - Escreva testes para um componente
UserProfile: O teste deve verificar se, no estado de carregamento, é exibido “carregando”; no estado de sucesso, são exibidas as informações do usuário (com tratamento assíncrono); e, no estado de falha, é exibida uma mensagem de erro. Usevi.spyOnpara simular a API de busca. - Escreva testes de integração para um componente
TodoApp(incluindo TodoList, TodoItem e o formulário AddTodo): Teste as três funções — adicionar uma nova tarefa, marcar uma tarefa como concluída e excluir uma tarefa — para verificar se a interface do usuário da lista é atualizada corretamente.