Компоненты с data fetching требуют особого подхода. После render нужно дождаться завершения всех async операций:
import { render, screen, waitFor, findByText } from '@testing-library/react'
// Компонент с async загрузкой:
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
api.getUser(userId).then(u => {
setUser(u)
setIsLoading(false)
})
}, [userId])
if (isLoading) return <div>Загрузка...</div>
return <div data-testid="user-name">{user.name}</div>
}
// Тест:
test('загружает и отображает пользователя', async () => {
// Мокаем API:
jest.spyOn(api, 'getUser').mockResolvedValue({ id: 1, name: 'Алексей' })
render(<UserProfile userId={1} />)
// 1. Проверяем состояние загрузки:
expect(screen.getByText('Загрузка...')).toBeInTheDocument()
// 2. Ждём появления данных:
const userName = await screen.findByTestId('user-name') // findBy = автоматический waitFor
expect(userName).toHaveTextContent('Алексей')
// 3. Проверяем что loading исчез:
expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})// findBy* — сочетает getBy + waitFor (рекомендуется)
const element = await screen.findByText('Загружено')
// waitFor — для более сложных ожиданий
await waitFor(() => {
expect(screen.getByText('Готово')).toBeInTheDocument()
expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})
// waitFor с таймаутом:
await waitFor(
() => expect(screen.getByText('Готово')).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
)MSW перехватывает реальные HTTP-запросы (работает на уровне Service Worker или Node.js). Лучше чем мокать fetch/axios напрямую:
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Алексей' })
}),
http.get('/api/users/:id/posts', () => {
return HttpResponse.json([
{ id: 1, title: 'Первый пост' },
{ id: 2, title: 'Второй пост' },
])
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Тест ошибки — переопределяем handler:
test('показывает ошибку', async () => {
server.use(
http.get('/api/users/:id', () => {
return new HttpResponse(null, { status: 404 })
})
)
render(<UserProfile userId={999} />)
await screen.findByText('Пользователь не найден')
})import { renderHook, act } from '@testing-library/react'
// Хук для тестирования:
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
// Тест хука:
test('useCounter: инкремент и декремент', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
act(() => result.current.increment())
expect(result.current.count).toBe(11)
act(() => result.current.decrement())
act(() => result.current.decrement())
expect(result.current.count).toBe(9)
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
// Тест хука с провайдером контекста:
test('useAuth: с провайдером', () => {
const wrapper = ({ children }) => (
<AuthProvider initialUser={{ name: 'Алексей' }}>
{children}
</AuthProvider>
)
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.user.name).toBe('Алексей')
})// Утилита для рендеринга с провайдерами:
function renderWithProviders(ui, { user = null } = {}) {
return render(
<AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter>
{ui}
</MemoryRouter>
</QueryClientProvider>
</AuthContext.Provider>
)
}
// Использование:
test('авторизованный пользователь видит кнопку', () => {
renderWithProviders(<Toolbar />, {
user: { id: 1, name: 'Алексей', role: 'admin' }
})
expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument()
})// describe/it структура:
describe('UserProfile', () => {
describe('состояния загрузки', () => {
it('показывает спиннер при загрузке', () => { /* ... */ })
it('скрывает спиннер после загрузки', async () => { /* ... */ })
})
describe('успешная загрузка', () => {
it('отображает имя пользователя', async () => { /* ... */ })
it('отображает аватар', async () => { /* ... */ })
})
describe('обработка ошибок', () => {
it('показывает сообщение при 404', async () => { /* ... */ })
it('показывает кнопку повтора при ошибке', async () => { /* ... */ })
})
})Реализация async тест-хелперов: waitFor с таймаутом и интервалом, mock fetch, renderHook симуляция, тесты loading/error/success состояний
// Реализуем тест-хелперы для асинхронного тестирования без тестового фреймворка.
// --- waitFor: ждём выполнения условия ---
async function waitFor(assertion, options = {}) {
const { timeout = 1000, interval = 50 } = options
const startTime = Date.now()
while (true) {
try {
assertion() // бросает если условие не выполнено
return true // успех
} catch (error) {
if (Date.now() - startTime >= timeout) {
throw new Error(
'waitFor timeout после ' + timeout + 'мс: ' + error.message
)
}
// Ждём следующей проверки
await new Promise(resolve => setTimeout(resolve, interval))
}
}
}
// --- Mock Fetch ---
function createMockFetch(handlers) {
const callLog = []
const mockFetch = async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase()
callLog.push({ url, method, timestamp: Date.now() })
// Ищем подходящий handler
const handler = handlers.find(h => {
const methodMatch = !h.method || h.method === method
const urlMatch = typeof h.url === 'string'
? url.includes(h.url)
: h.url.test(url)
return methodMatch && urlMatch
})
if (!handler) {
return { ok: false, status: 404, json: async () => ({ error: 'Not Found' }) }
}
// Симулируем задержку сети
if (handler.delay) {
await new Promise(r => setTimeout(r, handler.delay))
}
if (handler.status >= 400) {
return { ok: false, status: handler.status, json: async () => handler.body }
}
return { ok: true, status: 200, json: async () => handler.body }
}
mockFetch.getCalls = () => [...callLog]
mockFetch.getCallCount = () => callLog.length
mockFetch.wasCalledWith = (url) => callLog.some(c => c.url.includes(url))
return mockFetch
}
// --- Симуляция компонента с fetch ---
function createAsyncComponent(fetchFn) {
let state = { status: 'idle', data: null, error: null }
const listeners = []
function setState(updates) {
state = { ...state, ...updates }
listeners.forEach(fn => fn(state))
}
async function load(id) {
setState({ status: 'loading', error: null })
try {
const res = await fetchFn('/api/users/' + id)
if (!res.ok) throw new Error('HTTP ' + res.status)
const data = await res.json()
setState({ status: 'success', data })
} catch (error) {
setState({ status: 'error', error: error.message, data: null })
}
}
return {
load,
getState: () => ({ ...state }),
subscribe: (fn) => listeners.push(fn),
}
}
// --- Тесты ---
async function runTests() {
console.log('=== Тест 1: waitFor базовое использование ===')
let value = 0
setTimeout(() => { value = 42 }, 100)
await waitFor(() => {
if (value !== 42) throw new Error('Ожидаем 42, получили ' + value)
})
console.log('waitFor: value достиг 42 ✓')
// --- Тест 2: waitFor timeout ---
console.log('
=== Тест 2: waitFor timeout ===')
try {
await waitFor(
() => { throw new Error('никогда не выполнится') },
{ timeout: 100, interval: 20 }
)
} catch (err) {
console.log('Timeout поймали:', err.message.includes('timeout') ? '✓' : '✗')
}
// --- Тест 3: Успешная загрузка ---
console.log('
=== Тест 3: Успешная загрузка ===')
const mockFetch = createMockFetch([
{ url: '/api/users', method: 'GET', delay: 50, status: 200,
body: { id: 1, name: 'Алексей', role: 'Разработчик' } }
])
const component = createAsyncComponent(mockFetch)
const stateHistory = []
component.subscribe(s => stateHistory.push(s.status))
component.load(1)
// Сразу должно быть loading
await waitFor(() => {
if (component.getState().status !== 'loading')
throw new Error('Ожидаем loading')
})
console.log('Loading state: ✓')
// Ждём success
await waitFor(() => {
if (component.getState().status !== 'success')
throw new Error('Ожидаем success')
})
const state = component.getState()
console.log('Success state: ✓')
console.log('Данные:', state.data.name) // Алексей
// --- Тест 4: Ошибка ---
console.log('
=== Тест 4: Обработка ошибки 404 ===')
const errorFetch = createMockFetch([
{ url: '/api/users', method: 'GET', delay: 20, status: 404, body: { error: 'Not Found' } }
])
const component2 = createAsyncComponent(errorFetch)
component2.load(999)
await waitFor(() => {
if (component2.getState().status !== 'error')
throw new Error('Ожидаем error')
})
console.log('Error state: ✓')
console.log('Сообщение ошибки:', component2.getState().error) // 'HTTP 404'
// --- Тест 5: Mock Fetch логирование ---
console.log('
=== Тест 5: Mock Fetch лог вызовов ===')
console.log('Вызовов к errorFetch:', errorFetch.getCallCount()) // 1
console.log('Был вызван с /api/users:', errorFetch.wasCalledWith('/api/users'))
console.log('История вызовов:', errorFetch.getCalls().map(c => c.method + ' ' + c.url))
}
runTests()Компоненты с data fetching требуют особого подхода. После render нужно дождаться завершения всех async операций:
import { render, screen, waitFor, findByText } from '@testing-library/react'
// Компонент с async загрузкой:
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
api.getUser(userId).then(u => {
setUser(u)
setIsLoading(false)
})
}, [userId])
if (isLoading) return <div>Загрузка...</div>
return <div data-testid="user-name">{user.name}</div>
}
// Тест:
test('загружает и отображает пользователя', async () => {
// Мокаем API:
jest.spyOn(api, 'getUser').mockResolvedValue({ id: 1, name: 'Алексей' })
render(<UserProfile userId={1} />)
// 1. Проверяем состояние загрузки:
expect(screen.getByText('Загрузка...')).toBeInTheDocument()
// 2. Ждём появления данных:
const userName = await screen.findByTestId('user-name') // findBy = автоматический waitFor
expect(userName).toHaveTextContent('Алексей')
// 3. Проверяем что loading исчез:
expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})// findBy* — сочетает getBy + waitFor (рекомендуется)
const element = await screen.findByText('Загружено')
// waitFor — для более сложных ожиданий
await waitFor(() => {
expect(screen.getByText('Готово')).toBeInTheDocument()
expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument()
})
// waitFor с таймаутом:
await waitFor(
() => expect(screen.getByText('Готово')).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
)MSW перехватывает реальные HTTP-запросы (работает на уровне Service Worker или Node.js). Лучше чем мокать fetch/axios напрямую:
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
const server = setupServer(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Алексей' })
}),
http.get('/api/users/:id/posts', () => {
return HttpResponse.json([
{ id: 1, title: 'Первый пост' },
{ id: 2, title: 'Второй пост' },
])
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Тест ошибки — переопределяем handler:
test('показывает ошибку', async () => {
server.use(
http.get('/api/users/:id', () => {
return new HttpResponse(null, { status: 404 })
})
)
render(<UserProfile userId={999} />)
await screen.findByText('Пользователь не найден')
})import { renderHook, act } from '@testing-library/react'
// Хук для тестирования:
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(initialValue)
return { count, increment, decrement, reset }
}
// Тест хука:
test('useCounter: инкремент и декремент', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
act(() => result.current.increment())
expect(result.current.count).toBe(11)
act(() => result.current.decrement())
act(() => result.current.decrement())
expect(result.current.count).toBe(9)
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
// Тест хука с провайдером контекста:
test('useAuth: с провайдером', () => {
const wrapper = ({ children }) => (
<AuthProvider initialUser={{ name: 'Алексей' }}>
{children}
</AuthProvider>
)
const { result } = renderHook(() => useAuth(), { wrapper })
expect(result.current.user.name).toBe('Алексей')
})// Утилита для рендеринга с провайдерами:
function renderWithProviders(ui, { user = null } = {}) {
return render(
<AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter>
{ui}
</MemoryRouter>
</QueryClientProvider>
</AuthContext.Provider>
)
}
// Использование:
test('авторизованный пользователь видит кнопку', () => {
renderWithProviders(<Toolbar />, {
user: { id: 1, name: 'Алексей', role: 'admin' }
})
expect(screen.getByRole('button', { name: /настройки/i })).toBeInTheDocument()
})// describe/it структура:
describe('UserProfile', () => {
describe('состояния загрузки', () => {
it('показывает спиннер при загрузке', () => { /* ... */ })
it('скрывает спиннер после загрузки', async () => { /* ... */ })
})
describe('успешная загрузка', () => {
it('отображает имя пользователя', async () => { /* ... */ })
it('отображает аватар', async () => { /* ... */ })
})
describe('обработка ошибок', () => {
it('показывает сообщение при 404', async () => { /* ... */ })
it('показывает кнопку повтора при ошибке', async () => { /* ... */ })
})
})Реализация async тест-хелперов: waitFor с таймаутом и интервалом, mock fetch, renderHook симуляция, тесты loading/error/success состояний
// Реализуем тест-хелперы для асинхронного тестирования без тестового фреймворка.
// --- waitFor: ждём выполнения условия ---
async function waitFor(assertion, options = {}) {
const { timeout = 1000, interval = 50 } = options
const startTime = Date.now()
while (true) {
try {
assertion() // бросает если условие не выполнено
return true // успех
} catch (error) {
if (Date.now() - startTime >= timeout) {
throw new Error(
'waitFor timeout после ' + timeout + 'мс: ' + error.message
)
}
// Ждём следующей проверки
await new Promise(resolve => setTimeout(resolve, interval))
}
}
}
// --- Mock Fetch ---
function createMockFetch(handlers) {
const callLog = []
const mockFetch = async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase()
callLog.push({ url, method, timestamp: Date.now() })
// Ищем подходящий handler
const handler = handlers.find(h => {
const methodMatch = !h.method || h.method === method
const urlMatch = typeof h.url === 'string'
? url.includes(h.url)
: h.url.test(url)
return methodMatch && urlMatch
})
if (!handler) {
return { ok: false, status: 404, json: async () => ({ error: 'Not Found' }) }
}
// Симулируем задержку сети
if (handler.delay) {
await new Promise(r => setTimeout(r, handler.delay))
}
if (handler.status >= 400) {
return { ok: false, status: handler.status, json: async () => handler.body }
}
return { ok: true, status: 200, json: async () => handler.body }
}
mockFetch.getCalls = () => [...callLog]
mockFetch.getCallCount = () => callLog.length
mockFetch.wasCalledWith = (url) => callLog.some(c => c.url.includes(url))
return mockFetch
}
// --- Симуляция компонента с fetch ---
function createAsyncComponent(fetchFn) {
let state = { status: 'idle', data: null, error: null }
const listeners = []
function setState(updates) {
state = { ...state, ...updates }
listeners.forEach(fn => fn(state))
}
async function load(id) {
setState({ status: 'loading', error: null })
try {
const res = await fetchFn('/api/users/' + id)
if (!res.ok) throw new Error('HTTP ' + res.status)
const data = await res.json()
setState({ status: 'success', data })
} catch (error) {
setState({ status: 'error', error: error.message, data: null })
}
}
return {
load,
getState: () => ({ ...state }),
subscribe: (fn) => listeners.push(fn),
}
}
// --- Тесты ---
async function runTests() {
console.log('=== Тест 1: waitFor базовое использование ===')
let value = 0
setTimeout(() => { value = 42 }, 100)
await waitFor(() => {
if (value !== 42) throw new Error('Ожидаем 42, получили ' + value)
})
console.log('waitFor: value достиг 42 ✓')
// --- Тест 2: waitFor timeout ---
console.log('
=== Тест 2: waitFor timeout ===')
try {
await waitFor(
() => { throw new Error('никогда не выполнится') },
{ timeout: 100, interval: 20 }
)
} catch (err) {
console.log('Timeout поймали:', err.message.includes('timeout') ? '✓' : '✗')
}
// --- Тест 3: Успешная загрузка ---
console.log('
=== Тест 3: Успешная загрузка ===')
const mockFetch = createMockFetch([
{ url: '/api/users', method: 'GET', delay: 50, status: 200,
body: { id: 1, name: 'Алексей', role: 'Разработчик' } }
])
const component = createAsyncComponent(mockFetch)
const stateHistory = []
component.subscribe(s => stateHistory.push(s.status))
component.load(1)
// Сразу должно быть loading
await waitFor(() => {
if (component.getState().status !== 'loading')
throw new Error('Ожидаем loading')
})
console.log('Loading state: ✓')
// Ждём success
await waitFor(() => {
if (component.getState().status !== 'success')
throw new Error('Ожидаем success')
})
const state = component.getState()
console.log('Success state: ✓')
console.log('Данные:', state.data.name) // Алексей
// --- Тест 4: Ошибка ---
console.log('
=== Тест 4: Обработка ошибки 404 ===')
const errorFetch = createMockFetch([
{ url: '/api/users', method: 'GET', delay: 20, status: 404, body: { error: 'Not Found' } }
])
const component2 = createAsyncComponent(errorFetch)
component2.load(999)
await waitFor(() => {
if (component2.getState().status !== 'error')
throw new Error('Ожидаем error')
})
console.log('Error state: ✓')
console.log('Сообщение ошибки:', component2.getState().error) // 'HTTP 404'
// --- Тест 5: Mock Fetch логирование ---
console.log('
=== Тест 5: Mock Fetch лог вызовов ===')
console.log('Вызовов к errorFetch:', errorFetch.getCallCount()) // 1
console.log('Был вызван с /api/users:', errorFetch.wasCalledWith('/api/users'))
console.log('История вызовов:', errorFetch.getCalls().map(c => c.method + ' ' + c.url))
}
runTests()Создай компонент UserProfile с тремя состояниями (loading, success, error) и напиши для него тестовые проверки. Компонент загружает данные пользователя асинхронно. Заполни пропуски (???) для: проверки loading состояния, отображения имени пользователя после загрузки, обработки ошибки.
Для проверки loading: state.status === "loading". Для отображения ошибки: state.error. Для отображения имени: state.user.name.