Testing OpenTUI Applications
How to test terminal user interfaces built with OpenTUI.
Overview
OpenTUI provides:
- Test Renderer: Headless renderer for testing
- Snapshot Testing: Verify visual output
- Interaction Testing: Simulate user input
When to Use
Use this reference when you need snapshot tests, interaction testing, or renderer-based regression checks.
Test Setup
Bun Test Runner
OpenTUI uses Bun's built-in test runner:
import { test, expect, beforeEach, afterEach } from "bun:test"Test Renderer
Create a test renderer for headless testing:
import { createTestRenderer } from "@opentui/core/testing"
const testSetup = await createTestRenderer({
width: 80, // Terminal width
height: 24, // Terminal height
})Core Testing
Basic Test
import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { TextRenderable } from "@opentui/core"
test("renders text", async () => {
const testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const text = new TextRenderable(testSetup.renderer, {
id: "greeting",
content: "Hello, World!",
})
testSetup.renderer.root.add(text)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Hello, World!")
})Snapshot Testing
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { BoxRenderable, TextRenderable } from "@opentui/core"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const box = new BoxRenderable(testSetup.renderer, {
id: "box",
border: true,
width: 20,
height: 5,
})
box.add(new TextRenderable(testSetup.renderer, {
content: "Content",
}))
testSetup.renderer.root.add(box)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toMatchSnapshot()
})Test Renderer Setup Object
createTestRenderer() returns an expanded TestRendererSetup with helpers for
driving frames, input, and asserting on output:
| Property | Type | Description |
|---|---|---|
renderer |
TestRenderer |
Headless renderer instance |
mockInput |
MockInput |
Simulate keyboard input |
mockMouse |
MockMouse |
Simulate mouse events |
renderOnce() |
() => Promise<void> |
Trigger a single render cycle |
flush(options?) |
(o?: { maxPasses? }) => Promise<void> |
Drain scheduled render work (default 20 passes) |
waitFor(predicate, options?) |
Promise<void> |
Advance frames until a boolean/async predicate passes |
waitForFrame(predicate, options?) |
Promise<string> |
Like waitFor, predicate receives the frame string; resolves to it |
waitForVisualIdle(options?) |
(o?: { quietFrames?, maxFrames? }) => Promise<void> |
Wait until rendering settles (defaults 1 / 20) |
captureCharFrame() |
() => string |
Capture output as text |
captureSpans() |
() => CapturedFrame |
Capture styled spans |
externalOutput |
TestExternalOutput |
Capture scrollback / external output (take(), takeText(), clear()) |
getNativeStats() |
() => NativeRenderStats |
Native render statistics |
resize(width, height) |
(w, h) => void |
Resize the virtual terminal |
import { createTestRenderer } from "@opentui/core/testing"
const t = await createTestRenderer({ width: 40, height: 10 })
// Wait for async content instead of guessing at renderOnce() timing
await t.waitForFrame((frame) => frame.includes("Loaded"))
// Or wait until the UI stops changing
await t.waitForVisualIdle()
// Assert on scrollback written above the viewport
const commits = t.externalOutput.take()TestRendererOptions extends CliRendererConfig with width, height,
kittyKeyboard, and otherModifiersMode. Other exports from
@opentui/core/testing: createMockKeys, KeyCodes, createMockMouse,
MouseButtons, createSpy, TestRecorder, ManualClock,
createTerminalCapabilities, setRendererCapabilities,
MockTreeSitterClient.
React Testing
Test Utilities
React provides a built-in testRender utility via the @opentui/react/test-utils subpath export:
import { testRender } from "@opentui/react/test-utils"This utility:
- Creates a headless test renderer
- Sets up the React Act environment automatically
- Handles proper unmounting on destroy
- Returns the standard test setup object
Basic Component Test
import { test, expect } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
function Greeting({ name }: { name: string }) {
return <text>Hello, {name}!</text>
}
test("Greeting renders name", async () => {
const testSetup = await testRender(
<Greeting name="World" />,
{ width: 80, height: 24 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Hello, World!")
})Snapshot Testing
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await testRender(
<box style={{ width: 20, height: 5, border: true }}>
<text>Content</text>
</box>,
{ width: 25, height: 8 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})State Testing
import { test, expect, afterEach } from "bun:test"
import { useState } from "react"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
function Counter() {
const [count, setCount] = useState(0)
return (
<box>
<text>Count: {count}</text>
</box>
)
}
test("Counter shows initial value", async () => {
testSetup = await testRender(
<Counter />,
{ width: 20, height: 5 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Count: 0")
})Test Setup/Teardown Pattern
For multiple tests, use beforeEach/afterEach to manage the renderer lifecycle:
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
describe("MyComponent", () => {
beforeEach(async () => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("renders correctly", async () => {
testSetup = await testRender(<MyComponent />, {
width: 40,
height: 10,
})
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})
})Test Setup Return Object
The testRender function returns a test setup object with these properties:
| Property | Type | Description |
|---|---|---|
renderer |
Renderer |
The headless renderer instance |
renderOnce |
() => Promise<void> |
Triggers a single render cycle |
captureCharFrame |
() => string |
Captures current output as text |
resize |
(width, height) => void |
Resize the virtual terminal |
Solid Testing
Test Utilities
Solid exports testRender directly from the main package:
import { testRender } from "@opentui/solid"Note: Unlike React, Solid's testRender takes a function component (not a JSX element).
Basic Component Test
import { test, expect } from "bun:test"
import { testRender } from "@opentui/solid"
function Greeting(props: { name: string }) {
return <text>Hello, {props.name}!</text>
}
test("Greeting renders name", async () => {
const testSetup = await testRender(
() => <Greeting name="World" />,
{ width: 80, height: 24 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Hello, World!")
})Snapshot Testing
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/solid"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("component matches snapshot", async () => {
testSetup = await testRender(
() => (
<box style={{ width: 20, height: 5, border: true }}>
<text>Content</text>
</box>
),
{ width: 25, height: 8 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toMatchSnapshot()
})Snapshot Format
Snapshots capture the rendered terminal output as text:
┌──────────────────┐
│ Hello, World! │
│ │
└──────────────────┘Updating Snapshots
bun test --update-snapshotsInteraction Testing
Simulating Key Presses
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("responds to keyboard", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
// Create component that responds to keys
// ...
// Simulate keypress
testSetup.renderer.keyInput.emit("keypress", {
name: "enter",
sequence: "\r",
ctrl: false,
shift: false,
meta: false,
option: false,
eventType: "press",
repeated: false,
})
// Render after the keypress
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Selected")
})Testing Focus
import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { InputRenderable } from "@opentui/core"
let testSetup: Awaited<ReturnType<typeof createTestRenderer>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("input receives focus", async () => {
testSetup = await createTestRenderer({
width: 40,
height: 10,
})
const input = new InputRenderable(testSetup.renderer, {
id: "test-input",
placeholder: "Type here",
})
testSetup.renderer.root.add(input)
input.focus()
expect(input.focused).toBe(true)
})Test Organization
File Structure
src/
├── components/
│ ├── Button.tsx
│ └── Button.test.tsx
├── hooks/
│ ├── useCounter.ts
│ └── useCounter.test.ts
└── test-utils.tsxRunning Tests
# Run all tests
bun test
# Run specific test file
bun test src/components/Button.test.tsx
# Run with filter
bun test --test-name-pattern "Button"
# Watch mode
bun test --watchPatterns
Testing Conditional Rendering (React)
import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("shows loading state", async () => {
testSetup = await testRender(
<DataLoader loading={true} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toContain("Loading...")
})
test("shows data when loaded", async () => {
testSetup = await testRender(
<DataLoader loading={false} data={["Item 1", "Item 2"]} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
expect(frame).toContain("Item 1")
expect(frame).toContain("Item 2")
})Testing Lists
test("renders all items", async () => {
const items = ["Apple", "Banana", "Cherry"]
testSetup = await testRender(
<ItemList items={items} />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
items.forEach(item => {
expect(frame).toContain(item)
})
})Testing Layouts
test("matches layout snapshot", async () => {
testSetup = await testRender(
<AppLayout />,
{ width: 120, height: 40 } // Larger viewport
)
await testSetup.renderOnce()
expect(testSetup.captureCharFrame()).toMatchSnapshot()
})Debugging Tests
Print Frame Output
import { testRender } from "@opentui/react/test-utils"
test("debug output", async () => {
const testSetup = await testRender(
<MyComponent />,
{ width: 40, height: 10 }
)
await testSetup.renderOnce()
const frame = testSetup.captureCharFrame()
// Print to see what's rendered
console.log(frame)
expect(frame).toContain("expected")
})Verbose Mode
bun test --verboseGotchas
Async Rendering
Always call renderOnce() after setting up your component to ensure rendering is complete:
const testSetup = await testRender(<MyComponent />, { width: 40, height: 10 })
await testSetup.renderOnce() // Required before capturing frame
const frame = testSetup.captureCharFrame()Test Isolation and Cleanup
Always destroy the renderer after each test to avoid resource leaks:
import { afterEach } from "bun:test"
let testSetup: Awaited<ReturnType<typeof testRender>>
afterEach(() => {
if (testSetup) {
testSetup.renderer.destroy()
}
})
test("test 1", async () => {
testSetup = await testRender(<Component1 />, { width: 40, height: 10 })
// ...
})
test("test 2", async () => {
testSetup = await testRender(<Component2 />, { width: 40, height: 10 })
// ...
})Snapshot Dimensions
Be consistent with test dimensions for stable snapshots:
const testSetup = await createTestRenderer({
width: 80, // Standard width
height: 24, // Standard height
})Running from Package Directory
Run tests from the package directory:
cd packages/core
bun test
# Not from repo root for package-specific testsSee Also
- Core API -
createTestRendererand renderable classes - React Configuration - React test setup
- Solid Configuration - Solid test setup
- Keyboard - Simulating key events in tests