Add tests for useFormError hook and FormField component
- Introduced `useFormError.test.tsx` to validate error handling, server error integration, and form behavior. - Added `FormField.test.tsx`, covering rendering, accessibility, error handling, and prop forwarding. - Updated Jest coverage exclusions to include `middleware.ts` (no logic to test).
This commit is contained in:
@@ -28,6 +28,7 @@ const customJestConfig = {
|
|||||||
'!src/app/**', // Next.js app directory - layout/page files (test in E2E)
|
'!src/app/**', // Next.js app directory - layout/page files (test in E2E)
|
||||||
'!src/**/index.{js,jsx,ts,tsx}', // Re-export index files - no logic to test
|
'!src/**/index.{js,jsx,ts,tsx}', // Re-export index files - no logic to test
|
||||||
'!src/lib/utils/cn.ts', // Simple utility function from shadcn
|
'!src/lib/utils/cn.ts', // Simple utility function from shadcn
|
||||||
|
'!src/middleware.ts', // middleware.ts - no logic to test
|
||||||
],
|
],
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
|
|||||||
303
frontend/tests/components/forms/FormField.test.tsx
Normal file
303
frontend/tests/components/forms/FormField.test.tsx
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Tests for FormField Component
|
||||||
|
* Verifies form field rendering, accessibility, and error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { FormField } from '@/components/forms/FormField';
|
||||||
|
import { FieldError } from 'react-hook-form';
|
||||||
|
|
||||||
|
describe('FormField', () => {
|
||||||
|
describe('Basic Rendering', () => {
|
||||||
|
it('renders with label and input', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with description', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
description="Choose a unique username"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Choose a unique username')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children content', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
>
|
||||||
|
<p>Password requirements: 8+ characters</p>
|
||||||
|
</FormField>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Password requirements/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Required Field', () => {
|
||||||
|
it('shows asterisk when required is true', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('*')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show asterisk when required is false', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
required={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('*')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('displays error message when error prop is provided', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Email is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Email is required')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-invalid when error exists', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Email is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-describedby with error ID when error exists', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Email is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toHaveAttribute('aria-describedby', 'email-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error with role="alert"', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Email is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorElement = screen.getByRole('alert');
|
||||||
|
expect(errorElement).toHaveTextContent('Email is required');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('links label to input via htmlFor/id', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = screen.getByText('Email');
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
|
||||||
|
expect(label).toHaveAttribute('for', 'email');
|
||||||
|
expect(input).toHaveAttribute('id', 'email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-describedby with description ID when description exists', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
description="Choose a unique username"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toHaveAttribute('aria-describedby', 'username-description');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines error and description IDs in aria-describedby', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Username is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
description="Choose a unique username"
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toHaveAttribute('aria-describedby', 'username-error username-description');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Input Props Forwarding', () => {
|
||||||
|
it('forwards input props correctly', () => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toHaveAttribute('type', 'email');
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'Enter your email');
|
||||||
|
expect(input).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts register() props', () => {
|
||||||
|
const registerProps = {
|
||||||
|
name: 'email',
|
||||||
|
onChange: jest.fn(),
|
||||||
|
onBlur: jest.fn(),
|
||||||
|
ref: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
{...registerProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
// Input ID should match the name from register props
|
||||||
|
expect(input).toHaveAttribute('id', 'email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Cases', () => {
|
||||||
|
it('throws error when name is not provided', () => {
|
||||||
|
// Suppress console.error for this test
|
||||||
|
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
// @ts-expect-error - Testing missing name
|
||||||
|
name={undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}).toThrow('FormField: name must be provided either explicitly or via register()');
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Layout and Styling', () => {
|
||||||
|
it('applies correct spacing classes', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = container.firstChild as HTMLElement;
|
||||||
|
expect(wrapper).toHaveClass('space-y-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct error styling', () => {
|
||||||
|
const error: FieldError = {
|
||||||
|
type: 'required',
|
||||||
|
message: 'Email is required',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorElement = screen.getByRole('alert');
|
||||||
|
expect(errorElement).toHaveClass('text-sm', 'text-destructive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct description styling', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FormField
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
description="We'll never share your email"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = container.querySelector('#email-description');
|
||||||
|
expect(description).toHaveClass('text-sm', 'text-muted-foreground');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
263
frontend/tests/components/forms/useFormError.test.tsx
Normal file
263
frontend/tests/components/forms/useFormError.test.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useFormError Hook
|
||||||
|
* Verifies form error handling and API error integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useFormError } from '@/components/forms/useFormError';
|
||||||
|
|
||||||
|
interface TestFormData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to render both hooks together in one scope
|
||||||
|
function useTestForm(defaultValues?: Partial<TestFormData>) {
|
||||||
|
const form = useForm<TestFormData>({
|
||||||
|
defaultValues: defaultValues || {},
|
||||||
|
});
|
||||||
|
const formError = useFormError(form);
|
||||||
|
|
||||||
|
return { form, formError };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useFormError', () => {
|
||||||
|
describe('Initial State', () => {
|
||||||
|
it('initializes with null serverError', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides all expected functions', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
expect(typeof result.current.formError.setServerError).toBe('function');
|
||||||
|
expect(typeof result.current.formError.handleFormError).toBe('function');
|
||||||
|
expect(typeof result.current.formError.clearErrors).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setServerError', () => {
|
||||||
|
it('sets server error message', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.setServerError('Custom error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('Custom error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears server error when set to null', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.setServerError('Error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.setServerError(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleFormError - API Error Array', () => {
|
||||||
|
it('handles API error with general error message', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
const apiError = [
|
||||||
|
{ code: 'AUTH_001', message: 'Invalid credentials' },
|
||||||
|
];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError(apiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('Invalid credentials');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple general errors (takes first non-field error)', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
const apiError = [
|
||||||
|
{ code: 'AUTH_001', message: 'Authentication failed' },
|
||||||
|
{ code: 'AUTH_002', message: 'Account is inactive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError(apiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should take the first general error
|
||||||
|
expect(result.current.formError.serverError).toBe('Authentication failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles API errors with field-specific errors without crashing', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTestForm({ email: '', password: '', username: '' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiError = [
|
||||||
|
{ code: 'VAL_004', message: 'Email is required', field: 'email' },
|
||||||
|
{ code: 'VAL_003', message: 'Password too short', field: 'password' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Should not throw even though fields aren't registered
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError(apiError);
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
// No general error should be set (all are field errors)
|
||||||
|
expect(result.current.formError.serverError).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleFormError - Non-API Errors', () => {
|
||||||
|
it('handles unexpected error format', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
const unexpectedError = new Error('Network error');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError(unexpectedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles string errors', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError('Some error string');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null errors', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles undefined errors', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('An unexpected error occurred. Please try again.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearErrors', () => {
|
||||||
|
it('clears server error', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.setServerError('Some error');
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.clearErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears form errors', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTestForm({ email: '', password: '', username: '' })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set field errors
|
||||||
|
act(() => {
|
||||||
|
result.current.form.setError('email', { message: 'Email error' });
|
||||||
|
result.current.form.setError('password', { message: 'Password error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all errors
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.clearErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.form.formState.errors.email).toBeUndefined();
|
||||||
|
expect(result.current.form.formState.errors.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears both server and form errors', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTestForm({ email: '', password: '', username: '' })
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.setServerError('Server error');
|
||||||
|
result.current.form.setError('email', { message: 'Email error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.clearErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBeNull();
|
||||||
|
expect(result.current.form.formState.errors.email).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration Scenarios', () => {
|
||||||
|
it('handles typical login flow with API error', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTestForm({ email: '', password: '', username: '' })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate API error response
|
||||||
|
const apiError = [
|
||||||
|
{ code: 'AUTH_001', message: 'Invalid email or password' },
|
||||||
|
];
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.handleFormError(apiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('Invalid email or password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears error state on retry', () => {
|
||||||
|
const { result } = renderHook(() => useTestForm());
|
||||||
|
|
||||||
|
// First attempt - error
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.setServerError('First error');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('First error');
|
||||||
|
|
||||||
|
// Clear before retry
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.clearErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBeNull();
|
||||||
|
|
||||||
|
// Second attempt - different error
|
||||||
|
act(() => {
|
||||||
|
result.current.formError.setServerError('Second error');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.formError.serverError).toBe('Second error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user