Перейти к содержимому

16. Формы и взаимодействие

RTL Forms

LoginForm.tsx
import { useState } from 'react';
interface LoginFormProps {
onSubmit: (data: { email: string; password: string }) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = () => {
const newErrors: Record<string, string> = {};
if (!email) newErrors.email = 'Email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
newErrors.email = 'Invalid email format';
if (!password) newErrors.password = 'Password is required';
else if (password.length < 8)
newErrors.password = 'Password must be at least 8 characters';
return newErrors;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-invalid={!!errors.password}
/>
{errors.password && (
<span role="alert">{errors.password}</span>
)}
</div>
<button type="submit">Login</button>
</form>
);
}
LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
beforeEach(() => {
mockSubmit.mockClear();
render(<LoginForm onSubmit={mockSubmit} />);
});
test('renders form fields', () => {
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
test('submits with valid data', async () => {
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(mockSubmit).toHaveBeenCalledWith({
password: 'password123',
});
});
test('shows error for empty email', async () => {
await user.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByRole('alert', { name: /email is required/i })).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
});
test('shows error for invalid email', async () => {
await user.type(screen.getByLabelText(/email/i), 'notanemail');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
});
test('shows error for short password', async () => {
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), '123');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
expect(mockSubmit).not.toHaveBeenCalled();
});
test('clears errors on valid input', async () => {
// Показать ошибку
await user.click(screen.getByRole('button', { name: /login/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
// Исправить
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
PreferencesForm.tsx
export function PreferencesForm({ onSave }) {
return (
<form>
<label>
<input type="checkbox" name="notifications" />
Email notifications
</label>
<label>
Theme
<select name="theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
</label>
<button type="submit">Save preferences</button>
</form>
);
}
test('saves preferences', async () => {
const user = userEvent.setup();
const onSave = jest.fn();
render(<PreferencesForm onSave={onSave} />);
// Включить уведомления
await user.click(screen.getByRole('checkbox', { name: /email notifications/i }));
expect(screen.getByRole('checkbox')).toBeChecked();
// Выбрать тему
await user.selectOptions(
screen.getByRole('combobox', { name: /theme/i }),
'dark'
);
expect(screen.getByRole('combobox')).toHaveValue('dark');
await user.click(screen.getByRole('button', { name: /save/i }));
expect(onSave).toHaveBeenCalled();
});
import { useForm } from 'react-hook-form';
export function ContactForm({ onSubmit }) {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('name', { required: 'Name is required' })}
placeholder="Your name"
/>
{errors.name && <span>{errors.name.message}</span>}
<button type="submit">Send</button>
</form>
);
}
test('validates required fields', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /send/i }));
expect(await screen.findByText('Name is required')).toBeInTheDocument();
});
  1. Протестируй форму поиска с автодополнением
  2. Протестируй форму с несколькими шагами (Step 1 → Step 2 → Review)
  3. Протестируй форму с react-hook-form и Zod валидацией
  • Тестируй форму с точки зрения пользователя: ввод → отправка → результат
  • getByLabelText — лучший способ найти поля формы
  • Тестируй валидацию: пустые поля, неверные форматы, граничные случаи
  • Не тестируй реализацию формы — тестируй поведение