Testing
Preact provides testing utilities in the preact/test-utils package to make testing components easier. These utilities help you control the rendering lifecycle, flush effects synchronously, and write reliable tests.
Installation
The test utilities are included in the preact package:
npm install --save-dev preact
You’ll also want a test runner and assertion library:
# Using Vitest (recommended)
npm install --save-dev vitest @testing-library/preact
# Or Jest
npm install --save-dev jest @testing-library/preact
Importing Test Utils
import { act, setupRerender, teardown } from 'preact/test-utils';
import { render } from '@testing-library/preact';
Source: test-utils/src/index.js
setupRerender()
Sets up a function to flush pending renders synchronously. By default, Preact batches renders asynchronously. In tests, you want renders to happen immediately.
import { setupRerender } from 'preact/test-utils';
import { render } from 'preact';
import { useState } from 'preact/hooks';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
test('counter increments', () => {
const rerender = setupRerender();
const container = document.createElement('div');
render(<Counter />, container);
const button = container.querySelector('button');
const span = container.querySelector('span');
expect(span.textContent).toBe('0');
button.click();
rerender(); // Flush the render
expect(span.textContent).toBe('1');
});
How it works:
export function setupRerender() {
options.__test__previousDebounce = options.debounceRendering;
options.debounceRendering = cb => (options.__test__drainQueue = cb);
return () => options.__test__drainQueue && options.__test__drainQueue();
}
Source: test-utils/src/index.js:7-11
It replaces options.debounceRendering to capture the render callback, then returns a function to execute it synchronously.
act()
Runs a function and flushes all effects and rerenders. This is similar to React’s act() helper.
import { act } from 'preact/test-utils';
import { render } from '@testing-library/preact';
import { useState, useEffect } from 'preact/hooks';
function AsyncCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
// Effect runs after render
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
test('effects run synchronously in act', async () => {
const { container } = render(<AsyncCounter />);
const button = container.querySelector('button');
await act(() => {
button.click();
});
// Effect has run, title is updated
expect(document.title).toBe('Count: 1');
});
Key features:
- Flushes all pending renders
- Executes all pending effects (
useEffect, useLayoutEffect)
- Works with both sync and async callbacks
- Returns a Promise
Source: test-utils/src/index.js:27-112
Async Callbacks
act() works with asynchronous callbacks:
import { act } from 'preact/test-utils';
function DataLoader() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const loadData = async () => {
setLoading(true);
const result = await fetch('/api/data');
const json = await result.json();
setData(json);
setLoading(false);
};
return (
<div>
<button onClick={loadData}>Load</button>
{loading && <span>Loading...</span>}
{data && <span>{data.value}</span>}
</div>
);
}
test('loading data', async () => {
const { container } = render(<DataLoader />);
const button = container.querySelector('button');
await act(async () => {
button.click();
await new Promise(resolve => setTimeout(resolve, 100));
});
expect(container.textContent).toContain('Loading...');
});
Source: test-utils/src/index.js:37-46, test-utils/src/index.js:97-101
Nested act() Calls
Nested act() calls are supported:
await act(async () => {
// Outer act
button.click();
await act(async () => {
// Inner act - only outer flushes
anotherButton.click();
});
// Effects flush once when outer act completes
});
Source: test-utils/src/index.js:28-54
teardown()
Cleans up test utilities and resets Preact’s internal state. Call this after each test:
import { teardown } from 'preact/test-utils';
afterEach(() => {
teardown();
});
What it does:
- Flushes any pending updates
- Restores original
debounceRendering
- Cleans up internal test state
export function teardown() {
if (options.__test__drainQueue) {
// Flush any pending updates leftover by test
options.__test__drainQueue();
delete options.__test__drainQueue;
}
if (typeof options.__test__previousDebounce !== 'undefined') {
options.debounceRendering = options.__test__previousDebounce;
delete options.__test__previousDebounce;
} else {
options.debounceRendering = undefined;
}
}
Source: test-utils/src/index.js:117-130
Always call teardown() after tests to prevent state leaking between tests.
Testing Patterns
Basic Component Test
import { render } from '@testing-library/preact';
import { act, teardown } from 'preact/test-utils';
function Greeting({ name }) {
return <div>Hello, {name}!</div>;
}
test('renders greeting', () => {
const { container } = render(<Greeting name="World" />);
expect(container.textContent).toBe('Hello, World!');
});
afterEach(() => {
teardown();
});
Testing State Changes
import { render } from '@testing-library/preact';
import { act, teardown } from 'preact/test-utils';
import { useState } from 'preact/hooks';
function Toggle() {
const [on, setOn] = useState(false);
return (
<div>
<span>{on ? 'ON' : 'OFF'}</span>
<button onClick={() => setOn(!on)}>Toggle</button>
</div>
);
}
test('toggles state', async () => {
const { container } = render(<Toggle />);
const button = container.querySelector('button');
const span = container.querySelector('span');
expect(span.textContent).toBe('OFF');
await act(() => {
button.click();
});
expect(span.textContent).toBe('ON');
await act(() => {
button.click();
});
expect(span.textContent).toBe('OFF');
});
afterEach(() => {
teardown();
});
Testing Effects
import { render } from '@testing-library/preact';
import { act, teardown } from 'preact/test-utils';
import { useState, useEffect } from 'preact/hooks';
function DocumentTitle({ title }) {
useEffect(() => {
document.title = title;
return () => {
document.title = '';
};
}, [title]);
return <div>{title}</div>;
}
test('sets document title', async () => {
const { rerender, unmount } = render(<DocumentTitle title="Test" />);
await act(() => {
// Wait for effect
});
expect(document.title).toBe('Test');
await act(() => {
rerender(<DocumentTitle title="Updated" />);
});
expect(document.title).toBe('Updated');
await act(() => {
unmount();
});
expect(document.title).toBe('');
});
afterEach(() => {
teardown();
});
Testing Async Operations
import { render, waitFor } from '@testing-library/preact';
import { act, teardown } from 'preact/test-utils';
import { useState, useEffect } from 'preact/hooks';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
test('loads user data', async () => {
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'John Doe' })
})
);
const { container } = render(<UserProfile userId={1} />);
expect(container.textContent).toBe('Loading...');
await waitFor(() => {
expect(container.textContent).toBe('John Doe');
});
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
afterEach(() => {
teardown();
});
Testing Custom Hooks
import { renderHook, act } from '@testing-library/preact';
import { teardown } from 'preact/test-utils';
import { useState, useCallback } from 'preact/hooks';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount(c => c + 1);
}, []);
const decrement = useCallback(() => {
setCount(c => c - 1);
}, []);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
test('useCounter hook', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(11);
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(10);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
afterEach(() => {
teardown();
});
Testing Context
import { render } from '@testing-library/preact';
import { createContext } from 'preact';
import { useContext } from 'preact/hooks';
import { teardown } from 'preact/test-utils';
const ThemeContext = createContext('light');
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click me</button>;
}
test('uses context value', () => {
const { container } = render(
<ThemeContext.Provider value="dark">
<ThemedButton />
</ThemeContext.Provider>
);
const button = container.querySelector('button');
expect(button.className).toBe('dark');
});
afterEach(() => {
teardown();
});
Testing Error Boundaries
import { render } from '@testing-library/preact';
import { Component } from 'preact';
import { teardown } from 'preact/test-utils';
class ErrorBoundary extends Component {
state = { error: null };
componentDidCatch(error) {
this.setState({ error });
}
render() {
if (this.state.error) {
return <div>Error: {this.state.error.message}</div>;
}
return this.props.children;
}
}
function BrokenComponent() {
throw new Error('Something went wrong');
}
test('catches errors', () => {
// Suppress console.error in test
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(
<ErrorBoundary>
<BrokenComponent />
</ErrorBoundary>
);
expect(container.textContent).toBe('Error: Something went wrong');
spy.mockRestore();
});
afterEach(() => {
teardown();
});
Integration with Testing Library
Preact works seamlessly with @testing-library/preact:
import { render, screen, fireEvent, waitFor } from '@testing-library/preact';
import { teardown } from 'preact/test-utils';
import '@testing-library/jest-dom';
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Search</button>
</form>
);
}
test('search form', async () => {
const handleSearch = jest.fn();
render(<SearchForm onSearch={handleSearch} />);
const input = screen.getByPlaceholderText('Search...');
const button = screen.getByText('Search');
fireEvent.input(input, { target: { value: 'preact' } });
expect(input.value).toBe('preact');
fireEvent.click(button);
expect(handleSearch).toHaveBeenCalledWith('preact');
});
afterEach(() => {
teardown();
});
Vitest Configuration
Example Vitest setup for Preact:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.js']
},
esbuild: {
jsxFactory: 'h',
jsxFragment: 'Fragment',
jsxInject: `import { h, Fragment } from 'preact'`
}
});
// vitest.setup.js
import { afterEach } from 'vitest';
import { teardown } from 'preact/test-utils';
afterEach(() => {
teardown();
});
Source: vitest.config.mjs in Preact repo
Jest Configuration
Example Jest setup for Preact:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
transform: {
'^.+\\.jsx?$': 'babel-jest'
}
};
// jest.setup.js
import { afterEach } from '@jest/globals';
import { teardown } from 'preact/test-utils';
afterEach(() => {
teardown();
});
// .babelrc
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }]
],
"plugins": [
["@babel/plugin-transform-react-jsx", {
"pragma": "h",
"pragmaFrag": "Fragment"
}]
]
}
Snapshot Testing
import { render } from '@testing-library/preact';
import { teardown } from 'preact/test-utils';
function Card({ title, description }) {
return (
<div className="card">
<h2>{title}</h2>
<p>{description}</p>
</div>
);
}
test('card snapshot', () => {
const { container } = render(
<Card title="Test" description="This is a test" />
);
expect(container.firstChild).toMatchSnapshot();
});
afterEach(() => {
teardown();
});
Best Practices
- Always use act(): Wrap state updates and side effects in
act() to ensure all updates are flushed:
// Good
await act(() => {
button.click();
});
// Bad
button.click(); // Updates may not flush
- Call teardown(): Always clean up after tests:
afterEach(() => {
teardown();
});
- Test user behavior: Focus on how users interact with your components:
// Good - testing user interaction
fireEvent.click(screen.getByText('Submit'));
// Bad - testing implementation details
expect(component.state.submitted).toBe(true);
- Use Testing Library queries: Prefer accessible queries:
// Good
screen.getByRole('button', { name: 'Submit' });
screen.getByLabelText('Email');
// Less good
container.querySelector('button');
- Mock external dependencies: Mock API calls, timers, etc.:
beforeEach(() => {
jest.spyOn(global, 'fetch').mockResolvedValue({
json: async () => ({ data: 'test' })
});
});
- Test edge cases: Don’t just test the happy path:
test('handles empty list', () => {
const { container } = render(<List items={[]} />);
expect(container.textContent).toBe('No items');
});
test('handles network error', async () => {
fetch.mockRejectedValue(new Error('Network error'));
const { container } = render(<DataLoader />);
await waitFor(() => {
expect(container.textContent).toContain('Error');
});
});
Debugging Tests
Using debug()
import { render, screen } from '@testing-library/preact';
test('debug output', () => {
const { debug } = render(<MyComponent />);
debug(); // Prints current DOM
// Or debug specific element
debug(screen.getByRole('button'));
});
Log render count
import { options } from 'preact';
test('component render count', () => {
let renderCount = 0;
const oldRender = options._render;
options._render = (vnode) => {
if (vnode.type === MyComponent) renderCount++;
if (oldRender) oldRender(vnode);
};
// ... test code
expect(renderCount).toBe(1);
// Restore
options._render = oldRender;
});
Use preact/debug in tests for better error messages and validation.
Common Pitfalls
Forgetting to await act()
// Wrong
act(() => {
button.click();
});
expect(span.textContent).toBe('1'); // May fail!
// Correct
await act(() => {
button.click();
});
expect(span.textContent).toBe('1'); // Works!
Not cleaning up
// Wrong - state leaks between tests
test('test 1', () => { /* ... */ });
test('test 2', () => { /* ... */ });
// Correct
afterEach(() => {
teardown();
});
Testing implementation details
// Wrong - brittle
expect(wrapper.find('div').at(2).hasClass('active')).toBe(true);
// Correct - tests behavior
expect(screen.getByRole('tab', { name: 'Settings' })).toHaveAttribute('aria-selected', 'true');