Як писати код, щоб його легко тестувати
Загальний принцип
Section titled “Загальний принцип”Тестованість досягається тоді, коли модулі:
- ізольовані від зовнішнього середовища;
- детерміновані (однаковий результат за тих самих умов);
- мінімально зв’язані (не залежать від глобального стану);
- мають чіткі точки входу і виходу.
Принципи тестованого коду
Section titled “Принципи тестованого коду”| Принцип | Суть | Результат |
|---|---|---|
| Single Responsibility | одна функція = одна задача | менше побічних ефектів |
| Dependency Injection | зовнішні залежності передаються як аргументи | легше мокати |
| Pure Functions First | без state, без side-effects | 100% передбачуваність |
| Separation of Concerns | логіка, дані, UI — окремо | прості unit-и |
| Observable Outputs | результат видимий через return/props | легка перевірка у тесті |
| Explicit State | стан зберігається лише у компоненті або хуку | немає прихованих зв’язків |
Винось логіку з UI
Section titled “Винось логіку з UI”// компонент робить усе: валідацію, API, станconst LoginForm = () => { const [email, setEmail] = useState(''); const [error, setError] = useState(''); const handleSubmit = async () => { if (!email.includes('@')) setError('Invalid email'); await fetch('/login', { method: 'POST', body: JSON.stringify({ email }) }); }; return <form onSubmit={handleSubmit}>…</form>;};// логіка → хукfunction useLogin() { const [error, setError] = useState(''); const submit = async (email: string) => { if (!email.includes('@')) return setError('Invalid email'); return api.login(email); }; return { submit, error };}
// компонент → лише UIconst LoginForm = () => { const { submit, error } = useLogin(); return ( <form onSubmit={(e) => { e.preventDefault(); submit('test@mail.com'); }}> {error && <div role="alert">{error}</div>} </form> );};У тестах можна перевірити хук окремо, без DOM.
Ін’єкція залежностей (Dependency Injection)
Section titled “Ін’єкція залежностей (Dependency Injection)”export async function fetchUser(id: number) { const response = await fetch(`/api/users/${id}`); return response.json();}export async function fetchUser(api: typeof fetch, id: number) { const response = await api(`/api/users/${id}`); return response.json();}const fakeApi = vi.fn().mockResolvedValue({ json: () => ({ name: 'Alice' }) });expect(await fetchUser(fakeApi, 1)).toEqual({ name: 'Alice' });Робота з часом і випадковістю
Section titled “Робота з часом і випадковістю”Не викликай Date.now() або Math.random() напряму.
Створи обгортку:
export const time = { now: () => Date.now() };export const rand = { int: (n: number) => Math.floor(Math.random() * n) };
// У тестахvi.spyOn(time, 'now').mockReturnValue(1700000000000);vi.spyOn(rand, 'int').mockReturnValue(5);Ізолюй побічні ефекти
Section titled “Ізолюй побічні ефекти”Усе, що стосується:
fetch/axioslocalStorage/sessionStoragewindow/documentnavigatorsetTimeout/setInterval
має бути в адаптерах.
export const storage = { get: (k: string) => localStorage.getItem(k), set: (k: string, v: string) => localStorage.setItem(k, v),};
// у тесті:vi.spyOn(storage, 'get').mockReturnValue('mocked');Передбачуваний стан
Section titled “Передбачуваний стан”- Не зберігай приховані або дубльовані стани.
- Будь-яка зміна стану має бути наслідком дії.
- Використовуй
useReducerзамість множиннихuseState, коли логіка складна - це спрощує тести.
Мінімізуй приховані залежності
Section titled “Мінімізуй приховані залежності”❌ погано: імпорт глобальних синглтонів, наприклад import { store } from '@/store' усюди.
✅ добре: передавати store/context як параметр або через провайдер.
// StoreContext.tsximport { createContext, useContext } from 'react';export const StoreContext = createContext<{ user: { name: string } | null }>({ user: null });export const useStore = () => useContext(StoreContext);
// UserPanel.tsximport { useStore } from '@/StoreContext';
export const UserPanel = () => { const { user } = useStore(); return <div>Hello, {user?.name}</div>;};
// Тестimport { render } from '@testing-library/react';import { StoreContext } from '@/StoreContext';
render( <StoreContext.Provider value={{ user: { name: 'Alice' } }}> <UserPanel /> </StoreContext.Provider>,);// ✅ легко підмінити будь-який стан// getUserGreeting.tsexport function getUserGreeting(store: { user: { name: string } | null }) { return store.user ? `Hello, ${store.user.name}` : 'Guest';}
// Тестexpect(getUserGreeting({ user: { name: 'Alice' } })).toBe('Hello, Alice');Структуруй код для unit-тестів
Section titled “Структуруй код для unit-тестів”- Один файл = один публічний експорт (функція, хук, компонент).
- Приватні функції всередині - допустимі, але їх не тестують напряму.
- Якщо логіка складна - виділи приватні функції в окремий файл
*.internal.tsі тестуй його без DOM.
Використовуй чисті утиліти як ядро
Section titled “Використовуй чисті утиліти як ядро”Чисті функції (formatDate, validateEmail, calculateDiscount) мають:
- бути незалежними;
- не знати про React чи API;
- легко тестуватися у Node-середовищі (
*.node.test.ts);
Залежності від UI
Section titled “Залежності від UI”Компонент не має знати, як саме працює інша частина UI.
Замість цього — оперуй через callback або пропси.
// компонент напряму викликає зовнішній хукonClick={() => logoutUser()}// поведінка передається через проп <Button onClick={onLogout}>Logout</Button>Не блокуйте тести “магією”
Section titled “Не блокуйте тести “магією””- Уникайте анонімних функцій усередині
useEffectбез залежностей. - Не використовуйте динамічні імпорти без mock-шару.
- Не створюйте глобальні singletons без інтерфейсу для тестів.
Використовуйте TypeScript для стабільності
Section titled “Використовуйте TypeScript для стабільності”- Типізація зменшує кількість edge-кейсів у тестах.
- Використовуйте
Readonly<>,Partial<>,Pick<>для створення lightweight-об’єктів у фабриках. - Уникайте
any- це приховує логічні помилки.
Локальні приклади структури
Section titled “Локальні приклади структури”Directoryhooks
- useLogin.ts → бізнес-логіка
- useLogin.test.ts → тестує поведінку
Directoryservices
- api.ts → обгортка над fetch
- api.node.test.ts → тестує правильність запитів
Directoryutils
- validators.ts → чисті функції
- validators.node.test.ts
Directorycomponents
- LoginForm.tsx → тільки UI
- LoginForm.test.tsx → перевіряє інтеграцію з useLogin