Testing
Pulse ships a built-in testing helper at @invisibleloop/pulse/testing. Render a spec's view in tests and query the HTML output with CSS-like selectors — no DOM, no jsdom, no extra dependencies.
Quick start
import { renderSync, render } from '@invisibleloop/pulse/testing'
import assert from 'node:assert/strict'
import spec from './src/pages/counter.js'
// Synchronous — calls view directly, mock state and server
const result = renderSync(spec, { state: { count: 5 } })
assert(result.has('button'))
assert.equal(result.get('#count').text, '5')
// Async — runs real spec.server fetchers (or pass server to mock them)
const result = await render(productSpec, {
server: { product: { id: 1, name: 'Widget', price: 9.99 } }
})
assert.equal(result.get('h1').text, 'Widget')
assert.equal(result.count('li'), 3)
renderSync(spec, options?)
Synchronous. Calls the view function directly — no server fetcher resolution. The fastest path for unit testing pure view functions.
| Option | Type | Default | Description |
|---|---|---|---|
state | object | {} | State overrides merged with spec.state. |
server | object | {} | Server state passed directly to the view. Fetchers are never called. |
import { renderSync } from '@invisibleloop/pulse/testing'
const result = renderSync(formSpec, {
state: { name: 'Alice', email: 'alice@example.com' },
server: { plans: [{ id: 'pro', label: 'Pro' }] },
})
render(spec, options?)
Async. Two modes — mock or integration:
- Mock mode — pass
serverto use that data directly. Fetchers are not called. Fast and deterministic. - Integration mode — omit
serverand realspec.serverfetchers run. Passctxto set params, cookies, headers, etc.
| Option | Type | Default | Description |
|---|---|---|---|
state | object | {} | State overrides merged with spec.state. |
server | object | undefined | Server state passed directly to the view. When set, fetchers are skipped entirely. |
ctx | object | {} | Request context passed to spec.server fetchers (integration mode only). Accepts params, query, cookies, headers, etc. |
import { render } from '@invisibleloop/pulse/testing'
// Mock — skip fetchers
const result = await render(productSpec, {
server: { product: { id: 42, name: 'Gadget' } }
})
// Integration — real fetchers, with ctx
const result = await render(productSpec, {
ctx: { params: { id: '42' }, cookies: { session: 'abc' } }
})
RenderResult
Both functions return the same RenderResult object.
| Property / method | Returns | Description |
|---|---|---|
.html | string | Raw HTML string from the view. |
.state | object | Client state used for rendering. |
.server | object | Server state used for rendering. |
.text() | string | All text content — tags stripped, entities decoded, whitespace collapsed. |
.has(selector) | boolean | True if any element matches selector. |
.find(selector) | Element | null | First matching element, or null. |
.get(selector) | Element | First matching element. Throws with a clear message if not found. |
.findAll(selector) | Element[] | All matching elements. |
.count(selector) | number | Number of matching elements. |
.attr(selector, name) | string | null | Attribute value of the first matching element. Null if element or attribute absent. |
Element
Elements returned by find(), get(), and findAll() support the same query methods scoped to their own subtree.
| Property / method | Returns | Description |
|---|---|---|
.tag | string | Tag name (lowercase). |
.text | string | All text content within the element, whitespace-collapsed. |
.attrs | object | Parsed attribute map. Boolean attrs (e.g. disabled) have value true. |
.attr(name) | string | null | Get one attribute. Returns "" for boolean attrs, null if absent — mirrors getAttribute(). |
.find(selector) | Element | null | First matching descendant. |
.findAll(selector) | Element[] | All matching descendants. |
.has(selector) | boolean | True if any descendant matches selector. |
Supported selectors
The selector engine supports the most common patterns. Descendant combinators (div p) are not supported — use element.findAll() to search within a matched element instead.
| Selector | Example | Matches |
|---|---|---|
| Tag | button | Any <button> |
| Class | .ui-btn | Elements with that class |
| ID | #submit | Element with that id |
| Attribute present | [disabled] | Elements with a disabled attribute |
| Attribute value | [type="submit"] | Elements where type equals submit |
| Compound | button.primary[disabled] | All conditions on the same element |
result.has('button') // any <button>
result.has('.ui-btn--primary') // BEM modifier class
result.has('[data-action="submit"]') // data attribute
result.has('input[type="email"][required]') // compound
result.get('form').findAll('input') // inputs inside form
Common patterns
// Assert an element exists and check its text
assert.equal(result.get('h1').text, 'Page Title')
// Assert an element does NOT exist
assert(!result.has('.error-message'))
// Check an attribute value
assert.equal(result.attr('input[name="email"]', 'type'), 'email')
// Boolean attributes — attr() returns '' (not 'disabled')
assert.equal(result.attr('[disabled]', 'disabled'), '')
assert(result.get('[disabled]').attr('disabled') === '')
// Count elements
assert.equal(result.count('li'), 3)
// Inspect all items
const items = result.findAll('li')
assert.equal(items[0].text, 'Alpha')
assert.equal(items[1].text, 'Beta')
// Scope a search to a subtree
const form = result.get('form')
assert(form.has('button[type="submit"]'))
assert.equal(form.count('input'), 2)
// Text content decodes entities
// <p><b>bold</b></p> → text === '<b>bold</b>'
assert.equal(result.get('p').text, '<b>bold</b>')
Example test file
/**
* src/pages/counter.test.js
* run: node src/pages/counter.test.js
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { renderSync } from '@invisibleloop/pulse/testing'
import spec from './counter.js'
test('renders the current count', () => {
const result = renderSync(spec, { state: { count: 7 } })
assert.equal(result.get('#count').text, '7')
})
test('increment mutation returns count + 1', () => {
const next = spec.mutations.increment({ count: 0 })
assert.equal(next.count, 1)
})
test('decrement mutation returns count - 1', () => {
const next = spec.mutations.decrement({ count: 5 })
assert.equal(next.count, 4)
})
test('view renders increment and decrement buttons', () => {
const result = renderSync(spec)
assert(result.has('[data-event="increment"]'))
assert(result.has('[data-event="decrement"]'))
})
Use
renderSync for mutations and pure view tests — it's synchronous and needs no await. Use render when your spec has server fetchers you want to exercise for integration coverage.