Testing Pyreon Apps
Pyreon ships an official test kit, @pyreon/testing — a thin adapter over @testing-library/dom (the same foundation under the React, Vue, Solid, and Svelte testing libraries), so the entire Testing-Library API works exactly as you already know it. On top of that it adds a Pyreon-aware render/renderHook and reactive-native matchers that read Pyreon's fine-grained reactive graph — assertions no DOM-only testing library can express. It runs on vitest.
bun add -d @pyreon/testing @testing-library/jest-dom
@pyreon/testingis the public, app-facing kit. The@pyreon/test-utilspackage is framework-internal (used to test Pyreon's own packages) — you don't install it.@testing-library/jest-domis an optional peer for the DOM matchers.
Render + query
screen, fireEvent, waitFor, within, prettyDOM, and every query are re-exported verbatim from @testing-library/dom — so any Testing-Library guide or matcher applies directly. render is the one Pyreon-specific piece: it mounts a Pyreon component and binds the full query set to it.
import { render, screen, cleanup } from '@pyreon/testing'
import { afterEach } from 'vitest'
afterEach(cleanup) // or add '@pyreon/testing/vitest' to setupFiles
test('renders the greeting', () => {
render(<Greeting name="Ada" />)
expect(screen.getByText('Hello, Ada')).toBeTruthy()
})render(ui, options?) mounts into an isolated container and returns bound queries + container / unmount / debug. screen exposes the same queries scoped to the whole document. Query kinds: getByText, getByTestId, getByRole (implicit + explicit ARIA roles, narrow by accessible name), getByLabelText, getByPlaceholderText — each with queryBy / getAllBy / findBy variants.
Interaction
import { render, screen, fireEvent, waitFor } from '@pyreon/testing'
render(<LoginForm />)
fireEvent.input(screen.getByLabelText('Email'), { target: { value: 'a@b.co' } })
fireEvent.click(screen.getByRole('button', { name: 'Sign in' }))
await waitFor(() => expect(screen.getByText('Welcome')).toBeTruthy())fireEvent dispatches real DOM events across both halves of Pyreon's event model: delegated events (click, input, change, keyDown, submit, pointerDown, focusIn, …) bubble to the one listener on the mount container, and non-bubbling events (focus, blur, mouseEnter, mouseLeave) reach the direct addEventListener Pyreon attaches to the element — both are verified end-to-end in real Chromium. fireEvent returns false when a handler called preventDefault() on a cancelable event, true otherwise. waitFor(cb, { timeout, interval }) polls until cb stops throwing (rejecting after timeout, ~1s by default); waitForElementToBeRemoved(el) resolves once el leaves the DOM.
Hooks
import { renderHook } from '@pyreon/testing'
const { result, rerender } = renderHook((size) => usePagination(size), { initialProps: 10 })
expect(result.current.pageCount()).toBe(5)
rerender(20) // updates the REACTIVE prop signal — the hook is NOT re-invoked (Pyreon runs hooks once)
expect(result.current.pageCount()).toBe(3)Unlike React's renderHook, the hook runs once (Pyreon semantics); rerender updates a reactive props signal so computed/effect derivations re-run.
DOM matchers
@pyreon/testing/matchers registers the full @testing-library/jest-dom matcher set (or use the /vitest setup file, which also wires auto-cleanup):
import '@pyreon/testing/matchers'
expect(screen.getByRole('button')).toBeInTheDocument()
expect(screen.getByTestId('email')).toHaveValue('a@b.co')
expect(screen.getByRole('checkbox')).toBeChecked()
expect(screen.getByRole('dialog')).toBeVisible()The complete jest-dom set applies (toBeInTheDocument, toHaveTextContent, toBeVisible, toHaveAccessibleName, toBeChecked, toHaveValue, toHaveClass, …) — the same matchers you'd use in any Testing-Library project.
Reactive-native matchers
The part no DOM-only testing library can do — assert on fire counts and fine-grained re-run behavior by reading the reactive graph:
import { expectSignal, expectEffect } from '@pyreon/testing'
expectSignal(total).toHaveRecomputedTimes(1) // recomputed once — no thrash
expectEffect(logEffect).toReRunWhen(() => qty.set(3))
expectEffect(logEffect).notToReRunWhen(() => theme.set('dark')) // fine-grained: NOT re-run by an unrelated writenotToReRunWhen verifies fine-grained precision — that an unrelated write does not re-run an effect — which a whole-component re-render model can't assert. These read the reactive graph and require a dev/test build (they throw a clear error under a production build rather than silently pass).
GC / leak matchers
Collapse the hand-rolled WeakRef + double-gc() ceremony, and catch subscription leaks — require --expose-gc:
import { expectGarbageCollected, expectNoReactiveLeak } from '@pyreon/testing'
await expectGarbageCollected(() => makeRow(data))
await expectNoReactiveLeak(() => {
const { unmount } = render(<List rows={rows} />)
unmount()
})Run the suite with --expose-gc (execArgv: ['--expose-gc'] in the vitest config); without it these throw an actionable error rather than silently passing.
Library helpers
Seven subpaths eliminate the provider/setup boilerplate of testing code built on Pyreon's libraries. Each is gated on its optional peer — import @pyreon/testing/form only when @pyreon/form is installed; the main entry stays dependency-light. Every render harness takes a wrapper option, so providers compose (theme + router + query together) — there is deliberately no mega renderApp.
/form — forms without a hand-written harness
Before: write a probe component, mount it, reach into useForm, hand-fire events per field. After:
import { renderForm, expectForm, fillForm, submitForm } from '@pyreon/testing/form'
// Headless (model-level) — no component needed:
const { form, fill, submit } = renderForm(() =>
useForm({ initialValues: { email: '' }, validators: { email: required }, onSubmit }),
)
fill({ email: 'ada@lovelace.dev' }) // setFieldValue + touched per field
await submit() // full handleSubmit pipeline
expectForm(form).toBeValid()
expectForm(form).toHaveValues({ email: 'ada@lovelace.dev' })
// Rendered (DOM-level) — drives register()-bound inputs BY LABEL through
// real input/blur/submit events (so blur-validation runs like in an app):
render(<SignupForm />)
fillForm(document.body, { Email: 'ada@lovelace.dev', 'Accept terms': true })
await submitForm(document.body)fillForm's keys are label matchers (getByLabelText), not field names — register()'s ids are opaque, and the label association is the a11y-correct markup anyway. Checkboxes take booleans, file inputs take File | File[]. expectForm also offers toBeInvalid / toHaveFieldError(field, /re/) / toHaveNoFieldError / toBeDirty / toBePristine.
/router — routes settled before you assert
import { renderWithRouter, expectRouter } from '@pyreon/testing/router'
const { router, navigate, getByText } = await renderWithRouter(null, {
routes: [{ path: '/posts/:id', component: Post, loader: fetchPost }],
route: '/posts/1',
})
getByText('Post 1') // loader data present on FIRST render
expectRouter(router).toBeAt('/posts/:id') // pattern OR concrete path
await navigate('/posts/2') // resolves AFTER guards+loaders+DOM commitrenderWithRouter is async: it pre-resolves the initial route's lazy components and loaders (router.preload — the SSR-handler contract), so useLoaderData() is populated on first render with no loading fallbacks. Pass null for a bare <RouterView/>, or your app shell as ui.
/ui — theme + mode without provider plumbing
import { renderWithTheme, expectComputedStyle } from '@pyreon/testing/ui'
const { getByRole, setMode } = renderWithTheme(<Button state="primary">Go</Button>, { theme })
setMode('dark') // reactive — same elements re-style in place, no remount
expectComputedStyle(getByRole('button'), { color: 'red' }) // 'red' == '#ff0000' == 'rgb(255, 0, 0)'Honest limit: expectComputedStyle normalizes values through the engine's own computed-style serialization — in real Chromium all color forms canonicalize; happy-dom's getComputedStyle is partial (cascade/inheritance incomplete), so class-based computed-style assertions belong in *.browser.test.tsx.
/store — singleton isolation between tests
// @check
import { installStoreReset, withFreshStore } from '@pyreon/testing/store'
import { defineStore, signal } from '@pyreon/store'
import { expect, test } from 'vitest'
const useCart = defineStore('cart', () => {
const items = signal<string[]>([])
return { items, add: (item: string) => items.update((xs) => [...xs, item]) }
})
installStoreReset() // afterEach(resetAllStores) — each test gets fresh singletons
test('scoped isolation without touching other stores', () => {
withFreshStore(useCart, (cart) => {
cart.store.add('boots')
expect(cart.store.items()).toHaveLength(1)
}) // disposed — the next useCart() rebuilds from setup()
})Both build on @pyreon/store's resetStore/resetAllStores, which now dispose the store (effectScope stopped, plugin cleanups run) before dropping it — a reset can no longer leak setup-scope effects.
/i18n, /toast, /query
import { renderWithI18n } from '@pyreon/testing/i18n'
import { expectToast, findToast, clearToasts } from '@pyreon/testing/toast'
import { renderWithQueryClient } from '@pyreon/testing/query'
// i18n: provider + reactive locale switching + a bound t() for assertions.
const { setLocale, t, getByText } = renderWithI18n(<Nav />, { locale: 'en', messages })
setLocale('cs')
getByText(t('home'))
// toast: store-level matchers — work headless or with a mounted <Toaster>.
expectToast(/saved/i, { type: 'success' })
await findToast(/synced/) // waits for async producers
afterEach(clearToasts) // module-level store — always reset between tests
// query: fresh isolated client per test (retry off, gcTime Infinity — the
// TanStack testing convention) + a setQueryData passthrough for seeding.
const { setQueryData, findByText } = renderWithQueryClient(<Todos />)
setQueryData(['todos'], [{ id: 1, title: 'write tests' }])
await findByText('write tests')The guiding rule: test-environment parity
Tests must run in the same environment as production. A test that passes only because vitest provided something production doesn't (a process global, a hand-built vnode, a mocked API) gives false confidence.
Logic, reactivity, universal code → vitest in Node (Node is production for these).
DOM components → vitest with
environment: 'happy-dom'for fast unit coverage.Anything that runs in a real browser in production (renderers, the router, UI/styling, compat layers) → also a real-Chromium smoke test, because happy-dom is a polyfill, not a browser. Interaction tests (
fireEvent-driven) belong in a real browser — Pyreon's event delegation only fires for bubbling events in a real event loop.
Every package uses the shared vitest helper — never a hand-rolled mergeConfig:
import { defineNodeConfig } from '@pyreon/vitest-config'
export default defineNodeConfig({ category: 'fundamentals', environment: 'happy-dom' })Browser configs use defineBrowserConfig.
Bisect-verify regression tests
A regression test is only load-bearing if it fails against the broken code:
Save the fix. 2. Revert it. 3. Run the test — assert it fails with the right message. 4. Restore the fix. 5. Run the test — assert it passes.
Document the result in the PR. A test that passes both ways covers nothing.
Common pitfalls
happy-dom is not a browser. It won't catch real
IntersectionObservertiming, CSS rendering, orimport.meta.envbrowser behavior. Use a real-browser smoke for those.Dev-mode warnings use bare
process.env.NODE_ENV !== 'production'— nottypeof process(dead in Vite browser bundles), notimport.meta.env.DEV(Vite-only).Cross-tab Playwright specs reloading via Vite HMR — suppress
@vite/clientper-context for multi-tab listener specs.