Domphy

Testing

Setup

Domphy UIs are plain objects — most logic can be unit-tested with Vitest without a browser:

npm install -D vitest jsdom
// vitest.config.ts
import { defineConfig } from "vitest/config"

export default defineConfig({
  test: {
    environment: "jsdom",
  },
})

Testing state

State is a plain class — no mocking required:

import { describe, expect, it } from "vitest"
import { toState, computed } from "@domphy/core"

describe("counter state", () => {
  it("increments", () => {
    const count = toState(0)
    count.set((n) => n + 1)
    expect(count.get()).toBe(1)
  })

  it("computed re-derives on dependency change", () => {
    const a = toState(2)
    const b = toState(3)
    const sum = computed(() => a.get() + b.get())

    expect(sum.get()).toBe(5)
    a.set(10)
    expect(sum.get()).toBe(13)
  })
})

Note: calling state.get() without a listener returns the current value without subscribing.

Testing reactive subscriptions

import { describe, expect, it, vi } from "vitest"
import { toState } from "@domphy/core"

describe("subscription", () => {
  it("notifies on change", () => {
    const name = toState("Alice")
    const callback = vi.fn()

    name.subscribe(callback)
    name.set("Bob")

    expect(callback).toHaveBeenCalledWith("Bob")
  })

  it("does not notify if value unchanged", () => {
    const count = toState(0)
    const callback = vi.fn()

    count.subscribe(callback)
    count.set(0)   // same value

    expect(callback).not.toHaveBeenCalled()
  })
})

Testing element trees

Mount an element tree into a real DOM node and assert the output:

import { describe, expect, it, beforeEach, afterEach } from "vitest"
import { toState } from "@domphy/core"
import { ElementNode } from "@domphy/core"

// Helper to mount a Domphy element and return the DOM node
function mount(element: unknown): HTMLElement {
  const container = document.createElement("div")
  const node = new ElementNode(element)
  container.appendChild(node.domElement)
  return container
}

describe("Counter component", () => {
  it("renders initial count", () => {
    const count = toState(0)
    const Counter = { span: (l) => String(count.get(l)) }
    const dom = mount(Counter)
    expect(dom.querySelector("span")?.textContent).toBe("0")
  })

  it("updates DOM when state changes", async () => {
    const count = toState(0)
    const Counter = { span: (l) => String(count.get(l)) }
    const dom = mount(Counter)

    count.set(5)
    await Promise.resolve()   // flush microtask queue

    expect(dom.querySelector("span")?.textContent).toBe("5")
  })
})

Testing user interactions

Simulate DOM events on mounted elements:

import { describe, expect, it } from "vitest"
import { toState } from "@domphy/core"
import { ElementNode } from "@domphy/core"

describe("input field", () => {
  it("updates state on input", async () => {
    const value = toState("")
    const Input = {
      input: null,
      type: "text",
      value: (l) => value.get(l),
      onInput: (e: Event) => value.set((e.target as HTMLInputElement).value),
    }

    const container = document.createElement("div")
    const node = new ElementNode(Input)
    container.appendChild(node.domElement)

    const input = container.querySelector("input") as HTMLInputElement
    input.value = "hello"
    input.dispatchEvent(new Event("input"))

    expect(value.get()).toBe("hello")
  })
})

Testing async state

import { describe, expect, it, vi } from "vitest"
import { toState } from "@domphy/core"

describe("async data loading", () => {
  it("transitions through loading → success", async () => {
    const state = toState<{ data: string | null; loading: boolean }>({
      data: null, loading: false,
    })

    const fetchData = vi.fn().mockResolvedValue("Hello")

    async function load() {
      state.set((s) => ({ ...s, loading: true }))
      const data = await fetchData()
      state.set({ data, loading: false })
    }

    const loadPromise = load()
    expect(state.get().loading).toBe(true)
    expect(state.get().data).toBe(null)

    await loadPromise
    expect(state.get().loading).toBe(false)
    expect(state.get().data).toBe("Hello")
  })
})

Testing patches

import { describe, expect, it } from "vitest"
import { ElementNode } from "@domphy/core"
import { tooltip } from "./tooltip.js"

describe("tooltip patch", () => {
  it("sets title attribute", () => {
    const el = { span: "hover me", $: [tooltip({ text: "A tooltip" })] }
    const node = new ElementNode(el)
    expect((node.domElement as HTMLSpanElement).title).toBe("A tooltip")
  })
})

Testing CSS output

Check that a Domphy element generates the expected CSS:

import { describe, expect, it } from "vitest"
import { ElementNode } from "@domphy/core"
import { button } from "@domphy/ui"

describe("button patch CSS", () => {
  it("generates height CSS variable", () => {
    const el = { button: "Click", $: [button()] }
    const node = new ElementNode(el)
    const css = node.generateCSS()
    expect(css).toContain("height")
  })
})

Testing @domphy/press pages

The press pipeline exports renderDoc which runs entirely in Node — no browser needed:

import { describe, expect, it } from "vitest"
import { renderDoc } from "@domphy/press"
import { tmpdir } from "node:os"
import { join } from "node:path"

describe("docs rendering", () => {
  it("extracts title from H1", async () => {
    const result = await renderDoc("# My Page\n\nContent.", {
      filePath: join(tmpdir(), "test.md"),
      docsDir: tmpdir(),
      repoRoot: tmpdir(),
      highlight: (code) => code,
    })
    expect(result.title).toBe("My Page")
  })
})

Test organization

src/
  components/
    Counter.ts
    Counter.test.ts     ← unit test for Counter
  state/
    cart.ts
    cart.test.ts        ← unit test for cart state
tests/
  integration/
    checkout.test.ts    ← end-to-end flow test

Keep state tests separate from component tests — state tests are the cheapest and fastest; run them first.