GitHub

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.

OptionTypeDefaultDescription
stateobject{}State overrides merged with spec.state.
serverobject{}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 server to use that data directly. Fetchers are not called. Fast and deterministic.
  • Integration mode — omit server and real spec.server fetchers run. Pass ctx to set params, cookies, headers, etc.
OptionTypeDefaultDescription
stateobject{}State overrides merged with spec.state.
serverobjectundefinedServer state passed directly to the view. When set, fetchers are skipped entirely.
ctxobject{}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 / methodReturnsDescription
.htmlstringRaw HTML string from the view.
.stateobjectClient state used for rendering.
.serverobjectServer state used for rendering.
.text()stringAll text content — tags stripped, entities decoded, whitespace collapsed.
.has(selector)booleanTrue if any element matches selector.
.find(selector)Element | nullFirst matching element, or null.
.get(selector)ElementFirst matching element. Throws with a clear message if not found.
.findAll(selector)Element[]All matching elements.
.count(selector)numberNumber of matching elements.
.attr(selector, name)string | nullAttribute 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 / methodReturnsDescription
.tagstringTag name (lowercase).
.textstringAll text content within the element, whitespace-collapsed.
.attrsobjectParsed attribute map. Boolean attrs (e.g. disabled) have value true.
.attr(name)string | nullGet one attribute. Returns "" for boolean attrs, null if absent — mirrors getAttribute().
.find(selector)Element | nullFirst matching descendant.
.findAll(selector)Element[]All matching descendants.
.has(selector)booleanTrue 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.

SelectorExampleMatches
TagbuttonAny <button>
Class.ui-btnElements with that class
ID#submitElement with that id
Attribute present[disabled]Elements with a disabled attribute
Attribute value[type="submit"]Elements where type equals submit
Compoundbutton.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>&lt;b&gt;bold&lt;/b&gt;</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.