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

Как покрывал код тестами?

1.7 Middle🔥 182 комментариев
#JavaScript Core

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

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

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

Как покрывать код тестами на Frontend

Это очень частый вопрос на собеседованиях. Интервьюер хочет понять:

  • Знаешь ли ты разницу между unit/integration/e2e тестами
  • Понимаешь ли значение тестирования
  • Как ты подходишь к тестированию в React

1. Стратегия покрытия (в порядке приоритета)

У меня есть пирамида тестирования:

E2E (10-20%)
  - Важные пользовательские сценарии
  - Интеграция фронта и бэка
  - Используем Playwright

Integration (30-40%)
  - Взаимодействие компонентов
  - API моки (VCR.py, MSW)
  - Testing Library queries

Unit (50-70%)
  - Функции, утилиты
  - Простые компоненты
  - Виест (Vitest)

Все вместе дают 80-90% coverage.

2. Unit тесты в React (Testing Library)

// Компонент
export function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p data-testid="count">Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

// Тест (TDD подход)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter', () => {
  // Требование 1: показывает начальное значение
  test('показывает начальное значение 0', () => {
    render(<Counter />);
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 0');
  });

  // Требование 2: увеличивает при клике
  test('увеличивает счетчик при клике на кнопку', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    
    const button = screen.getByRole('button', { name: '+' });
    await user.click(button);
    
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 1');
  });

  // Требование 3: увеличивает несколько раз
  test('увеличивает несколько раз подряд', async () => {
    const user = userEvent.setup();
    render(<Counter />);
    
    const button = screen.getByRole('button', { name: '+' });
    await user.click(button);
    await user.click(button);
    await user.click(button);
    
    expect(screen.getByTestId('count')).toHaveTextContent('Count: 3');
  });
});

3. Тесты форм

// Компонент формы
export function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    if (!email || !password) {
      setError('Оба поля обязательны');
      return;
    }

    setLoading(true);
    try {
      await onSubmit({ email, password });
      setError(null);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        data-testid="email-input"
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        data-testid="password-input"
      />
      {error && <div data-testid="error-message">{error}</div>}
      <button type="submit" disabled={loading}>
        {loading ? 'Загрузка...' : 'Войти'}
      </button>
    </form>
  );
}

// Тесты
describe('LoginForm', () => {
  // Тест валидации
  test('показывает ошибку если поля пусты', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);
    
    const submitButton = screen.getByRole('button', { name: /войти/i });
    await user.click(submitButton);
    
    expect(screen.getByTestId('error-message')).toHaveTextContent(
      'Оба поля обязательны'
    );
  });

  // Тест успешной отправки
  test('отправляет форму с корректными данными', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
    
    render(<LoginForm onSubmit={onSubmit} />);
    
    await user.type(screen.getByTestId('email-input'), 'test@example.com');
    await user.type(screen.getByTestId('password-input'), 'password123');
    await user.click(screen.getByRole('button', { name: /войти/i }));
    
    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });

  // Тест обработки ошибок
  test('показывает ошибку от сервера', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn();
    onSubmit.mockRejectedValue(new Error('Invalid credentials'));
    
    render(<LoginForm onSubmit={onSubmit} />);
    
    await user.type(screen.getByTestId('email-input'), 'test@example.com');
    await user.type(screen.getByTestId('password-input'), 'wrong');
    await user.click(screen.getByRole('button', { name: /войти/i }));
    
    expect(screen.getByTestId('error-message')).toHaveTextContent(
      'Invalid credentials'
    );
  });

  // Тест состояния загрузки
  test('показывает состояние загрузки', async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn(
      () => new Promise(resolve => setTimeout(resolve, 100))
    );
    
    render(<LoginForm onSubmit={onSubmit} />);
    
    await user.type(screen.getByTestId('email-input'), 'test@example.com');
    await user.type(screen.getByTestId('password-input'), 'password123');
    
    const button = screen.getByRole('button');
    await user.click(button);
    
    expect(button).toHaveTextContent('Загрузка...');
    expect(button).toBeDisabled();
  });
});

4. Тесты с API моками (MSW)

import { setupServer } from 'msw/node';
import { HttpResponse, http } from 'msw';

// Моки API
const server = setupServer(
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'John Doe',
      email: 'john@example.com',
    });
  }),
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: '1', ...body },
      { status: 201 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

// Компонент с API
export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Тест
test('загружает и показывает пользователя', async () => {
  render(<UserProfile userId="123" />);
  
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  expect(await screen.findByText('John Doe')).toBeInTheDocument();
  expect(screen.getByText('john@example.com')).toBeInTheDocument();
});

// Тест обработки ошибок
test('показывает ошибку при ошибке API', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.error();
    })
  );

  render(<UserProfile userId="123" />);
  
  expect(await screen.findByText(/error/i)).toBeInTheDocument();
});

5. E2E тесты (Playwright)

import { test, expect } from '@playwright/test';

test('полный сценарий логина', async ({ page }) => {
  // 1. Переходим на страницу логина
  await page.goto('http://localhost:3000/login');
  
  // 2. Заполняем форму
  await page.fill('[data-testid="email-input"]', 'test@example.com');
  await page.fill('[data-testid="password-input"]', 'password123');
  
  // 3. Нажимаем кнопку
  await page.click('button:has-text("Войти")');
  
  // 4. Проверяем редирект на профиль
  await expect(page).toHaveURL('/profile');
  
  // 5. Проверяем что логин произошел
  await expect(page.locator('h1')).toContainText('Профиль');
});

test('ошибка при неправильном пароле', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  
  await page.fill('[data-testid="email-input"]', 'test@example.com');
  await page.fill('[data-testid="password-input"]', 'wrongpassword');
  
  await page.click('button:has-text("Войти")');
  
  // Проверяем ошибку
  await expect(
    page.locator('[data-testid="error-message"]')
  ).toContainText('Invalid credentials');
  
  // Остаемся на странице логина
  await expect(page).toHaveURL('/login');
});

6. Как я подхожу к тестированию

// Мой процесс (TDD):

// ШАГ 1: Напишу список требований
const requirements = [
  "Показывает список пользователей",
  "Может отфильтровать по имени",
  "Может отсортировать",
  "Ловит ошибки загрузки",
  "Показывает скелетон при загрузке",
];

// ШАГ 2: Напишу падающие тесты
test.todo('Показывает список пользователей');
test.todo('Может отфильтровать по имени');
// и т.д.

// ШАГ 3: Напишу минимальный код
// ТОЛЬКО чтобы тесты прошли

// ШАГ 4: Рефакторю, не ломая тесты

// ШАГ 5: Проверяю coverage
// npm run test:coverage
// Нужно 80%+ минимум

7. Команды

# Unit тесты в watch mode
npm test

# Один раз
npm run test:run

# Coverage отчет
npm run test:coverage

# Только определенный файл
npm test Counter.test.tsx

# E2E
npm run playwright

8. Частые ошибки в тестировании

// Ошибка 1: Тестирование деталей реализации (BAD)
test('вызывает setCount с 1', () => {
  const setCount = vi.fn();
  render(<Counter setCount={setCount} />);
  // Это ломается при рефакторе
});

// Хорошо: Тестируем поведение
test('увеличивает счетчик', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(screen.getByRole('button'));
  expect(screen.getByText('1')).toBeInTheDocument();
});

// Ошибка 2: Неправильные селекторы (BAD)
test('показывает название', () => {
  render(<Card />);
  expect(screen.getByClassName('card-title')).toBeInTheDocument();
  // Селектор по классу ломается при изменении стилей
});

// Хорошо: Используй data-testid
test('показывает название', () => {
  render(<Card />);
  expect(screen.getByTestId('card-title')).toBeInTheDocument();
});

// Ошибка 3: Не ждешь async операций
test('загружает данные', () => {
  render(<UserList />);
  // Нельзя просто проверять, нужно ждать
  expect(screen.getByText('John')).toBeInTheDocument(); // FAIL
});

// Хорошо: Используй waitFor или findBy
test('загружает данные', async () => {
  render(<UserList />);
  expect(await screen.findByText('John')).toBeInTheDocument();
});

Итого

У меня есть иерархия тестирования:

  1. Unit (70%) - функции, компоненты по отдельности
  2. Integration (20%) - взаимодействие компонентов и API
  3. E2E (10%) - полные пользовательские сценарии

Использую TDD подход: требования -> падающие тесты -> код. Всегда минимум 80% coverage. Использую Testing Library (не Enzyme). Не тестирую деталей реализации, только поведение.

Как покрывал код тестами? | PrepBro