remix/ui/test
Use render from remix/ui/test as the primary way to test components in a live browser through a remix/test "browser" test (*.test.browser.tsx). It creates a DOM container, renders and flushes the initial component tree, and returns helpers for querying, interacting with, and cleaning up the rendered output.
Rendering Components
import { expect } from 'remix/assert'
import { describe, it } from 'remix/test'
import { on, type Handle } from 'remix/ui'
import { render } from 'remix/ui/test'
function Counter(handle: Handle) {
let count = 0
return () => (
<div>
<button
data-action="increment"
mix={[
on('click', () => {
count++
handle.update()
}),
]}
>
Increment
</button>
<output data-testid="count">{count}</output>
</div>
)
}
describe('Counter', () => {
it('increments the count', async (t) => {
let { $, act, cleanup } = render(<Counter />)
t.after(cleanup)
expect($('[data-testid="count"]')?.textContent).toBe('0')
await act(() => $('[data-action="increment"]')?.click())
expect($('[data-testid="count"]')?.textContent).toBe('1')
})
})The initial render is already flushed, so the DOM and event listeners are ready when render
returns. Wrap interactions that may update component state in act and always await it. act
waits for the callback and then flushes pending component updates before the next assertion.
Testing Async Operations
When an interaction starts an async operation, await that operation inside act so updates queued
after it resolves are flushed before making assertions:
function UserLoader(handle: Handle<{ loadUser(): Promise<{ name: string }> }>) {
let name = 'No user loaded'
return () => (
<div>
<button
mix={[
on('click', async () => {
let user = await handle.props.loadUser()
name = user.name
handle.update()
}),
]}
>
Load user
</button>
<output>{name}</output>
</div>
)
}
it('loads a user', async (t) => {
let userPromise = Promise.resolve({ name: 'Ada' })
let { $, act, cleanup } = render(<UserLoader loadUser={() => userPromise} />)
t.after(cleanup)
await act(async () => {
$('button')?.click()
await userPromise
})
expect($('output')?.textContent).toBe('Ada')
})Querying Rendered Output
The returned query helpers are scoped to the rendered container so you don't have to worry about false positives outside of the rendered component:
let { $, $$, container, cleanup } = render(
<nav>
<a href="/one">One</a>
<a href="/two">Two</a>
</nav>,
)
let nav = $('nav') // container.querySelector('nav')
let links = $$('a') // container.querySelectorAll('a')
expect(nav).toBeTruthy()
expect(links).toHaveLength(2)
expect(container.textContent).toContain('One')
cleanup()Use $ for the first matching element, $$ for all matching elements, and container when a
DOM assertion does not fit a selector.
Cleaning Up
Register cleanup with the test context so it always runs, including when an assertion fails:
it('renders a dialog', (t) => {
let { $, cleanup } = render(<Dialog />)
t.after(cleanup)
expect($('[role="dialog"]')).toBeTruthy()
})cleanup disposes the root and removes its container from the document. You can call it directly
when a test needs to assert cleanup behavior.
Rendering Into an Existing Container
Pass a container when the test needs a specific DOM context:
let container = document.createElement('section')
document.body.appendChild(container)
let result = render(<MyComponent />, { container })
expect(result.container).toBe(container)
result.cleanup()Advanced: Flushing Manually
Most component tests should use render and act. For lower-level runtime tests that need direct
control over rendering or scheduling, use the returned root and call root.flush() after an
operation that queues work:
let { root, container, cleanup } = render(<MyComponent value="first" />)
root.render(<MyComponent value="second" />)
root.flush()
expect(container.textContent).toContain('second')
cleanup()You can also create and manage a root directly:
import { createRoot } from 'remix/ui'
let container = document.createElement('div')
let root = createRoot(container)
root.render(<MyComponent />)
root.flush() // Complete the initial render and attach event listeners
container.querySelector('button')?.click()
root.flush() // Apply updates queued by the interaction
root.dispose()Manual flushing is also useful after an async operation resolves outside an act callback:
root.render(<AsyncLoader />)
root.flush()
expect(container.textContent).toBe('Loading...')
await waitForFetch()
root.flush()
expect(container.textContent).toBe('Expected data')See Also
- Getting Started - Root methods reference
- Handle API -
handle.queueTask()behavior