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