Testing
OpenTUI provides a headless test renderer that creates a real Zig native renderer with mock I/O. Tests exercise the full stack - TypeScript components, Yoga layout, FFI, Zig rendering - with no mocking of the native layer.
Test Renderer
import { createTestRenderer } from "@opentui/core/testing"
const { renderer, mockInput, mockMouse, renderOnce, captureCharFrame, resize } =
await createTestRenderer({ width: 80, height: 24 })
createTestRenderer() creates a real CliRenderer with:
- A
TestWriteStream(no-op write) instead of real stdout - Real Zig native renderer in test mode
- Configurable initial terminal size
Rendering and Capturing
import { Box, Text } from "@opentui/core"
// Build UI
renderer.root.add(
Box(
{ border: true, borderStyle: "rounded", padding: 1 },
Text({ content: "Hello" }),
),
)
// Execute one render cycle
await renderOnce()
// Capture the frame as a string
const frame = captureCharFrame()
console.log(frame)
// ╭─────────────────╮
// │ Hello │
// ╰─────────────────╯
captureCharFrame() returns the rendered terminal output as a string - what you would see on screen.
Structured Span Capture
For assertions beyond string matching:
const spans = captureSpans()
// Returns structured data: { spans: [...], cursor: { x, y, visible } }
// Each span has: text, fg, bg, attributes, position
Resize
resize(40, 12) // simulate terminal resize to 40x12
await renderOnce()
Mock Keyboard Input
import { createMockKeys } from "@opentui/core/testing"
const keys = createMockKeys(renderer)
// Single key
await keys.pressKey("a")
await keys.pressKey("escape")
await keys.pressKey("f1")
// With modifiers
await keys.pressKey("c", { ctrl: true }) // Ctrl+C
await keys.pressKey("s", { ctrl: true }) // Ctrl+S
await keys.pressKey("a", { shift: true }) // Shift+A
// Arrow keys
await keys.pressArrow("up")
await keys.pressArrow("down")
// Convenience methods
await keys.pressEnter()
await keys.pressEscape()
await keys.pressTab()
await keys.pressBackspace()
await keys.pressCtrlC()
// Type a string character by character
await keys.typeText("hello world")
// Sequence of keys with delay between
await keys.pressKeys(["h", "e", "l", "l", "o"], 10)
// Bracketed paste
await keys.pasteBracketedText("pasted content here")
Tip: Mock input generates real ANSI escape sequences that flow through the same stdin parsing path as production. This means your tests exercise the full input pipeline.
Mock Mouse Input
import { createMockMouse } from "@opentui/core/testing"
const mouse = createMockMouse(renderer)
// Click at coordinates
await mouse.click(10, 5) // left click
await mouse.click(10, 5, "right") // right click
await mouse.doubleClick(10, 5)
// Mouse movement
await mouse.moveTo(20, 10)
// Press and release (for drag)
await mouse.pressDown(5, 5)
await mouse.moveTo(15, 5) // becomes drag while button held
await mouse.release(15, 5)
// Drag helper (includes interpolated intermediate positions)
await mouse.drag(5, 5, 25, 5)
// Scroll
await mouse.scroll(10, 5, "up")
await mouse.scroll(10, 5, "down")
Test Recording
Record frames for visual regression or debugging:
import { TestRecorder } from "@opentui/core/testing"
const recorder = new TestRecorder(renderer)
recorder.rec() // start recording
// ... interact with the app ...
await renderOnce()
await keys.pressKey("a")
await renderOnce()
recorder.stop() // stop recording
// Access recorded frames
for (const frame of recorder.frames) {
console.log(`Frame ${frame.number} at ${frame.timestamp}:`)
console.log(frame.content)
}
Spy Functions
Minimal spy for asserting callbacks:
import { createSpy } from "@opentui/core/testing"
const spy = createSpy()
const input = Input({
onChange: spy,
})
// ... trigger input change ...
expect(spy.callCount).toBe(1)
spy.calledWith("expected value") // assertion
Framework-Specific Test Helpers
Solid
import { testRender } from "@opentui/solid"
const { renderer, renderOnce, captureCharFrame, mockInput, mockMouse } =
await testRender(() => (
<box style={{ padding: 1, border: true }}>
<text content="Hello from Solid" />
</box>
))
await renderOnce()
const frame = captureCharFrame()
React
import { testRender } from "@opentui/react"
const { renderer, renderOnce, captureCharFrame, mockInput, mockMouse } =
await testRender(
<box style={{ padding: 1, border: true }}>
<text content="Hello from React" />
</box>
)
await renderOnce()
const frame = captureCharFrame()
Writing Effective Tests
Pattern: Test Component Output
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Box, Text } from "@opentui/core"
test("renders bordered box with content", async () => {
const { renderer, renderOnce, captureCharFrame } =
await createTestRenderer({ width: 40, height: 10 })
renderer.root.add(
Box(
{ border: true, borderStyle: "single" },
Text({ content: "Hello" }),
),
)
await renderOnce()
const frame = captureCharFrame()
expect(frame).toContain("Hello")
expect(frame).toContain("┌") // border character
})
Pattern: Test Keyboard Interaction
import { test, expect } from "bun:test"
import { createTestRenderer, createMockKeys } from "@opentui/core/testing"
import { Input } from "@opentui/core"
test("input accepts typed text", async () => {
const { renderer, renderOnce, captureCharFrame } =
await createTestRenderer({ width: 40, height: 5 })
const input = Input({ id: "name", placeholder: "Name..." })
renderer.root.add(input)
input.focus()
await renderOnce()
const keys = createMockKeys(renderer)
await keys.typeText("Alice")
await renderOnce()
const frame = captureCharFrame()
expect(frame).toContain("Alice")
})
Pattern: Test Mouse Interaction
import { test, expect } from "bun:test"
import { createTestRenderer, createMockMouse, createSpy } from "@opentui/core/testing"
import { Box } from "@opentui/core"
test("box responds to click", async () => {
const spy = createSpy()
const { renderer, renderOnce } =
await createTestRenderer({ width: 40, height: 10 })
renderer.root.add(
Box({
id: "clickable",
width: 20,
height: 5,
onMouseDown: spy,
}),
)
await renderOnce()
const mouse = createMockMouse(renderer)
await mouse.click(5, 2)
expect(spy.callCount).toBe(1)
})
Gotcha: Always call
await renderOnce()after adding components and before capturing frames or interacting. The first render establishes layout positions, which mouse events need.
Running Tests
cd packages/core
bun test # all TypeScript tests
bun test src/renderables/Text.test.ts # specific test file
cd packages/solid
bun test # Solid integration tests
cd packages/react
bun test # React integration tests