608 lines
12 KiB
Markdown
608 lines
12 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
import { test, expect, beforeEach, afterEach } from "bun:test"
|
|
```
|
|
|
|
### Test Renderer
|
|
|
|
Create a test renderer for headless testing:
|
|
|
|
```typescript
|
|
import { createTestRenderer } from "@opentui/core/testing"
|
|
|
|
const testSetup = await createTestRenderer({
|
|
width: 80, // Terminal width
|
|
height: 24, // Terminal height
|
|
})
|
|
```
|
|
|
|
## Core Testing
|
|
|
|
### Basic Test
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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()
|
|
})
|
|
```
|
|
|
|
## React Testing
|
|
|
|
### Test Utilities
|
|
|
|
React provides a built-in `testRender` utility via the `@opentui/react/test-utils` subpath export:
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
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:
|
|
|
|
```tsx
|
|
import { testRender } from "@opentui/solid"
|
|
```
|
|
|
|
Note: Unlike React, Solid's `testRender` takes a **function component** (not a JSX element).
|
|
|
|
### Basic Component Test
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```bash
|
|
bun test --update-snapshots
|
|
```
|
|
|
|
## Interaction Testing
|
|
|
|
### Simulating Key Presses
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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.isFocused()).toBe(true)
|
|
})
|
|
```
|
|
|
|
## Test Organization
|
|
|
|
### File Structure
|
|
|
|
```
|
|
src/
|
|
├── components/
|
|
│ ├── Button.tsx
|
|
│ └── Button.test.tsx
|
|
├── hooks/
|
|
│ ├── useCounter.ts
|
|
│ └── useCounter.test.ts
|
|
└── test-utils.tsx
|
|
```
|
|
|
|
### Running Tests
|
|
|
|
```bash
|
|
# Run all tests
|
|
bun test
|
|
|
|
# Run specific test file
|
|
bun test src/components/Button.test.tsx
|
|
|
|
# Run with filter
|
|
bun test --filter "Button"
|
|
|
|
# Watch mode
|
|
bun test --watch
|
|
```
|
|
|
|
## Patterns
|
|
|
|
### Testing Conditional Rendering (React)
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```bash
|
|
bun test --verbose
|
|
```
|
|
|
|
## Gotchas
|
|
|
|
### Async Rendering
|
|
|
|
Always call `renderOnce()` after setting up your component to ensure rendering is complete:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
const testSetup = await createTestRenderer({
|
|
width: 80, // Standard width
|
|
height: 24, // Standard height
|
|
})
|
|
```
|
|
|
|
### Running from Package Directory
|
|
|
|
Run tests from the package directory:
|
|
|
|
```bash
|
|
cd packages/core
|
|
bun test
|
|
|
|
# Not from repo root for package-specific tests
|
|
```
|