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



2. Diagramas conceituais

O diagrama a seguir ilustra o papel e as relações dos testes unitários no desenvolvimento de componentes do React:

100%
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:

BASH
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):

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:

TS
// 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:

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 getByRole primeiro (semântico), getByText em segundo lugar e getByTestId por último.

▶ Exemplo 1: Testando a renderização e a interatividade do componente Counter

Primeiro, vamos escrever um componente “Counter” simples:

TSX
// 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>
  )
}
▶ Experimente

Elaboração de provas:

TSX
// 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:

  1. getByText — Encontrar elementos que contenham o texto especificado (o método mais simples e direto)
  2. getByRole — Pesquisa por função ARIA; a opção name fornece uma correspondência exata para o texto do botão
  3. queryByRole — Procura um elemento que pode não existir, retornando null em vez de lançar uma exceção
  4. toBeDisabled() / 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

TSX 📖 Somente leitura
// 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>
  )
}
56 linhas de lógica (limite de 40, somente leitura)
TSX
// 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

TSX 📖 Somente leitura
// 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>
  )
}
46 linhas de lógica (limite de 40, somente leitura)
TSX
// 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

TS
// 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

TS
// 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

TSX
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

JSX
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)
  })
})
▶ Experimente

▶ Exemplo 5: Testes de integração — Processo de envio de formulário

JSX 📖 Somente leitura
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' })
    })
  })
})
41 linhas de lógica (limite de 40, somente leitura)

❓ Perguntas Frequentes

P: Qual é exatamente a diferença entre getBy, findBy e queryBy? R: Uma regra prática simples: use getBy quando houver garantia de que o elemento existe (um erro é lançado se ele não for encontrado); use queryBy quando o elemento pode não existir (retorna null); use findBy quando 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, como getByRole, findByText e queryByTestId.

P: O que devo usar, userEvent ou fireEvent? R: Use userEvent sempre que possível. fireEvent é uma API de baixo nível que aciona diretamente eventos DOM. userEvent simula uma sequência completa de ações do usuário com base em fireEvent (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 por userEvent você deve recorrer a fireEvent.

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 usar afterEach(() => { 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, use vi.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 --coverage do Vitest pode gerar um relatório de cobertura do Istanbul.


📖 Resumo


📝 Exercícios

  1. Escreva testes abrangentes para um componente Button: verifique se clicar nele aciona onClick e disabled, 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.
  2. 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. Use vi.spyOn para simular a API de busca.
  3. 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.
Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%