Skip to content

Commit 889a664

Browse files
committed
feat: setup asvanced testing
1 parent adc16cb commit 889a664

12 files changed

+3277
-189
lines changed

package-lock.json

Lines changed: 1461 additions & 184 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
"build": "next build",
88
"start": "next start",
99
"lint": "next lint",
10-
"test": "vitest run"
10+
"test": "vitest run",
11+
"test:coverage": "vitest run --coverage",
12+
"test:ui": "vitest --ui",
13+
"test:watch": "vitest --watch"
1114
},
1215
"dependencies": {
1316
"@dnd-kit/core": "^6.3.1",
1417
"@dnd-kit/sortable": "^8.0.0",
1518
"@dnd-kit/utilities": "^3.2.2",
1619
"@hello-pangea/dnd": "^18.0.1",
1720
"@hookform/resolvers": "^3.10.0",
18-
"@tailwindcss/vite": "^4.1.18",
1921
"@tiptap/extension-image": "^3.20.0",
2022
"@tiptap/extension-link": "^3.20.0",
2123
"@tiptap/extension-placeholder": "^3.20.0",
@@ -45,8 +47,10 @@
4547
"devDependencies": {
4648
"@eslint/eslintrc": "^3",
4749
"@tailwindcss/postcss": "^4.0.0",
50+
"@tailwindcss/vite": "^4.2.0",
4851
"@testing-library/jest-dom": "^6.8.0",
4952
"@testing-library/react": "^16.3.0",
53+
"@testing-library/user-event": "^14.6.1",
5054
"@types/node": "^20",
5155
"@types/react": "^18.3.27",
5256
"@types/react-dom": "^18.3.7",
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
/**
2+
* ComponentTester.test.tsx
3+
*
4+
* Isolated unit tests for the ComponentTester utility.
5+
* Covers: rendering, props, a11y, error boundaries, async state,
6+
* event simulation, and snapshot / visual checks.
7+
*/
8+
9+
import React, { useState, useEffect, FC, ReactNode } from 'react';
10+
import { describe, it, expect, vi, afterEach } from 'vitest';
11+
import { render, screen, waitFor } from '@testing-library/react';
12+
import userEvent from '@testing-library/user-event';
13+
import { createMockFile } from '../utils/testUtils';
14+
15+
// Sample components used across tests
16+
17+
interface ButtonProps {
18+
label: string;
19+
onClick?: () => void;
20+
disabled?: boolean;
21+
variant?: 'primary' | 'secondary' | 'danger';
22+
'aria-label'?: string;
23+
}
24+
25+
const Button: FC<ButtonProps> = ({ label, onClick, disabled, variant = 'primary', ...rest }) => (
26+
<button
27+
className={`btn btn--${variant}`}
28+
onClick={onClick}
29+
disabled={disabled}
30+
aria-label={rest['aria-label'] ?? label}
31+
data-testid="test-button"
32+
>
33+
{label}
34+
</button>
35+
);
36+
37+
interface CounterProps {
38+
initial?: number;
39+
step?: number;
40+
onCount?: (n: number) => void;
41+
}
42+
43+
const Counter: FC<CounterProps> = ({ initial = 0, step = 1, onCount }) => {
44+
const [count, setCount] = useState(initial);
45+
const increment = () => {
46+
const next = count + step;
47+
setCount(next);
48+
onCount?.(next);
49+
};
50+
return (
51+
<div>
52+
<span data-testid="count">{count}</span>
53+
<button onClick={increment} data-testid="increment">+</button>
54+
</div>
55+
);
56+
};
57+
58+
interface AsyncDataProps {
59+
fetchData: () => Promise<string>;
60+
}
61+
62+
const AsyncDataComponent: FC<AsyncDataProps> = ({ fetchData }) => {
63+
const [data, setData] = useState<string | null>(null);
64+
const [error, setError] = useState<string | null>(null);
65+
const [loading, setLoading] = useState(true);
66+
67+
useEffect(() => {
68+
fetchData()
69+
.then(setData)
70+
.catch((e: Error) => setError(e.message))
71+
.finally(() => setLoading(false));
72+
}, [fetchData]);
73+
74+
if (loading) return <p data-testid="loading">Loading…</p>;
75+
if (error) return <p data-testid="error">{error}</p>;
76+
return <p data-testid="data">{data}</p>;
77+
};
78+
79+
class ErrorBoundary extends React.Component<
80+
{ children: ReactNode; fallback: ReactNode },
81+
{ hasError: boolean }
82+
> {
83+
state = { hasError: false };
84+
static getDerivedStateFromError() { return { hasError: true }; }
85+
render() {
86+
return this.state.hasError ? this.props.fallback : this.props.children;
87+
}
88+
}
89+
90+
const Throwing: FC = () => { throw new Error('Render error'); };
91+
92+
interface FormProps {
93+
onSubmit: (value: string) => void;
94+
}
95+
96+
const SimpleForm: FC<FormProps> = ({ onSubmit }) => {
97+
const [value, setValue] = useState('');
98+
return (
99+
<form
100+
data-testid="form"
101+
onSubmit={(e) => { e.preventDefault(); onSubmit(value); }}
102+
>
103+
<label htmlFor="name">Name</label>
104+
<input
105+
id="name"
106+
data-testid="input"
107+
value={value}
108+
onChange={(e) => setValue(e.target.value)}
109+
required
110+
/>
111+
<button type="submit" data-testid="submit">Submit</button>
112+
</form>
113+
);
114+
};
115+
116+
// Button – rendering & variants
117+
118+
describe('ComponentTester – Button', () => {
119+
it('renders with the provided label', () => {
120+
render(<Button label="Click me" />);
121+
expect(screen.getByTestId('test-button')).toHaveTextContent('Click me');
122+
});
123+
124+
it.each(['primary', 'secondary', 'danger'] as const)(
125+
'applies "%s" variant class',
126+
(variant) => {
127+
render(<Button label="X" variant={variant} />);
128+
expect(screen.getByTestId('test-button')).toHaveClass(`btn--${variant}`);
129+
}
130+
);
131+
132+
it('calls onClick handler when clicked', async () => {
133+
const user = userEvent.setup();
134+
const handler = vi.fn();
135+
render(<Button label="Go" onClick={handler} />);
136+
await user.click(screen.getByTestId('test-button'));
137+
expect(handler).toHaveBeenCalledOnce();
138+
});
139+
140+
it('does not call onClick when disabled', async () => {
141+
const user = userEvent.setup();
142+
const handler = vi.fn();
143+
render(<Button label="Go" onClick={handler} disabled />);
144+
await user.click(screen.getByTestId('test-button'));
145+
expect(handler).not.toHaveBeenCalled();
146+
});
147+
148+
it('has an accessible aria-label', () => {
149+
render(<Button label="Save" aria-label="Save document" />);
150+
expect(screen.getByRole('button', { name: 'Save document' })).toBeInTheDocument();
151+
});
152+
153+
it('is focusable via keyboard', async () => {
154+
const user = userEvent.setup();
155+
render(<Button label="Focus me" />);
156+
await user.tab();
157+
expect(screen.getByTestId('test-button')).toHaveFocus();
158+
});
159+
});
160+
161+
// Counter – stateful component
162+
163+
describe('ComponentTester – Counter', () => {
164+
it('renders initial count', () => {
165+
render(<Counter initial={5} />);
166+
expect(screen.getByTestId('count')).toHaveTextContent('5');
167+
});
168+
169+
it('increments by default step on each click', async () => {
170+
const user = userEvent.setup();
171+
render(<Counter />);
172+
await user.click(screen.getByTestId('increment'));
173+
expect(screen.getByTestId('count')).toHaveTextContent('1');
174+
});
175+
176+
it('increments by custom step', async () => {
177+
const user = userEvent.setup();
178+
render(<Counter step={10} />);
179+
await user.click(screen.getByTestId('increment'));
180+
expect(screen.getByTestId('count')).toHaveTextContent('10');
181+
});
182+
183+
it('fires onCount callback with the new value', async () => {
184+
const user = userEvent.setup();
185+
const spy = vi.fn();
186+
render(<Counter onCount={spy} />);
187+
await user.click(screen.getByTestId('increment'));
188+
expect(spy).toHaveBeenCalledWith(1);
189+
});
190+
191+
it('accumulates across multiple clicks', async () => {
192+
const user = userEvent.setup();
193+
render(<Counter step={3} />);
194+
for (let i = 0; i < 4; i++) await user.click(screen.getByTestId('increment'));
195+
expect(screen.getByTestId('count')).toHaveTextContent('12');
196+
});
197+
});
198+
199+
// AsyncDataComponent
200+
201+
describe('ComponentTester – AsyncDataComponent', () => {
202+
afterEach(() => vi.restoreAllMocks());
203+
204+
it('shows loading state initially', () => {
205+
render(<AsyncDataComponent fetchData={() => new Promise(() => {})} />);
206+
expect(screen.getByTestId('loading')).toBeInTheDocument();
207+
});
208+
209+
it('displays data after successful fetch', async () => {
210+
const fetchData = vi.fn().mockResolvedValue('Hello, World!');
211+
render(<AsyncDataComponent fetchData={fetchData} />);
212+
await waitFor(() => expect(screen.getByTestId('data')).toHaveTextContent('Hello, World!'));
213+
});
214+
215+
it('displays error when fetch rejects', async () => {
216+
const fetchData = vi.fn().mockRejectedValue(new Error('fetch failed'));
217+
render(<AsyncDataComponent fetchData={fetchData} />);
218+
await waitFor(() => expect(screen.getByTestId('error')).toHaveTextContent('fetch failed'));
219+
});
220+
221+
it('removes loading indicator after fetch completes', async () => {
222+
const fetchData = vi.fn().mockResolvedValue('done');
223+
render(<AsyncDataComponent fetchData={fetchData} />);
224+
await waitFor(() => expect(screen.queryByTestId('loading')).not.toBeInTheDocument());
225+
});
226+
});
227+
228+
// ErrorBoundary
229+
230+
describe('ComponentTester – ErrorBoundary', () => {
231+
it('renders fallback when child throws', () => {
232+
// Suppress console.error for this test
233+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
234+
render(
235+
<ErrorBoundary fallback={<p data-testid="fallback">Something went wrong</p>}>
236+
<Throwing />
237+
</ErrorBoundary>
238+
);
239+
expect(screen.getByTestId('fallback')).toBeInTheDocument();
240+
consoleSpy.mockRestore();
241+
});
242+
243+
it('renders children when no error occurs', () => {
244+
render(
245+
<ErrorBoundary fallback={<p>Error</p>}>
246+
<Button label="Safe" />
247+
</ErrorBoundary>
248+
);
249+
expect(screen.getByTestId('test-button')).toBeInTheDocument();
250+
});
251+
});
252+
253+
// SimpleForm
254+
255+
describe('ComponentTester – SimpleForm', () => {
256+
it('submits with the entered value', async () => {
257+
const user = userEvent.setup();
258+
const handler = vi.fn();
259+
render(<SimpleForm onSubmit={handler} />);
260+
await user.type(screen.getByTestId('input'), 'Jane Doe');
261+
await user.click(screen.getByTestId('submit'));
262+
expect(handler).toHaveBeenCalledWith('Jane Doe');
263+
});
264+
265+
it('is associated with a visible label', () => {
266+
render(<SimpleForm onSubmit={vi.fn()} />);
267+
expect(screen.getByLabelText('Name')).toBeInTheDocument();
268+
});
269+
270+
it('clears input value when user clears it', async () => {
271+
const user = userEvent.setup();
272+
render(<SimpleForm onSubmit={vi.fn()} />);
273+
const input = screen.getByTestId('input') as HTMLInputElement;
274+
await user.type(input, 'hello');
275+
await user.clear(input);
276+
expect(input.value).toBe('');
277+
});
278+
});
279+
280+
// File-upload simulation
281+
282+
describe('ComponentTester – File handling', () => {
283+
it('accepts a mock file object', () => {
284+
const file = createMockFile('avatar.png', 'binary', 'image/png');
285+
expect(file.name).toBe('avatar.png');
286+
expect(file.type).toBe('image/png');
287+
});
288+
289+
it('simulates file selection on an input', async () => {
290+
const user = userEvent.setup();
291+
const onChange = vi.fn();
292+
render(<input type="file" data-testid="file-input" onChange={onChange} />);
293+
const file = createMockFile('doc.pdf', 'content', 'application/pdf');
294+
await user.upload(screen.getByTestId('file-input'), file);
295+
expect(onChange).toHaveBeenCalled();
296+
});
297+
});
298+
299+
// Snapshot regression
300+
describe('ComponentTester – Snapshot', () => {
301+
it('matches stored snapshot for default Button', () => {
302+
const { container } = render(<Button label="Snapshot" />);
303+
expect(container.firstChild).toMatchSnapshot();
304+
});
305+
306+
it('matches stored snapshot for Counter at initial=0', () => {
307+
const { container } = render(<Counter />);
308+
expect(container.firstChild).toMatchSnapshot();
309+
});
310+
});

0 commit comments

Comments
 (0)