@fictjs/testing-library 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +716 -0
  2. package/package.json +4 -4
package/README.md ADDED
@@ -0,0 +1,716 @@
1
+ # @fictjs/testing-library
2
+
3
+ Testing utilities for Fict components, built on top of `@testing-library/dom`.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Overview](#overview)
8
+ - [Installation](#installation)
9
+ - [Quick Start](#quick-start)
10
+ - [Core Concepts](#core-concepts)
11
+ - [API Reference](#api-reference)
12
+ - [Type Definitions](#type-definitions)
13
+ - [Testing Patterns](#testing-patterns)
14
+ - [Troubleshooting](#troubleshooting)
15
+
16
+ ## Overview
17
+
18
+ `@fictjs/testing-library` provides a set of utilities for testing Fict components in a manner similar to `@testing-library/react` and `@solidjs/testing-library`. It integrates seamlessly with Fict's reactive runtime while leveraging the familiar query APIs from `@testing-library/dom`.
19
+
20
+ ### Key Features
21
+
22
+ | Feature | Description |
23
+ | ---------------------- | ---------------------------------------------------- |
24
+ | `render()` | Render Fict components and get query utilities |
25
+ | `cleanup()` | Automatic cleanup of rendered components |
26
+ | `renderHook()` | Test custom reactive hooks in isolation |
27
+ | `testEffect()` | Test effects with async assertions |
28
+ | `act()` | Flush pending microtasks and effects |
29
+ | Error Boundary Testing | Test error handling with `renderWithErrorBoundary()` |
30
+ | Suspense Testing | Test loading states with `renderWithSuspense()` |
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pnpm add -D @fictjs/testing-library
36
+ # or
37
+ npm install --save-dev @fictjs/testing-library
38
+ # or
39
+ yarn add -D @fictjs/testing-library
40
+ ```
41
+
42
+ **Peer Dependencies:**
43
+
44
+ ```bash
45
+ pnpm add @fictjs/runtime @testing-library/dom
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ### Basic Component Testing
51
+
52
+ ```tsx
53
+ import { render, screen, cleanup } from '@fictjs/testing-library'
54
+ import { describe, it, expect, afterEach } from 'vitest'
55
+
56
+ // Cleanup is automatic with Vitest/Jest
57
+ describe('Greeting', () => {
58
+ it('renders greeting message', () => {
59
+ const { getByText } = render(() => <Greeting name="World" />)
60
+
61
+ expect(getByText('Hello, World!')).toBeInTheDocument()
62
+ })
63
+ })
64
+ ```
65
+
66
+ ### Testing with User Interactions
67
+
68
+ ```tsx
69
+ import { render, fireEvent } from '@fictjs/testing-library'
70
+
71
+ it('increments counter on click', async () => {
72
+ const { getByRole, getByText } = render(() => <Counter />)
73
+
74
+ const button = getByRole('button')
75
+ fireEvent.click(button)
76
+
77
+ expect(getByText('Count: 1')).toBeInTheDocument()
78
+ })
79
+ ```
80
+
81
+ ### Testing Reactive Hooks
82
+
83
+ ```tsx
84
+ import { renderHook } from '@fictjs/testing-library'
85
+
86
+ function useCounter(initial: number) {
87
+ let count = $state(initial)
88
+ const increment = () => count++
89
+ return { count: () => count, increment }
90
+ }
91
+
92
+ it('increments counter', () => {
93
+ const { result } = renderHook(() => useCounter(0))
94
+
95
+ expect(result.current.count()).toBe(0)
96
+ result.current.increment()
97
+ expect(result.current.count()).toBe(1)
98
+ })
99
+ ```
100
+
101
+ ## Core Concepts
102
+
103
+ ### 1. Component Rendering
104
+
105
+ The `render()` function renders a Fict component into a container and returns query utilities bound to that container:
106
+
107
+ ```tsx
108
+ const { container, getByText, queryByRole } = render(() => <MyComponent />)
109
+ ```
110
+
111
+ The component is rendered inside a reactive root, enabling full reactivity during tests.
112
+
113
+ ### 2. Automatic Cleanup
114
+
115
+ By default, cleanup runs automatically after each test when using Vitest or Jest. This can be disabled via the `FICT_TL_SKIP_AUTO_CLEANUP` environment variable.
116
+
117
+ ```tsx
118
+ // Cleanup runs automatically - no manual cleanup needed!
119
+ it('first test', () => {
120
+ render(() => <ComponentA />)
121
+ })
122
+
123
+ it('second test', () => {
124
+ render(() => <ComponentB />) // Previous render is already cleaned up
125
+ })
126
+ ```
127
+
128
+ ### 3. Testing Reactive Code
129
+
130
+ Use `renderHook()` to test reactive hooks and custom logic:
131
+
132
+ ```tsx
133
+ const { result, rerender, cleanup } = renderHook(initial => useMyHook(initial), {
134
+ initialProps: [10],
135
+ })
136
+
137
+ // Access hook return values
138
+ result.current.someMethod()
139
+
140
+ // Rerender with new props (note: state resets on rerender)
141
+ rerender([20])
142
+ ```
143
+
144
+ > **Note:** `rerender()` disposes the previous root and creates a new one. Hook state does not persist across rerenders.
145
+
146
+ ### 4. Testing Effects
147
+
148
+ Use `testEffect()` for testing async effects:
149
+
150
+ ```tsx
151
+ const result = await testEffect<string>(done => {
152
+ const data = $state<string | null>(null)
153
+
154
+ $effect(() => {
155
+ if (data !== null) {
156
+ done(data) // Signal completion
157
+ }
158
+ })
159
+
160
+ // Simulate async operation
161
+ setTimeout(() => {
162
+ data = 'loaded'
163
+ }, 100)
164
+ })
165
+
166
+ expect(result).toBe('loaded')
167
+ ```
168
+
169
+ ### 5. Flushing Updates
170
+
171
+ Use `act()` to ensure all pending microtasks and effects are flushed:
172
+
173
+ ```tsx
174
+ await act(() => {
175
+ result.current.increment()
176
+ })
177
+
178
+ // All effects have run, assertions are safe
179
+ expect(result.current.count()).toBe(1)
180
+ ```
181
+
182
+ ## API Reference
183
+
184
+ ### render
185
+
186
+ Render a Fict component for testing.
187
+
188
+ ```typescript
189
+ function render<Q extends Queries = typeof queries>(
190
+ view: View,
191
+ options?: RenderOptions<Q>,
192
+ ): RenderResult<Q>
193
+ ```
194
+
195
+ **Parameters:**
196
+
197
+ | Name | Type | Description |
198
+ | --------------------- | ---------------- | ----------------------------------------------------------------- |
199
+ | `view` | `() => FictNode` | A function that returns the component to render |
200
+ | `options.container` | `HTMLElement?` | Container element to render into |
201
+ | `options.baseElement` | `HTMLElement?` | Base element for queries (defaults to container or document.body) |
202
+ | `options.queries` | `Queries?` | Custom queries to use |
203
+ | `options.wrapper` | `Component?` | Wrapper component (e.g., for context providers) |
204
+
205
+ **Returns:**
206
+
207
+ | Property | Type | Description |
208
+ | ------------------------------- | ---------------------- | --------------------------------------- |
209
+ | `container` | `HTMLElement` | The container element |
210
+ | `baseElement` | `HTMLElement` | The base element for queries |
211
+ | `asFragment()` | `() => string` | Returns the container's innerHTML |
212
+ | `debug()` | `DebugFn` | Pretty-print the DOM to console |
213
+ | `unmount()` | `() => void` | Unmount and cleanup |
214
+ | `rerender(view)` | `(view: View) => void` | Re-render with a new view |
215
+ | `getByText`, `queryByRole`, ... | Query functions | All queries from `@testing-library/dom` |
216
+
217
+ **Example:**
218
+
219
+ ```tsx
220
+ // Basic usage
221
+ const { getByText } = render(() => <MyComponent />)
222
+ expect(getByText('Hello')).toBeInTheDocument()
223
+
224
+ // With wrapper for context
225
+ const { getByText } = render(() => <MyComponent />, {
226
+ wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
227
+ })
228
+
229
+ // With custom container
230
+ const container = document.createElement('div')
231
+ render(() => <MyComponent />, { container })
232
+ ```
233
+
234
+ ---
235
+
236
+ ### cleanup
237
+
238
+ Clean up all rendered components.
239
+
240
+ ```typescript
241
+ function cleanup(): void
242
+ ```
243
+
244
+ Called automatically after each test. Can be called manually if needed.
245
+
246
+ ---
247
+
248
+ ### renderHook
249
+
250
+ Render a hook/reactive code for testing.
251
+
252
+ ```typescript
253
+ function renderHook<Result, Props extends unknown[] = []>(
254
+ hookFn: (...args: Props) => Result,
255
+ options?: RenderHookOptions<Props> | Props,
256
+ ): RenderHookResult<Result, Props>
257
+ ```
258
+
259
+ **Parameters:**
260
+
261
+ | Name | Type | Description |
262
+ | ---------------------- | ---------------------------- | --------------------------------- |
263
+ | `hookFn` | `(...args: Props) => Result` | The hook function to test |
264
+ | `options.initialProps` | `Props?` | Initial props to pass to the hook |
265
+ | `options.wrapper` | `Component?` | Wrapper component |
266
+
267
+ **Returns:**
268
+
269
+ | Property | Type | Description |
270
+ | ------------------ | ------------------------- | ----------------------------------------- |
271
+ | `result` | `{ current: Result }` | Container holding the hook's return value |
272
+ | `rerender(props?)` | `(props?: Props) => void` | Rerender with new props |
273
+ | `cleanup()` | `() => void` | Clean up the hook |
274
+ | `unmount()` | `() => void` | Alias for cleanup |
275
+
276
+ **Example:**
277
+
278
+ ```tsx
279
+ // Test a counter hook
280
+ const { result } = renderHook((initial: number) => useCounter(initial), {
281
+ initialProps: [10],
282
+ })
283
+
284
+ expect(result.current.count()).toBe(10)
285
+ result.current.increment()
286
+ expect(result.current.count()).toBe(11)
287
+
288
+ // Shorthand array syntax for initial props
289
+ const { result } = renderHook((a, b) => useMyHook(a, b), ['foo', 42])
290
+ ```
291
+
292
+ ---
293
+
294
+ ### testEffect
295
+
296
+ Test an effect asynchronously.
297
+
298
+ ```typescript
299
+ function testEffect<T = void>(fn: TestEffectCallback<T>): Promise<T>
300
+
301
+ type TestEffectCallback<T> = (done: (result: T) => void) => void
302
+ ```
303
+
304
+ **Example:**
305
+
306
+ ```tsx
307
+ // Test async data loading
308
+ const result = await testEffect<number>(done => {
309
+ const count = $state(0)
310
+
311
+ $effect(() => {
312
+ if (count === 3) {
313
+ done(count)
314
+ }
315
+ })
316
+
317
+ count = 1
318
+ count = 2
319
+ count = 3 // Triggers done()
320
+ })
321
+
322
+ expect(result).toBe(3)
323
+ ```
324
+
325
+ ---
326
+
327
+ ### act
328
+
329
+ Run updates and flush pending microtasks/effects.
330
+
331
+ ```typescript
332
+ async function act<T>(fn: () => T | Promise<T>): Promise<T>
333
+ ```
334
+
335
+ **Example:**
336
+
337
+ ```tsx
338
+ await act(() => {
339
+ result.current.increment()
340
+ })
341
+ // All effects have been flushed
342
+ ```
343
+
344
+ ---
345
+
346
+ ### flush
347
+
348
+ Flush pending microtasks.
349
+
350
+ ```typescript
351
+ function flush(): Promise<void>
352
+ ```
353
+
354
+ ---
355
+
356
+ ### waitForCondition
357
+
358
+ Wait for a condition to be true.
359
+
360
+ ```typescript
361
+ function waitForCondition(
362
+ condition: () => boolean,
363
+ options?: { timeout?: number; interval?: number },
364
+ ): Promise<void>
365
+ ```
366
+
367
+ **Example:**
368
+
369
+ ```tsx
370
+ await waitForCondition(() => element.textContent === 'Loaded', {
371
+ timeout: 1000,
372
+ interval: 50,
373
+ })
374
+ ```
375
+
376
+ ---
377
+
378
+ ### renderWithErrorBoundary
379
+
380
+ Render a view wrapped in an ErrorBoundary.
381
+
382
+ ```typescript
383
+ function renderWithErrorBoundary<Q extends Queries>(
384
+ view: View,
385
+ options?: ErrorBoundaryRenderOptions<Q>,
386
+ ): ErrorBoundaryRenderResult<Q>
387
+ ```
388
+
389
+ **Additional Options:**
390
+
391
+ | Name | Type | Description |
392
+ | ----------- | ---------------------------------------- | ----------------------------- |
393
+ | `fallback` | `FictNode \| ((err, reset) => FictNode)` | Fallback UI when error occurs |
394
+ | `onError` | `(err: unknown) => void` | Callback when error is caught |
395
+ | `resetKeys` | `unknown \| (() => unknown)` | Keys that trigger a reset |
396
+
397
+ **Additional Returns:**
398
+
399
+ | Property | Type | Description |
400
+ | ---------------------- | ------------------------ | -------------------------- |
401
+ | `triggerError(error)` | `(error: Error) => void` | Trigger an error |
402
+ | `resetErrorBoundary()` | `() => void` | Reset the error boundary |
403
+ | `isShowingFallback()` | `() => boolean` | Check if fallback is shown |
404
+
405
+ **Example:**
406
+
407
+ ```tsx
408
+ const onError = vi.fn()
409
+ const { isShowingFallback, triggerError } = renderWithErrorBoundary(
410
+ () => <ComponentThatMightThrow />,
411
+ {
412
+ fallback: err => <div data-testid="error">{err.message}</div>,
413
+ onError,
414
+ },
415
+ )
416
+
417
+ triggerError(new Error('Test error'))
418
+ expect(isShowingFallback()).toBe(true)
419
+ expect(onError).toHaveBeenCalled()
420
+ ```
421
+
422
+ ---
423
+
424
+ ### renderWithSuspense
425
+
426
+ Render a view wrapped in a Suspense boundary.
427
+
428
+ ```typescript
429
+ function renderWithSuspense<Q extends Queries>(
430
+ view: View,
431
+ options?: SuspenseRenderOptions<Q>,
432
+ ): SuspenseRenderResult<Q>
433
+ ```
434
+
435
+ **Additional Options:**
436
+
437
+ | Name | Type | Description |
438
+ | ----------- | ------------------------------ | ------------------------------- |
439
+ | `fallback` | `FictNode \| (() => FictNode)` | Loading UI while suspended |
440
+ | `onResolve` | `() => void` | Callback when suspense resolves |
441
+ | `onReject` | `(err: unknown) => void` | Callback when suspense rejects |
442
+
443
+ **Additional Returns:**
444
+
445
+ | Property | Type | Description |
446
+ | ----------------------------- | ----------------------------------------- | ---------------------------------- |
447
+ | `isShowingFallback()` | `() => boolean` | Check if loading fallback is shown |
448
+ | `waitForResolution(options?)` | `(options?: {timeout?}) => Promise<void>` | Wait for suspense to resolve |
449
+
450
+ **Example:**
451
+
452
+ ```tsx
453
+ const { token, resolve } = createTestSuspenseToken()
454
+
455
+ const { isShowingFallback, waitForResolution } = renderWithSuspense(
456
+ () => <AsyncComponent token={token} />,
457
+ { fallback: <div>Loading...</div> },
458
+ )
459
+
460
+ expect(isShowingFallback()).toBe(true)
461
+
462
+ resolve()
463
+ await waitForResolution()
464
+
465
+ expect(isShowingFallback()).toBe(false)
466
+ ```
467
+
468
+ ---
469
+
470
+ ### createTestSuspenseToken
471
+
472
+ Create a test suspense token for controlling suspense in tests.
473
+
474
+ ```typescript
475
+ function createTestSuspenseToken(): TestSuspenseHandle
476
+
477
+ interface TestSuspenseHandle {
478
+ token: { then: PromiseLike<void>['then'] }
479
+ resolve: () => void
480
+ reject: (err: unknown) => void
481
+ }
482
+ ```
483
+
484
+ **Example:**
485
+
486
+ ```tsx
487
+ const { token, resolve, reject } = createTestSuspenseToken()
488
+
489
+ // Throw token in component to trigger suspense
490
+ const Component = () => {
491
+ throw token
492
+ }
493
+
494
+ // Later, resolve to continue rendering
495
+ resolve()
496
+ ```
497
+
498
+ ## Type Definitions
499
+
500
+ ### Core Types
501
+
502
+ ```typescript
503
+ /** A Fict view function */
504
+ type View = () => FictNode
505
+
506
+ /** Render options */
507
+ interface RenderOptions<Q extends Queries> {
508
+ container?: HTMLElement
509
+ baseElement?: HTMLElement
510
+ queries?: Q
511
+ wrapper?: Component<{ children: FictNode }>
512
+ }
513
+
514
+ /** RenderHook options */
515
+ interface RenderHookOptions<Props extends unknown[]> {
516
+ initialProps?: Props
517
+ wrapper?: Component<{ children: FictNode }>
518
+ }
519
+
520
+ /** TestEffect callback */
521
+ type TestEffectCallback<T> = (done: (result: T) => void) => void
522
+ ```
523
+
524
+ ### Result Types
525
+
526
+ ```typescript
527
+ /** Render result */
528
+ type RenderResult<Q extends Queries> = BoundFunctions<Q> & {
529
+ asFragment: () => string
530
+ container: HTMLElement
531
+ baseElement: HTMLElement
532
+ debug: DebugFn
533
+ unmount: () => void
534
+ rerender: (newView: View) => void
535
+ }
536
+
537
+ /** RenderHook result */
538
+ interface RenderHookResult<Result, Props extends unknown[]> {
539
+ result: { current: Result }
540
+ rerender: (newProps?: Props) => void
541
+ cleanup: () => void
542
+ unmount: () => void
543
+ }
544
+ ```
545
+
546
+ ## Testing Patterns
547
+
548
+ ### Testing with Context Providers
549
+
550
+ ```tsx
551
+ const AllProviders = ({ children }: { children: FictNode }) => (
552
+ <ThemeProvider>
553
+ <AuthProvider>{children}</AuthProvider>
554
+ </ThemeProvider>
555
+ )
556
+
557
+ const { getByText } = render(() => <MyComponent />, {
558
+ wrapper: AllProviders,
559
+ })
560
+ ```
561
+
562
+ ### Testing Reactive State Changes
563
+
564
+ ```tsx
565
+ import { renderHook, act } from '@fictjs/testing-library'
566
+
567
+ it('handles state updates', async () => {
568
+ const { result } = renderHook(() => {
569
+ const count = createSignal(0)
570
+ return { count, inc: () => count(count() + 1) }
571
+ })
572
+
573
+ await act(() => {
574
+ result.current.inc()
575
+ })
576
+
577
+ expect(result.current.count()).toBe(1)
578
+ })
579
+ ```
580
+
581
+ ### Testing Async Effects
582
+
583
+ ```tsx
584
+ import { testEffect } from '@fictjs/testing-library'
585
+
586
+ it('loads data asynchronously', async () => {
587
+ const result = await testEffect<Data>(done => {
588
+ const data = $state<Data | null>(null)
589
+
590
+ $effect(() => {
591
+ if (data) done(data)
592
+ })
593
+
594
+ fetchData().then(d => {
595
+ data = d
596
+ })
597
+ })
598
+
599
+ expect(result).toEqual(expectedData)
600
+ })
601
+ ```
602
+
603
+ ### Testing Error Boundaries
604
+
605
+ ```tsx
606
+ it('shows error fallback when child throws', () => {
607
+ const { isShowingFallback, triggerError, getByTestId } = renderWithErrorBoundary(
608
+ () => <ChildComponent />,
609
+ {
610
+ fallback: err => <div data-testid="error">{String(err)}</div>,
611
+ },
612
+ )
613
+
614
+ triggerError(new Error('Something went wrong'))
615
+
616
+ expect(isShowingFallback()).toBe(true)
617
+ expect(getByTestId('error').textContent).toContain('Something went wrong')
618
+ })
619
+ ```
620
+
621
+ ## Troubleshooting
622
+
623
+ ### Common Issues
624
+
625
+ #### 1. "Cleanup not running between tests"
626
+
627
+ **Cause:** Auto-cleanup may not be set up correctly.
628
+
629
+ **Solution:** Ensure you're using Vitest or Jest, or manually call `cleanup()`:
630
+
631
+ ```tsx
632
+ import { cleanup } from '@fictjs/testing-library'
633
+
634
+ afterEach(() => {
635
+ cleanup()
636
+ })
637
+ ```
638
+
639
+ #### 2. "Hook state resets on rerender"
640
+
641
+ **Cause:** This is expected behavior. `renderHook().rerender()` disposes the previous root and creates a new one.
642
+
643
+ **Solution:** If you need persistent state, test it within a single hook execution:
644
+
645
+ ```tsx
646
+ const { result } = renderHook(() => useMyHook())
647
+ result.current.increment() // State persists here
648
+ result.current.increment() // And here
649
+ // rerender() creates a fresh hook instance
650
+ ```
651
+
652
+ #### 3. "Effects not running in tests"
653
+
654
+ **Cause:** Effects are scheduled asynchronously.
655
+
656
+ **Solution:** Use `act()` or `await` with `flush()`:
657
+
658
+ ```tsx
659
+ await act(() => {
660
+ result.current.triggerEffect()
661
+ })
662
+ // Effects have now run
663
+ ```
664
+
665
+ #### 4. "Queries not finding elements"
666
+
667
+ **Cause:** Element may not be in the queried container.
668
+
669
+ **Solution:** Use `debug()` to inspect the DOM:
670
+
671
+ ```tsx
672
+ const { debug, queryByText } = render(() => <MyComponent />)
673
+ debug() // Prints the DOM to console
674
+ ```
675
+
676
+ ### Configuration
677
+
678
+ #### Disabling Auto-Cleanup
679
+
680
+ Set the environment variable:
681
+
682
+ ```bash
683
+ FICT_TL_SKIP_AUTO_CLEANUP=1 npm test
684
+ ```
685
+
686
+ #### Using Custom Queries
687
+
688
+ ```tsx
689
+ import { buildQueries } from '@testing-library/dom'
690
+
691
+ const queryAllByDataCy = (container, value) =>
692
+ container.querySelectorAll(`[data-cy="${value}"]`)
693
+
694
+ const [
695
+ queryByDataCy,
696
+ getAllByDataCy,
697
+ getByDataCy,
698
+ findAllByDataCy,
699
+ findByDataCy,
700
+ ] = buildQueries(queryAllByDataCy, ...)
701
+
702
+ const { getByDataCy } = render(() => <MyComponent />, {
703
+ queries: { getByDataCy, queryByDataCy, ... }
704
+ })
705
+ ```
706
+
707
+ ## Related Packages
708
+
709
+ - `@fictjs/runtime` - Core reactive runtime
710
+ - `@fictjs/compiler` - JSX compiler
711
+ - `@fictjs/vite-plugin` - Vite integration
712
+ - `@testing-library/dom` - DOM testing utilities
713
+
714
+ ## License
715
+
716
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/testing-library",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Testing utilities for Fict components, built on @testing-library/dom",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -37,9 +37,9 @@
37
37
  "jsdom": "^26.1.0",
38
38
  "tsup": "^8.5.1",
39
39
  "vitest": "^4.0.18",
40
- "@fictjs/compiler": "0.4.0",
41
- "@fictjs/runtime": "0.4.0",
42
- "@fictjs/vite-plugin": "0.4.0"
40
+ "@fictjs/compiler": "0.5.1",
41
+ "@fictjs/runtime": "0.5.1",
42
+ "@fictjs/vite-plugin": "0.5.1"
43
43
  },
44
44
  "keywords": [
45
45
  "fict",