Skip to content

Writing Tests

The examples on this page assume the setup from Getting Started: Vitest in happy-dom, with the auto cleanup registered. Counter is the component from the first test.

render accepts a function that returns a view. It calls the function under mount, so the bindings, the effect$ calls and the blocks of the view get the scope they need:

import { it, expect } from 'vitest'
import { render } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { button } from 'nanoviews'
it('should set ref', () => {
const $ref = signal<HTMLButtonElement | null>(null)
render(() => (
button({
ref: $ref
})(
'Click me!'
)
))
expect($ref()).toBeInstanceOf(HTMLButtonElement)
})

A component and a component instance are such functions too: a component returns an instance, and an instance called with no arguments renders. So a component that needs no props goes in as it is, and a component with props goes in as an instance, with its props type-checked against the component. A called composed story is an instance of the same kind. Each of these calls mounts a view:

import { render } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { p, component$ } from 'nanoviews'
import { Counter } from './Counter.js'
const Greeting = component$(() => p()('Hello'))
const $count = signal(0)
// a component that needs no props
render(Greeting)
// a function that returns a view
render(() => (
Counter({
$count
})
))
// a component instance
render(Counter({
$count
}))

Hand an instance over as it is, without calling it: in render(Counter({ $count })()) the render of Counter runs right there, outside of mount, where an effect$ throws Cannot read properties of undefined (reading 'depsTail'). Anything that is not a function, such as a node built by hand, throws a TypeError when render calls it. TypeScript rejects both.

The view is mounted into a fresh <div> appended to the container, and the container is document.body by default. So container.innerHTML starts with that <div>, which is container.firstElementChild:

import { it, expect } from 'vitest'
import { render } from '@nanoviews/testing-library'
import { p } from 'nanoviews'
it('should render a paragraph', () => {
const { container } = render(() => p()('Hello'))
expect(container.innerHTML).toBe('<div><p>Hello</p></div>')
})

A node that a portal mounts into document.body lands next to that <div>, not inside it, and goes away with the view:

import { it, expect } from 'vitest'
import { render } from '@nanoviews/testing-library'
import { div, p, portal } from 'nanoviews'
it('should render the toast next to the view', () => {
const { container } = render(() => (
div()(
'Page',
portal(() => document.body, p()('Toast'))
)
))
expect(container.innerHTML).toBe('<div><div>Page</div></div><p>Toast</p>')
})

Another target or container goes into the options of render.

Queries are bound to the container, so they can be destructured from the render result or taken from screen. With the default container both query document.body:

import { it, expect } from 'vitest'
import { render, screen } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { Counter } from './Counter.js'
it('should find the same button', () => {
const $count = signal(0)
const { getByRole } = render(Counter({
$count
}))
expect(getByRole('button')).toBe(screen.getByRole('button'))
})

within, from the same import, binds the queries to any element:

import { it, expect } from 'vitest'
import { render, screen, within } from '@nanoviews/testing-library'
import { div, ul, li } from 'nanoviews'
it('should find the item of the second list', () => {
render(() => (
div()(
ul({
'aria-label': 'Fruits'
})(
li()('Apple')
),
ul({
'aria-label': 'Vegetables'
})(
li()('Carrot')
)
)
))
const vegetables = within(screen.getByRole('list', { name: 'Vegetables' }))
expect(vegetables.getByRole('listitem').textContent).toBe('Carrot')
})

Reactivity needs no special treatment: a signal write is applied to the DOM synchronously, so the assertion goes right after it, with no await and no render to wait for. The nodes stay where they are, and the write changes the bound text, attribute or property in place:

import { it, expect } from 'vitest'
import { render, screen } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { p } from 'nanoviews'
it('should follow the signal', () => {
const $name = signal('World')
render(() => (
p({
title: $name
})(
'Hello, ', $name
)
))
const greeting = screen.getByTitle('World')
expect(greeting.textContent).toBe('Hello, World')
$name('Nanoviews')
expect(greeting.textContent).toBe('Hello, Nanoviews')
expect(screen.getByTitle('Nanoviews')).toBe(greeting)
})

A boolean attribute set to true is written as "true", and removed for false. So a test checks that it is there, with hasAttribute('disabled') or with the jest-dom matchers toBeDisabled() and toBeEnabled(), rather than checking its value.

Blocks change the document right away too. A child that show_ hides is detached from the document, so a queryBy* query returns null for it, and a child that if_ switches away from is gone together with its state:

import { it, expect } from 'vitest'
import { render, screen } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { div, p, show_ } from 'nanoviews'
it('should show the details', () => {
const $open = signal(false)
render(() => (
div()(
show_($open, () => p()('Details'))
)
))
expect(screen.queryByText('Details')).toBeNull()
$open(true)
expect(screen.queryByText('Details')).not.toBeNull()
})

fireEvent dispatches DOM events, and a two-way binding listens to the event a browser fires for its control:

BindingEvent to fire
value of input and textareafireEvent.input(el, { target: { value } })
checked of a checkbox or a radio buttonfireEvent.change(el, { target: { checked } }) or fireEvent.click(el)
value of selectfireEvent.change(el, { target: { value } })

The target of the event init sets the property on the element before the event is dispatched, and the binding writes what the element holds to the signal:

import { it, expect } from 'vitest'
import { render, screen, fireEvent } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { input } from 'nanoviews'
it('should handle value of text input', () => {
const $value = signal('Hello, world!')
render(() => (
input({
type: 'text',
value: $value
})
))
const textbox = screen.getByRole<HTMLInputElement>('textbox')
expect(textbox.value).toBe('Hello, world!')
$value('Hello, Nanoviews!')
expect(textbox.value).toBe('Hello, Nanoviews!')
fireEvent.input(textbox, {
target: { value: 'user input' }
})
expect($value()).toBe('user input')
})

A bare change does not write value: the binding of a text control listens to input, which a browser fires on every edit. The type argument of a role query, getByRole<HTMLInputElement>, types the element it returns.

A click toggles a checkbox and checks a radio button, and fires change as in a browser, so fireEvent.click drives checked as well as a change with the new state does:

import { it, expect } from 'vitest'
import { render, screen, fireEvent } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { input } from 'nanoviews'
it('should handle checked of checkbox', () => {
const $agreed = signal(false)
render(() => (
input({
type: 'checkbox',
checked: $agreed
})
))
const checkbox = screen.getByRole<HTMLInputElement>('checkbox')
fireEvent.click(checkbox)
expect($agreed()).toBe(true)
fireEvent.change(checkbox, {
target: { checked: false }
})
expect($agreed()).toBe(false)
})

A select is driven by a change on the select itself; a click on an option changes nothing:

import { it, expect } from 'vitest'
import { render, screen, fireEvent } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { select, option } from 'nanoviews'
it('should handle value of select', () => {
const $color = signal('red')
render(() => (
select({
value: $color
})(
option({
value: 'red'
})(
'Red'
),
option({
value: 'blue'
})(
'Blue'
)
)
))
const combobox = screen.getByRole<HTMLSelectElement>('combobox')
expect(combobox.value).toBe('red')
fireEvent.change(combobox, {
target: { value: 'blue' }
})
expect($color()).toBe('blue')
})

A select with a plain value selects its option a microtask after it is built: its options come after its attributes, and the select watches them with a MutationObserver. Wait for a microtask before the assertion:

import { it, expect } from 'vitest'
import { render, screen } from '@nanoviews/testing-library'
import { select, option } from 'nanoviews'
it('should select the plain value', async () => {
render(() => (
select({
value: 'blue'
})(
option({
value: 'red'
})(
'Red'
),
option({
value: 'blue'
})(
'Blue'
)
)
))
const combobox = screen.getByRole<HTMLSelectElement>('combobox')
await Promise.resolve()
expect(combobox.value).toBe('blue')
})

A signal value needs no wait: it is applied when the view is mounted and on every write, synchronously. Options that come in later, built by for_ or with async data, are selected a microtask after they land, with a plain value and a signal alike. Forms covers the bindings themselves.

Files have no binding. Fire change with the files in the target, and the onChange handler reads them from the input:

import { it, expect, vi } from 'vitest'
import { render, fireEvent } from '@nanoviews/testing-library'
import { input } from 'nanoviews'
it('should upload the picked file', () => {
const upload = vi.fn()
const { container } = render(() => (
input({
type: 'file',
onChange(event) {
upload(event.target.files)
}
})
))
const file = new File(['avatar'], 'avatar.png', {
type: 'image/png'
})
fireEvent.change(container.querySelector('input')!, {
target: { files: [file] }
})
expect(upload).toHaveBeenCalledWith([file])
})

Every other event reaches the on* handler as the native event: fireEvent.keyDown(el, { key: 'Enter' }) calls onKeyDown, fireEvent.dblClick(el) calls onDblClick and fireEvent.submit(form) calls onSubmit.

@testing-library/user-event works against rendered elements as usual:

import { it, expect } from 'vitest'
import { userEvent } from '@testing-library/user-event'
import { render, screen } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import { Counter } from './Counter.js'
it('should increment count on click', async () => {
const user = userEvent.setup()
const $count = signal(0)
render(Counter({
$count
}))
await user.click(screen.getByRole('button'))
expect($count()).toBe(1)
})

user.type fires an input event per character, and user.selectOptions fires change on the select, so they drive value bindings the same way fireEvent does.

A store created at module level, like the $todos and $filter signals exported by a todos.ts module here, keeps its state from test to test. Reset it in beforeEach, in a batch, so that whatever depends on it updates once:

import { beforeEach } from 'vitest'
import { batch } from 'nanoviews/store'
import { $todos, $filter } from './todos.js'
beforeEach(() => {
batch(() => {
$todos([])
$filter('all')
})
})

A store resolved through dependency injection has no such problem: it is created in the injection context the view is rendered with, so a test that opens a context of its own starts with a fresh store.

A view that calls inject gets its dependencies from the injection context it is built in. In a test, context$ sets that context up with the values the test chooses. The examples use a theme token and a card that reads it:

theme.ts
import { signal } from 'nanoviews/store'
export type Theme = 'light' | 'dark'
export function Theme$() {
return signal<Theme>('light')
}
Card.ts
import { inject } from 'nanoviews/store'
import { div, component$ } from 'nanoviews'
import { Theme$ } from './theme.js'
export const Card = component$(() => {
const $theme = inject(Theme$)
return (
div({
class: () => `card card_${$theme()}`
})(
'Hello'
)
)
})

With no context around it, context$ with providers opens the root context with them, and the view is built inside of it:

import { it, expect } from 'vitest'
import { render, screen } from '@nanoviews/testing-library'
import { signal, provide } from 'nanoviews/store'
import { context$ } from 'nanoviews'
import { type Theme, Theme$ } from './theme.js'
import { Card } from './Card.js'
it('should use the provided theme', () => {
const $theme = signal<Theme>('dark')
render(() => (
context$(provide(Theme$, $theme))(
Card()
)
))
const card = screen.getByText('Hello')
expect(card.className).toBe('card card_dark')
$theme('light')
expect(card.className).toBe('card card_light')
})

A provided value has exactly the type the factory returns: signal<Theme>('dark') here, since signal('dark') is a WritableSignal<string> and does not type-check.

A view that injects needs a context: rendered without one, inject throws Cannot inject dependency outside of injection context. context$() with no providers gives it a root context with the defaults:

import { it, expect } from 'vitest'
import { render } from '@nanoviews/testing-library'
import { context$ } from 'nanoviews'
import { Card } from './Card.js'
it('should use the default theme', () => {
const { container } = render(() => (
context$()(
Card()
)
))
expect(container.innerHTML).toBe('<div><div class="card card_light">Hello</div></div>')
})

To reach what the view resolved, the test makes the root context itself and hands it in with context$(context). inject with that context returns the very instance the view uses:

import { it, expect } from 'vitest'
import { render, screen } from '@nanoviews/testing-library'
import { InjectionContext, inject } from 'nanoviews/store'
import { context$ } from 'nanoviews'
import { Theme$ } from './theme.js'
import { Card } from './Card.js'
it('should follow the theme the card injected', () => {
const context = new InjectionContext()
render(() => (
context$(context)(
Card()
)
))
const $theme = inject(Theme$, context)
$theme('dark')
expect(screen.getByText('Hello').className).toBe('card card_dark')
})

Dependency Injection covers context$, provide and InjectionContext in depth.

Data that comes with a promise, through resolved, a Nano Kit query or a fetch in onMount, is loaded by a store, and the view only renders what the store holds. The data reaches the DOM once the promise settles: the test fakes the API the store calls, waits for the data once, and everything after it is synchronous again. The examples use a store that loads a name through an API token:

user.ts
import { inject, resolved } from 'nanoviews/store'
export function Api$() {
return {
fetchName: (): Promise<string> => fetch('/api/name').then(response => response.text())
}
}
export function User$() {
const { fetchName } = inject(Api$)
const [$name, $error, $loading] = resolved(fetchName())
return {
$name,
$error,
$loading
}
}

And a view that renders the name:

UserName.ts
import { inject } from 'nanoviews/store'
import { p, if_, component$ } from 'nanoviews'
import { User$ } from './user.js'
export const UserName = component$(() => {
const { $name, $loading } = inject(User$)
return (
p()(
if_($loading)(
() => 'Loading...',
() => $name
)
)
)
})

findBy* queries and waitFor retry until the element shows up or the callback stops throwing, for a second by default:

import { it, expect } from 'vitest'
import { render, screen } from '@nanoviews/testing-library'
import { provide } from 'nanoviews/store'
import { context$ } from 'nanoviews'
import { Api$ } from './user.js'
import { UserName } from './UserName.js'
it('should show the name once it is loaded', async () => {
render(() => (
context$(
provide(Api$, {
fetchName: () => Promise.resolve('Rick')
})
)(
UserName()
)
))
const paragraph = screen.getByText('Loading...')
expect(await screen.findByText('Rick')).toBe(paragraph)
})

The paragraph is the same element before and after: if_ swapped the text inside of it.

A test that holds the promise can await it. resolved subscribed to the promise during the render, when the view injected the store, before the test did, so its result is in the DOM once the await is over:

import { it, expect } from 'vitest'
import { render } from '@nanoviews/testing-library'
import { provide } from 'nanoviews/store'
import { context$ } from 'nanoviews'
import { Api$ } from './user.js'
import { UserName } from './UserName.js'
it('should render the name', async () => {
const name = Promise.resolve('Rick')
const { container } = render(() => (
context$(
provide(Api$, {
fetchName: () => name
})
)(
UserName()
)
))
await name
expect(container.innerHTML).toBe('<div><p>Rick</p></div>')
})

waitTasks from nanoviews/store waits for the tasks of a signal and of every signal it depends on. resolved and Nano Kit queries attach their promises to their signals as tasks, and task attaches one by hand, so a test that holds a signal waits for exactly the data behind it. Here the test makes the context, as in Reaching What the View Injected, and waits for the name of the very store the view injected:

import { it, expect } from 'vitest'
import { render } from '@nanoviews/testing-library'
import { InjectionContext, provide, inject, waitTasks } from 'nanoviews/store'
import { context$ } from 'nanoviews'
import { Api$, User$ } from './user.js'
import { UserName } from './UserName.js'
it('should render the name from the API', async () => {
const context = new InjectionContext([
provide(Api$, {
fetchName: () => Promise.resolve('Rick')
})
])
const { container } = render(() => (
context$(context)(
UserName()
)
))
const { $name } = inject(User$, context)
await waitTasks($name)
expect(container.innerHTML).toBe('<div><p>Rick</p></div>')
})

waitTasks follows the links the signal graph already has, so give it a signal that the view has rendered or the test has read: a computed links its dependencies on its first read. It waits for every promise still pending, including one that resolved has already replaced with a newer one: a fake request that never settles keeps it waiting until the test times out. A rejected promise ends the wait like a resolved one, so the error state is asserted after it.

A query fetches only while its $data is mounted, and a view that renders $data mounts it. A test of the store alone calls start($data) from nanoviews/store before await waitTasks($data), as Nano Kit’s Query Testing shows. Without it, the wait ends at once and nothing is fetched.

A store is tested without a view as well: Store Testing in the Nano Kit docs shows how, and goes deeper into tasks.

An onMount cleanup runs STORE_UNMOUNT_DELAY, a second, after the last subscriber of the store is gone, so a quick unmount and mount do not restart the store. A test of the cleanup advances fake timers by the delay:

import { it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render } from '@nanoviews/testing-library'
import { STORE_UNMOUNT_DELAY, mountable, signal, onMount, length } from 'nanoviews/store'
import { p } from 'nanoviews'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should close the connection after the view is gone', () => {
const $messages = mountable(signal<string[]>([]))
const close = vi.fn()
onMount($messages, () => close)
const { destroy } = render(() => p()(length($messages)))
destroy()
expect(close).not.toHaveBeenCalled()
vi.advanceTimersByTime(STORE_UNMOUNT_DELAY)
expect(close).toHaveBeenCalledTimes(1)
})

onStop has no delay: its listener runs as soon as the last subscriber is gone.

composeStories from @nanoviews/storybook turns a stories module into functions, and calling one returns the story unbuilt, like a component instance, which render takes as it is. An arg can be a plain value or a signal, and a signal is the handle the test drives the story with. With the Button stories of Storybook’s Getting Started:

import { it, expect } from 'vitest'
import { composeStories } from '@nanoviews/storybook'
import { render } from '@nanoviews/testing-library'
import { signal } from 'nanoviews/store'
import * as Stories from './button.stories.js'
const { Primary } = composeStories(Stories)
it('should follow the primary arg', () => {
const $primary = signal(true)
const { container } = render(Primary({
primary: $primary
}))
expect(container.innerHTML).toBe('<div><button class="button button_primary">Send</button></div>')
$primary(false)
expect(container.innerHTML).toBe('<div><button class="button">Send</button></div>')
})

An arg left out, label here, falls back to the args of the story and its meta, wrapped in a signal just the same. Portable Stories covers composeStory and the project annotations.

A routed view is tested on virtualNavigation, a history kept in memory that the test moves synchronously with push, replace and back. Router Testing shows how, with and without dependency injection.