Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Идеальный Test-Driven Development (TDD) в Frontend
TDD (Test-Driven Development) - это методология разработки, где тесты пишутся ДО кода. За 10+ лет я прошёл путь от скептика к убежденному сторонику TDD. Сейчас я буду объяснять не только как писать тесты, но как правильно думать в рамках TDD.
Фундаментальная философия TDD
TDD основан на трёх шагах цикла:
RED -> GREEN -> REFACTOR
↑ ↓
←←←←←←←←←←←←←←←←←←←←
RED: Написать тест, который падает (потому что функционала ещё нет)
GREEN: Написать минимальный код, чтобы тест прошёл
REFACTOR: Улучшить код, сохраняя зелёный тест
RED фаза: Думаем о требованиях
Перед написанием кода я пишу тесты, которые описывают ТРЕБОВАНИЕ, а не реализацию.
Пример: Функция для валидации email
// Файл: EmailValidator.test.ts
// ШАГ 1: Пишем тест (RED)
import { validateEmail } from './EmailValidator';
describe('EmailValidator', () => {
// Требование 1: Принимать корректный email
it('should accept valid email', () => {
expect(validateEmail('test@example.com')).toBe(true);
});
// Требование 2: Отклонять email без @
it('should reject email without @', () => {
expect(validateEmail('testexample.com')).toBe(false);
});
// Требование 3: Отклонять email без домена
it('should reject email without domain', () => {
expect(validateEmail('test@')).toBe(false);
});
// Требование 4: Отклонять пустую строку
it('should reject empty string', () => {
expect(validateEmail('')).toBe(false);
});
// Требование 5: Отклонять email с пробелами
it('should reject email with spaces', () => {
expect(validateEmail('test @example.com')).toBe(false);
});
});
// На этом этапе все тесты ПАДАЮТ (RED)
// Функция EmailValidator ещё не существует
Ключевые моменты RED фазы:
- Описывай требование в названии теста -
should accept valid email, неtest1 - Один тест = одно требование - не группируй 5 проверок в одну
- Используй Arrange-Act-Assert паттерн
it('should calculate total price with tax', () => {
// ARRANGE - подготовка
const price = 100;
const taxRate = 0.1;
// ACT - выполнение
const result = calculateTotal(price, taxRate);
// ASSERT - проверка
expect(result).toBe(110);
});
GREEN фаза: Минимальный код
Теперь пишу МИНИМАЛЬНЫЙ код чтобы тесты прошли.
// Файл: EmailValidator.ts
// ШАГ 2: Пишем минимальный код (GREEN)
export function validateEmail(email: string): boolean {
// Самый простой код который пройдёт ВСЕ тесты
if (!email) return false;
if (!email.includes('@')) return false;
if (email.endsWith('@')) return false;
if (email.includes(' ')) return false;
return true;
}
Важно: На GREEN фазе мы не думаем об идеальности или оптимизации. Просто делаем работающий код.
REFACTOR фаза: Улучшение
Теперь тесты зелёные. Можем безопасно улучшать код, потому что тесты будут нас защищать.
// Файл: EmailValidator.ts
// ШАГ 3: Рефакторим (REFACTOR), тесты остаются GREEN
export function validateEmail(email: string): boolean {
// Версия 1: Regex (более правильно, но всё ещё просто)
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Все тесты ещё зелёные, потому что требования не изменились!
Практический пример: Компонент User Profile
Полный цикл RED -> GREEN -> REFACTOR для React компонента.
RED: Пишем требования в тестах
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile', () => {
// Требование 1: Показывать loading при загрузке
it('should show loading state while fetching user', () => {
render(<UserProfile userId="123" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
// Требование 2: Показывать данные пользователя после загрузки
it('should display user data when loaded', async () => {
// Мокируем API
jest.spyOn(global, 'fetch').mockResolvedValue({
json: async () => ({
id: '123',
name: 'John',
email: 'john@example.com'
})
});
render(<UserProfile userId="123" />);
// Сначала loading
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Потом данные
expect(await screen.findByText('John')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
// Требование 3: Показывать ошибку при ошибке загрузки
it('should show error message on API failure', async () => {
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('API Error'));
render(<UserProfile userId="123" />);
expect(await screen.findByText(/error/i)).toBeInTheDocument();
});
// Требование 4: Изменять userId при изменении props
it('should fetch new user when userId prop changes', async () => {
const { rerender } = render(<UserProfile userId="123" />);
expect(await screen.findByText('John')).toBeInTheDocument();
// Меняем userId
rerender(<UserProfile userId="456" />);
expect(await screen.findByText('Jane')).toBeInTheDocument();
});
});
GREEN: Минимальный компонент
// UserProfile.tsx
import { useEffect, useState } from 'react';
interface UserProfile {
userId: string;
}
export function UserProfile({ userId }: UserProfile) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
(async () => {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (isMounted) {
setUser(data);
setLoading(false);
}
} catch (err) {
if (isMounted) {
setError(err.message);
setLoading(false);
}
}
})();
return () => {
isMounted = false;
};
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
REFACTOR: Улучшаем, тесты зелёные
// UserProfile.tsx (улучшенная версия)
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
interface UserProfileProps {
userId: string;
}
export function UserProfile({ userId }: UserProfileProps) {
// Используем React Query для управления данными
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
}
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// Все тесты всё ещё зелёные!
// Реализация улучшена, но требования не изменились
TDD для сложной бизнес-логики
Пример: Калькулятор скидок
// DiscountCalculator.test.ts
describe('DiscountCalculator', () => {
it('should apply basic percentage discount', () => {
const calculator = new DiscountCalculator();
const total = calculator.applyDiscount(100, { type: 'percentage', value: 10 });
expect(total).toBe(90);
});
it('should apply fixed amount discount', () => {
const calculator = new DiscountCalculator();
const total = calculator.applyDiscount(100, { type: 'fixed', value: 20 });
expect(total).toBe(80);
});
it('should not apply discount if minimum purchase not met', () => {
const calculator = new DiscountCalculator();
const total = calculator.applyDiscount(50, {
type: 'percentage',
value: 10,
minimumPurchase: 100
});
expect(total).toBe(50); // скидка не применена
});
it('should apply multiple discounts correctly', () => {
const calculator = new DiscountCalculator();
const discounts = [
{ type: 'percentage', value: 10 },
{ type: 'fixed', value: 5 }
];
// 100 -> 90 (10%) -> 85 (5)
const total = calculator.applyMultipleDiscounts(100, discounts);
expect(total).toBe(85);
});
it('should cap discount at maximum', () => {
const calculator = new DiscountCalculator();
const total = calculator.applyDiscount(100, {
type: 'percentage',
value: 90,
maxDiscount: 50 // максимум 50
});
expect(total).toBe(50); // 90% = 90, но max = 50
});
});
// DiscountCalculator.ts
interface Discount {
type: 'percentage' | 'fixed';
value: number;
minimumPurchase?: number;
maxDiscount?: number;
}
export class DiscountCalculator {
applyDiscount(total: number, discount: Discount): number {
// RED -> GREEN -> REFACTOR
// Проверка minimum purchase
if (discount.minimumPurchase && total < discount.minimumPurchase) {
return total;
}
let discountAmount = 0;
if (discount.type === 'percentage') {
discountAmount = (total * discount.value) / 100;
} else if (discount.type === 'fixed') {
discountAmount = discount.value;
}
// Проверка maximum discount
if (discount.maxDiscount && discountAmount > discount.maxDiscount) {
discountAmount = discount.maxDiscount;
}
return Math.max(0, total - discountAmount);
}
applyMultipleDiscounts(total: number, discounts: Discount[]): number {
return discounts.reduce(
(current, discount) => this.applyDiscount(current, discount),
total
);
}
}
TDD лучшие практики
1. Описывай требование в названии
// ✅ ХОРОШО
it('should validate email with + sign correctly', () => {
expect(validateEmail('user+tag@example.com')).toBe(true);
});
// ❌ ПЛОХО
it('test email', () => {
expect(validateEmail('user+tag@example.com')).toBe(true);
});
2. Один тест = одна проверка
// ✅ ХОРОШО
it('should calculate base price', () => {
expect(calculatePrice(10, 1)).toBe(10);
});
it('should apply quantity discount', () => {
expect(calculatePrice(10, 10)).toBe(90);
});
// ❌ ПЛОХО
it('should calculate price', () => {
expect(calculatePrice(10, 1)).toBe(10);
expect(calculatePrice(10, 10)).toBe(90);
expect(calculatePrice(0, 5)).toBe(0);
});
3. Тесты должны быть независимы
// ❌ ПЛОХО - зависимость между тестами
let user;
it('should create user', () => {
user = createUser('John');
expect(user.name).toBe('John');
});
it('should update user', () => {
user.name = 'Jane';
expect(user.name).toBe('Jane');
});
// ✅ ХОРОШО - каждый тест независим
it('should create user', () => {
const user = createUser('John');
expect(user.name).toBe('John');
});
it('should update user', () => {
const user = createUser('John');
user.name = 'Jane';
expect(user.name).toBe('Jane');
});
4. Используй Arrange-Act-Assert
it('should apply tax to price', () => {
// ARRANGE
const basePrice = 100;
const taxRate = 0.1;
// ACT
const result = applyTax(basePrice, taxRate);
// ASSERT
expect(result).toBe(110);
});
5. Mock зависимости
it('should handle API errors', async () => {
// Mock API
jest.spyOn(global, 'fetch').mockRejectedValue(
new Error('Network error')
);
// ACT
const result = await fetchUser('123');
// ASSERT
expect(result).toEqual({ error: 'Network error' });
});
Структура проекта для TDD
src/
|
+-- domain/
| +-- Discount.ts
| +-- Discount.test.ts # Тесты рядом с кодом
|
+-- hooks/
| +-- useUser.ts
| +-- useUser.test.ts
|
+-- components/
+-- UserProfile.tsx
+-- UserProfile.test.tsx
Покрытие тестами
npm run test:coverage
Цели:
- Statement coverage >= 90%
- Branch coverage >= 85%
- Function coverage >= 90%
- Line coverage >= 90%
Когда НЕ использовать TDD
// TDD идеален для
// 1. Business logic
// 2. Utils functions
// 3. Hooks
// 4. Services
// TDD менее эффективен для
// 1. Чистая UI (кнопка без логики)
// 2. Стили
// 3. Интеграционные тесты (используй E2E)
Итог: Идеальный TDD
Цикл:
- RED - напиши тест который описывает требование
- GREEN - напиши минимальный код чтобы пройти тест
- REFACTOR - улучши код, тесты защищают
- Повтори - для каждого требования
Результаты:
- Меньше багов (тесты ловят)
- Меньше ревью циклов (требования ясны)
- Код готов к изменениям (тесты защищают)
- Документация (тесты описывают как использовать)
- Больше confidence при рефакторинге
Главное: TDD - это не способ писать тесты, это способ ДУМАТЬ о коде. Тесты идут первыми потому что это заставляет тебя продумать требования до написания кода.
ТDD требует практики, но окупается многократно в виде качественного, поддерживаемого кода.