@fictjs/testing-library
v0.16.0
Published
Testing utilities for Fict components, built on @testing-library/dom
Downloads
1,341
Maintainers
Readme
@fictjs/testing-library
Testing utilities for Fict components, built on top of @testing-library/dom.
Table of Contents
- Overview
- Installation
- Quick Start
- Core Concepts
- API Reference
- Type Definitions
- Testing Patterns
- Troubleshooting
Overview
@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.
Key Features
| Feature | Description |
| ---------------------- | ---------------------------------------------------- |
| render() | Render Fict components and get query utilities |
| cleanup() | Automatic cleanup of rendered components |
| renderHook() | Test custom reactive hooks in isolation |
| testEffect() | Test effects with async assertions |
| act() | Flush pending microtasks and effects |
| Error Boundary Testing | Test error handling with renderWithErrorBoundary() |
| Suspense Testing | Test loading states with renderWithSuspense() |
Installation
pnpm add -D @fictjs/testing-library
# or
npm install --save-dev @fictjs/testing-library
# or
yarn add -D @fictjs/testing-libraryPeer Dependencies:
pnpm add @fictjs/runtime @testing-library/domQuick Start
Basic Component Testing
import { render, screen, cleanup } from '@fictjs/testing-library'
import { describe, it, expect, afterEach } from 'vitest'
// Cleanup is automatic with Vitest/Jest
describe('Greeting', () => {
it('renders greeting message', () => {
const { getByText } = render(() => <Greeting name="World" />)
expect(getByText('Hello, World!')).toBeInTheDocument()
})
})Testing with User Interactions
import { render, fireEvent } from '@fictjs/testing-library'
it('increments counter on click', async () => {
const { getByRole, getByText } = render(() => <Counter />)
const button = getByRole('button')
fireEvent.click(button)
expect(getByText('Count: 1')).toBeInTheDocument()
})Testing Reactive Hooks
import { renderHook } from '@fictjs/testing-library'
function useCounter(initial: number) {
let count = $state(initial)
const increment = () => count++
return { count: () => count, increment }
}
it('increments counter', () => {
const { result } = renderHook(() => useCounter(0))
expect(result.current.count()).toBe(0)
result.current.increment()
expect(result.current.count()).toBe(1)
})Core Concepts
1. Component Rendering
The render() function renders a Fict component into a container and returns query utilities bound to that container:
const { container, getByText, queryByRole } = render(() => <MyComponent />)The component is rendered inside a reactive root, enabling full reactivity during tests.
2. Automatic Cleanup
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.
// Cleanup runs automatically - no manual cleanup needed!
it('first test', () => {
render(() => <ComponentA />)
})
it('second test', () => {
render(() => <ComponentB />) // Previous render is already cleaned up
})3. Testing Reactive Code
Use renderHook() to test reactive hooks and custom logic:
const { result, rerender, cleanup } = renderHook(initial => useMyHook(initial), {
initialProps: [10],
})
// Access hook return values
result.current.someMethod()
// Rerender with new props (note: state resets on rerender)
rerender([20])Note:
rerender()disposes the previous root and creates a new one. Hook state does not persist across rerenders. Disposal runs effect cleanups from the previous root before mounting the new hook instance.
4. Testing Effects
Use testEffect() for testing async effects:
const result = await testEffect<string>(done => {
const data = $state<string | null>(null)
$effect(() => {
if (data !== null) {
done(data) // Signal completion
}
})
// Simulate async operation
setTimeout(() => {
data = 'loaded'
}, 100)
})
expect(result).toBe('loaded')5. Flushing Updates
Use act() to ensure all pending microtasks and effects are flushed:
await act(() => {
result.current.increment()
})
// All effects have run, assertions are safe
expect(result.current.count()).toBe(1)API Reference
render
Render a Fict component for testing.
function render<Q extends Queries = typeof queries>(
view: View,
options?: RenderOptions<Q>,
): RenderResult<Q>Parameters:
| Name | Type | Description |
| --------------------- | ---------------- | ----------------------------------------------------------------- |
| view | () => FictNode | A function that returns the component to render |
| options.container | HTMLElement? | Container element to render into |
| options.baseElement | HTMLElement? | Base element for queries (defaults to container or document.body) |
| options.queries | Queries? | Custom queries to use |
| options.wrapper | Component? | Wrapper component (e.g., for context providers) |
Returns:
| Property | Type | Description |
| ------------------------------- | ---------------------- | --------------------------------------- |
| container | HTMLElement | The container element |
| baseElement | HTMLElement | The base element for queries |
| asFragment() | () => string | Returns the container's innerHTML |
| debug() | DebugFn | Pretty-print the DOM to console |
| unmount() | () => void | Unmount and cleanup |
| rerender(view) | (view: View) => void | Re-render with a new view |
| getByText, queryByRole, ... | Query functions | All queries from @testing-library/dom |
Example:
// Basic usage
const { getByText } = render(() => <MyComponent />)
expect(getByText('Hello')).toBeInTheDocument()
// With wrapper for context
const { getByText } = render(() => <MyComponent />, {
wrapper: ({ children }) => <ThemeProvider>{children}</ThemeProvider>,
})
// With custom container
const container = document.createElement('div')
render(() => <MyComponent />, { container })cleanup
Clean up all rendered components.
function cleanup(): voidCalled automatically after each test. Can be called manually if needed.
renderHook
Render a hook/reactive code for testing.
function renderHook<Result, Props extends unknown[] = []>(
hookFn: (...args: Props) => Result,
options?: RenderHookOptions<Props> | Props,
): RenderHookResult<Result, Props>Parameters:
| Name | Type | Description |
| ---------------------- | ---------------------------- | --------------------------------- |
| hookFn | (...args: Props) => Result | The hook function to test |
| options.initialProps | Props? | Initial props to pass to the hook |
| options.wrapper | Component? | Wrapper component |
Returns:
| Property | Type | Description |
| ------------------ | ------------------------- | ------------------------------------------------ |
| result | { current: Result } | Container holding the hook's return value |
| rerender(props?) | (props?: Props) => void | Remount with new props (previous state disposed) |
| cleanup() | () => void | Clean up the hook |
| unmount() | () => void | Alias for cleanup |
Example:
// Test a counter hook
const { result } = renderHook((initial: number) => useCounter(initial), {
initialProps: [10],
})
expect(result.current.count()).toBe(10)
result.current.increment()
expect(result.current.count()).toBe(11)
// Shorthand array syntax for initial props
const { result } = renderHook((a, b) => useMyHook(a, b), ['foo', 42])testEffect
Test an effect asynchronously.
function testEffect<T = void>(fn: TestEffectCallback<T>): Promise<T>
type TestEffectCallback<T> = (done: (result: T) => void) => voidExample:
// Test async data loading
const result = await testEffect<number>(done => {
const count = $state(0)
$effect(() => {
if (count === 3) {
done(count)
}
})
count = 1
count = 2
count = 3 // Triggers done()
})
expect(result).toBe(3)act
Run updates and flush pending microtasks/effects.
async function act<T>(fn: () => T | Promise<T>): Promise<T>Example:
await act(() => {
result.current.increment()
})
// All effects have been flushedflush
Flush pending microtasks.
function flush(): Promise<void>waitForCondition
Wait for a condition to be true.
function waitForCondition(
condition: () => boolean,
options?: { timeout?: number; interval?: number },
): Promise<void>Example:
await waitForCondition(() => element.textContent === 'Loaded', {
timeout: 1000,
interval: 50,
})renderWithErrorBoundary
Render a view wrapped in an ErrorBoundary.
function renderWithErrorBoundary<Q extends Queries>(
view: View,
options?: ErrorBoundaryRenderOptions<Q>,
): ErrorBoundaryRenderResult<Q>Additional Options:
| Name | Type | Description |
| ----------- | ---------------------------------------- | ----------------------------- |
| fallback | FictNode \| ((err, reset) => FictNode) | Fallback UI when error occurs |
| onError | (err: unknown) => void | Callback when error is caught |
| resetKeys | unknown \| (() => unknown) | Keys that trigger a reset |
Additional Returns:
| Property | Type | Description |
| ---------------------- | ------------------------ | -------------------------- |
| triggerError(error) | (error: Error) => void | Trigger an error |
| resetErrorBoundary() | () => void | Reset the error boundary |
| isShowingFallback() | () => boolean | Check if fallback is shown |
Example:
const onError = vi.fn()
const { isShowingFallback, triggerError } = renderWithErrorBoundary(
() => <ComponentThatMightThrow />,
{
fallback: err => <div data-testid="error">{err.message}</div>,
onError,
},
)
triggerError(new Error('Test error'))
expect(isShowingFallback()).toBe(true)
expect(onError).toHaveBeenCalled()renderWithSuspense
Render a view wrapped in a Suspense boundary.
function renderWithSuspense<Q extends Queries>(
view: View,
options?: SuspenseRenderOptions<Q>,
): SuspenseRenderResult<Q>Additional Options:
| Name | Type | Description |
| ----------- | ------------------------------ | ------------------------------- |
| fallback | FictNode \| (() => FictNode) | Loading UI while suspended |
| onResolve | () => void | Callback when suspense resolves |
| onReject | (err: unknown) => void | Callback when suspense rejects |
Additional Returns:
| Property | Type | Description |
| ----------------------------- | ----------------------------------------- | ---------------------------------- |
| isShowingFallback() | () => boolean | Check if loading fallback is shown |
| waitForResolution(options?) | (options?: {timeout?}) => Promise<void> | Wait for suspense to resolve |
Example:
const { token, resolve } = createTestSuspenseToken()
const { isShowingFallback, waitForResolution } = renderWithSuspense(
() => <AsyncComponent token={token} />,
{ fallback: <div>Loading...</div> },
)
expect(isShowingFallback()).toBe(true)
resolve()
await waitForResolution()
expect(isShowingFallback()).toBe(false)createTestSuspenseToken
Create a test suspense token for controlling suspense in tests.
function createTestSuspenseToken(): TestSuspenseHandle
interface TestSuspenseHandle {
token: { then: PromiseLike<void>['then'] }
resolve: () => void
reject: (err: unknown) => void
}Example:
const { token, resolve, reject } = createTestSuspenseToken()
// Throw token in component to trigger suspense
const Component = () => {
throw token
}
// Later, resolve to continue rendering
resolve()Type Definitions
Core Types
/** A Fict view function */
type View = () => FictNode
/** Render options */
interface RenderOptions<Q extends Queries> {
container?: HTMLElement
baseElement?: HTMLElement
queries?: Q
wrapper?: Component<{ children: FictNode }>
}
/** RenderHook options */
interface RenderHookOptions<Props extends unknown[]> {
initialProps?: Props
wrapper?: Component<{ children: FictNode }>
}
/** TestEffect callback */
type TestEffectCallback<T> = (done: (result: T) => void) => voidResult Types
/** Render result */
type RenderResult<Q extends Queries> = BoundFunctions<Q> & {
asFragment: () => string
container: HTMLElement
baseElement: HTMLElement
debug: DebugFn
unmount: () => void
rerender: (newView: View) => void
}
/** RenderHook result */
interface RenderHookResult<Result, Props extends unknown[]> {
result: { current: Result }
rerender: (newProps?: Props) => void
cleanup: () => void
unmount: () => void
}Testing Patterns
Testing with Context Providers
const AllProviders = ({ children }: { children: FictNode }) => (
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
)
const { getByText } = render(() => <MyComponent />, {
wrapper: AllProviders,
})Testing Reactive State Changes
import { renderHook, act } from '@fictjs/testing-library'
it('handles state updates', async () => {
const { result } = renderHook(() => {
const count = createSignal(0)
return { count, inc: () => count(count() + 1) }
})
await act(() => {
result.current.inc()
})
expect(result.current.count()).toBe(1)
})Testing Async Effects
import { testEffect } from '@fictjs/testing-library'
it('loads data asynchronously', async () => {
const result = await testEffect<Data>(done => {
const data = $state<Data | null>(null)
$effect(() => {
if (data) done(data)
})
fetchData().then(d => {
data = d
})
})
expect(result).toEqual(expectedData)
})Testing Error Boundaries
it('shows error fallback when child throws', () => {
const { isShowingFallback, triggerError, getByTestId } = renderWithErrorBoundary(
() => <ChildComponent />,
{
fallback: err => <div data-testid="error">{String(err)}</div>,
},
)
triggerError(new Error('Something went wrong'))
expect(isShowingFallback()).toBe(true)
expect(getByTestId('error').textContent).toContain('Something went wrong')
})Troubleshooting
Common Issues
1. "Cleanup not running between tests"
Cause: Auto-cleanup may not be set up correctly.
Solution: Ensure you're using Vitest or Jest, or manually call cleanup():
import { cleanup } from '@fictjs/testing-library'
afterEach(() => {
cleanup()
})2. "Hook state resets on rerender"
Cause: This is expected behavior. renderHook().rerender() disposes the previous root and creates a new one.
Solution: If you need persistent state, test it within a single hook execution:
const { result } = renderHook(() => useMyHook())
result.current.increment() // State persists here
result.current.increment() // And here
// rerender() creates a fresh hook instance3. "Effects not running in tests"
Cause: Effects are scheduled asynchronously.
Solution: Use act() or await with flush():
await act(() => {
result.current.triggerEffect()
})
// Effects have now run4. "Queries not finding elements"
Cause: Element may not be in the queried container.
Solution: Use debug() to inspect the DOM:
const { debug, queryByText } = render(() => <MyComponent />)
debug() // Prints the DOM to consoleConfiguration
Disabling Auto-Cleanup
Set the environment variable:
FICT_TL_SKIP_AUTO_CLEANUP=1 npm testUsing Custom Queries
import { buildQueries } from '@testing-library/dom'
const queryAllByDataCy = (container, value) =>
container.querySelectorAll(`[data-cy="${value}"]`)
const [
queryByDataCy,
getAllByDataCy,
getByDataCy,
findAllByDataCy,
findByDataCy,
] = buildQueries(queryAllByDataCy, ...)
const { getByDataCy } = render(() => <MyComponent />, {
queries: { getByDataCy, queryByDataCy, ... }
})Related Packages
@fictjs/runtime- Core reactive runtime@fictjs/compiler- JSX compiler@fictjs/vite-plugin- Vite integration@testing-library/dom- DOM testing utilities
License
MIT
