Skip to content

Як писати код, щоб його легко тестувати

Тестованість досягається тоді, коли модулі:

  • ізольовані від зовнішнього середовища;
  • детерміновані (однаковий результат за тих самих умов);
  • мінімально зв’язані (не залежать від глобального стану);
  • мають чіткі точки входу і виходу.

Принципи тестованого коду

Section titled “Принципи тестованого коду”
ПринципСутьРезультат
Single Responsibilityодна функція = одна задачаменше побічних ефектів
Dependency Injectionзовнішні залежності передаються як аргументилегше мокати
Pure Functions Firstбез state, без side-effects100% передбачуваність
Separation of Concernsлогіка, дані, UI — окремопрості unit-и
Observable Outputsрезультат видимий через return/propsлегка перевірка у тесті
Explicit Stateстан зберігається лише у компоненті або хукунемає прихованих зв’язків
Погано
// компонент робить усе: валідацію, 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 };
}
// компонент → лише UI
const 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);

Усе, що стосується:

  • fetch / axios
  • localStorage / sessionStorage
  • window / document
  • navigator
  • setTimeout / 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');
  • Не зберігай приховані або дубльовані стани.
  • Будь-яка зміна стану має бути наслідком дії.
  • Використовуй useReducer замість множинних useState, коли логіка складна - це спрощує тести.

Мінімізуй приховані залежності

Section titled “Мінімізуй приховані залежності”

❌ погано: імпорт глобальних синглтонів, наприклад import { store } from '@/store' усюди.
✅ добре: передавати store/context як параметр або через провайдер.

// StoreContext.tsx
import { createContext, useContext } from 'react';
export const StoreContext = createContext<{ user: { name: string } | null }>({ user: null });
export const useStore = () => useContext(StoreContext);
// UserPanel.tsx
import { 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>,
);
// ✅ легко підмінити будь-який стан

Структуруй код для unit-тестів

Section titled “Структуруй код для unit-тестів”
  • Один файл = один публічний експорт (функція, хук, компонент).
  • Приватні функції всередині - допустимі, але їх не тестують напряму.
  • Якщо логіка складна - виділи приватні функції в окремий файл *.internal.ts і тестуй його без DOM.

Використовуй чисті утиліти як ядро

Section titled “Використовуй чисті утиліти як ядро”

Чисті функції (formatDate, validateEmail, calculateDiscount) мають:

  • бути незалежними;
  • не знати про React чи API;
  • легко тестуватися у Node-середовищі (*.node.test.ts);

Компонент не має знати, як саме працює інша частина 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