Middle: питання на співбесіді з теми «Тестування»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Багато змін в інтерфейсі відбуваються не одразу: після відповіді сервера, таймера, переходу (startTransition). Синхронна перевірка відразу після дії їх не побачить.
findBy... - чекає, поки елемент з'явиться (за замовчуванням до 1000 мс, перевіряючи кожні 50 мс):
test('завантажує вакансії', async () => {
render(<VacancyList />);
expect(screen.getByText('Завантаження...')).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: 'Laravel-розробник' })).toBeInTheDocument();
});
findBy = waitFor + getBy, і це найпростіший спосіб дочекатися появи елемента.
waitFor - повторює колбек, поки він не перестане кидати помилку:
await user.click(screen.getByRole('button', { name: 'Зберегти' }));
await waitFor(() => expect(saveMock).toHaveBeenCalledWith({ title: 'Нова' }));
Правила для waitFor:
- одна перевірка всередині - якщо кілька, при падінні незрозуміло, яка не дочекалася;
- без побічних ефектів у колбеку: він виконується багато разів (клік у
waitForклацне кілька разів); - для появи елемента -
findBy, а неwaitFor(() => getBy...); - для зникнення -
waitForElementToBeRemoved(() => screen.queryByText('Завантаження...')).
Попередження «not wrapped in act(...)» означає: стан компонента оновився після того, як тест уже щось перевірив, і поза контролем тестових утиліт. Типові причини:
- запит завершився після закінчення тесту - тест не дочекався кінцевого стану. Рішення - дочекатися через
findByтого, що з'являється наприкінці; - таймер чи проміс оновлює стан, а тест не знає про це.
Не варто обгортати все в act вручну. render, userEvent, fireEvent, findBy, waitFor уже використовують act. Явний act потрібен рідко - наприклад, при виклику функції з renderHook чи прямому оновленні зовнішнього сховища.
Фальшиві таймери (vi.useFakeTimers()) прискорюють тести з затримками, але з userEvent потрібен параметр advanceTimers, а findBy/waitFor у сучасних версіях Testing Library самі просувають фальшиві таймери Jest; з Vitest це залежить від налаштування - простіше уникати фальшивих таймерів там, де можна.
Ознака поганого тесту - довільні затримки (await new Promise((r) => setTimeout(r, 500))): повільно й нестабільно.
Докладніше в документації: Testing Library: асинхронні методи
Тести компонентів, що завантажують дані, не повинні ходити в справжній API: повільно, нестабільно, залежить від стану бази. Підмінити fetch через vi.mock можна, але тоді тест прив'язаний до того, як код робить запит.
MSW (Mock Service Worker) перехоплює запити на рівні мережі: компонент викликає звичайний fetch чи axios, а відповідь надходить з обробника тесту.
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/vacancies', () =>
HttpResponse.json([{ id: 1, title: 'Laravel-розробник' }]),
),
http.post('/api/vacancies/:id/apply', async ({ params, request }) => {
const body = await request.json();
return HttpResponse.json({ id: params.id, email: body.email }, { status: 201 });
}),
];
// src/test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Інший сценарій в окремому тесті - перевизначити обробник:
test('показує помилку сервера', async () => {
server.use(http.get('/api/vacancies', () => new HttpResponse(null, { status: 500 })));
render(<VacancyList />);
expect(await screen.findByRole('alert')).toHaveTextContent('Не вдалося завантажити');
});
resetHandlers() після тесту повертає типові обробники - сценарії не «протікають» між тестами.
Переваги:
- тест не знає про спосіб запиту - перехід з
fetchна axios чи TanStack Query не ламає тести; - ті самі обробники працюють у браузері (через Service Worker) - для розробки фронтенду без готового бекенду, в Storybook і в браузерних тестах;
onUnhandledRequest: 'error'- тест падає на запиті, для якого немає обробника, замість тихого звернення в мережу.
Що варто знати:
- відносні URL (
/api/...) у середовищі jsdom розв'язуються відносноlocationтесту; - затримки й мережеві помилки -
await delay(1000)іHttpResponse.error()для перевірки станів завантаження й обриву з'єднання; - перевіряти запит: тіло й заголовки читаються в обробнику (
await request.json()), тож можна переконатися, що форма відправила правильні дані.
Хук не можна викликати поза компонентом. renderHook рендерить тестовий компонент, що викликає хук, і дає доступ до його результату.
import { renderHook, act } from '@testing-library/react';
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
return { count, increment: () => setCount((c) => c + 1) };
}
test('збільшує лічильник', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
act(() => result.current.increment());
expect(result.current.count).toBe(6);
});
Ключові моменти:
result.current- значення, повернене хуком при останньому рендері. Не можна зберегтиconst { count } = result.currentна початку - значення застаріє;actпотрібен для виклику функцій, що оновлюють стан, - так оновлення застосується до наступної перевірки;- зміна аргументів - через
rerender:
const { result, rerender } = renderHook(({ id }) => useUser(id), { initialProps: { id: 1 } });
rerender({ id: 2 });
- демонтування -
unmount(), щоб перевірити очищення (відписка, скасування запиту).
Асинхронні хуки - з waitFor:
const { result } = renderHook(() => useVacancies());
await waitFor(() => expect(result.current.status).toBe('success'));
Хуки, що потребують провайдерів (контекст, роутер, клієнт TanStack Query) - опція wrapper:
const wrapper = ({ children }) => (
<QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useVacancies(), { wrapper });
Коли тестувати хук окремо, а коли через компонент:
- окремо - хук зі складною логікою, що використовується в багатьох місцях (форматування, синхронізація з URL, машина станів);
- через компонент - хук, що існує заради одного компонента. Тест компонента перевіряє поведінку, яку бачить користувач, і не ламається, якщо логіку переставити між хуком і компонентом.
Історія: раніше renderHook був в окремому пакеті @testing-library/react-hooks; для React 18+ він вбудований у @testing-library/react, а старий пакет не підтримується.
Докладніше в документації: React Testing Library: renderHook
Реальні компоненти рідко живуть самі по собі: вони читають тему, поточного користувача, маршрут, клієнт запитів. Без провайдерів у тесті вони падають або поводяться не так, як у застосунку.
Найпростіше - wrapper у render:
render(<Header />, {
wrapper: ({ children }) => <AuthContext value={{ user: testUser, logout: vi.fn() }}>{children}</AuthContext>,
});
Для всього проєкту - власна функція render, яка обгортає компонент усім потрібним і дає змогу налаштувати кожен провайдер:
// src/test/render.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
type Options = RenderOptions & { user?: User | null; route?: string };
export function renderWithProviders(ui: ReactElement, { user = null, route = '/', ...options }: Options = {}) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthContext value={{ user, logout: vi.fn() }}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</AuthContext>
</QueryClientProvider>
);
}
return render(ui, { wrapper: Providers, ...options });
}
export * from '@testing-library/react';
import { renderWithProviders, screen } from '../test/render';
test('гість бачить кнопку входу', () => {
renderWithProviders(<Header />, { user: null });
expect(screen.getByRole('link', { name: 'Увійти' })).toBeInTheDocument();
});
Що варто врахувати:
- свіжий стан на кожен тест: клієнт запитів, сховище (Zustand, Redux) створюються всередині функції, а не на рівні модуля - інакше кеш з одного тесту впливає на інший;
retry: falseдля TanStack Query - інакше тест помилки чекатиме повторних спроб;- маршрутизатор у пам'яті (
MemoryRouter,createMemoryRouter) - без реального URL браузера; початковий маршрут задається параметром; - підміняти лише те, що поза межами тесту: справжній контекст з тестовими даними краще за мок
useContext- тест перевіряє реальну інтеграцію; - перевірка поведінки при різному контексті (гість / адміністратор / інша мова) стає одним параметром.
renderHook приймає той самий wrapper - хуки тестуються з тими самими провайдерами.
Докладніше в документації: React Testing Library: власний render