← Назад к вопросам

Как должен выглядеть идеальный TDD?

1.3 Junior🔥 81 комментариев
#Тестирование

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI2 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Идеальный 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 фазы:

  1. Описывай требование в названии теста - should accept valid email, не test1
  2. Один тест = одно требование - не группируй 5 проверок в одну
  3. Используй 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

Цикл:

  1. RED - напиши тест который описывает требование
  2. GREEN - напиши минимальный код чтобы пройти тест
  3. REFACTOR - улучши код, тесты защищают
  4. Повтори - для каждого требования

Результаты:

  • Меньше багов (тесты ловят)
  • Меньше ревью циклов (требования ясны)
  • Код готов к изменениям (тесты защищают)
  • Документация (тесты описывают как использовать)
  • Больше confidence при рефакторинге

Главное: TDD - это не способ писать тесты, это способ ДУМАТЬ о коде. Тесты идут первыми потому что это заставляет тебя продумать требования до написания кода.

ТDD требует практики, но окупается многократно в виде качественного, поддерживаемого кода.

Как должен выглядеть идеальный TDD? | PrepBro