Testing
Silvery ships with a Playwright-style testing API that lets you write fast, deterministic tests without a real terminal. The headless renderer captures everything your app would display, and locator methods let you query the output just like you'd query a web page.
Quick Start
import { createRenderer } from "@silvery/test"
import { expect, test } from "vitest"
const render = createRenderer({ cols: 80, rows: 24 })
test("counter increments on key press", async () => {
const app = render(<Counter />)
expect(app.text).toContain("Count: 0")
await app.press("j")
expect(app.text).toContain("Count: 1")
await app.press("k")
expect(app.text).toContain("Count: 0")
})Key Benefits
- No real terminal needed — Tests run headlessly, no TTY setup, no flakiness from terminal state
- Playwright-style API —
press(),getByText(),getByTestId(),locator()— familiar patterns from web testing - Deterministic — No timing issues, no animation waits. Each
press()processes the full React update cycle - Snapshot testing — Capture the rendered buffer for visual regression testing
- Fast — Tests run in milliseconds, not seconds
The Testing API
createRenderer(options)
Creates a virtual terminal and renderer:
const render = createRenderer({
cols: 80, // Terminal width
rows: 24, // Terminal height
})
const app = render(<MyApp />)app.text
The full text content of the rendered output:
expect(app.text).toContain("Hello, world!")
expect(app.text).not.toContain("Error")app.press(key)
Simulate keyboard input. Supports single keys, modifiers, and special keys:
await app.press("j") // Single character
await app.press("Enter") // Special key
await app.press("Ctrl+K") // Modifier + key
await app.press("ArrowDown") // Arrow key
await app.press("Escape") // Escapeapp.getByText(text)
Find elements containing specific text:
const heading = app.getByText("Dashboard")
expect(heading).toExist()app.getByTestId(id)
Find elements by testID prop:
// In your component:
;<Box testID="status-bar">
<Text>Ready</Text>
</Box>
// In your test:
const statusBar = app.getByTestId("status-bar")
expect(statusBar.text).toBe("Ready")app.locator(selector)
CSS-like locator for complex queries:
const items = app.locator("[testID=list-item]")
expect(items.count()).toBe(5)Testing Patterns
Testing Keyboard Navigation
test("list navigates with arrow keys", async () => {
const app = render(<TaskList items={tasks} />)
// First item selected by default
expect(app.text).toContain("> Task 1")
// Navigate down
await app.press("ArrowDown")
expect(app.text).toContain("> Task 2")
// Navigate to end
await app.press("End")
expect(app.text).toContain("> Task 5")
})Testing Text Input
test("search filters results", async () => {
const app = render(<SearchableList items={allItems} />)
// Type a search query
await app.press("r")
await app.press("e")
await app.press("a")
await app.press("c")
await app.press("t")
expect(app.text).toContain("React")
expect(app.text).not.toContain("Vue")
})Testing Multi-Step Wizards
test("wizard completes all steps", async () => {
const app = render(<Wizard />)
// Step 1: Select framework
expect(app.text).toContain("Choose a framework")
await app.press("ArrowDown") // Select React
await app.press("Enter")
// Step 2: Enter name
expect(app.text).toContain("Project name")
await app.press("m")
await app.press("y")
await app.press("-")
await app.press("a")
await app.press("p")
await app.press("p")
await app.press("Enter")
// Step 3: Done
expect(app.text).toContain("Done!")
expect(app.text).toContain("my-app")
})Snapshot Testing
Capture the full rendered buffer for regression testing:
test("dashboard layout matches snapshot", async () => {
const app = render(<Dashboard />)
expect(app.text).toMatchSnapshot()
})Testing Scrolling
test("scroll follows selection", async () => {
const app = render(<List items={hundredItems} />)
// Scroll past the visible area
for (let i = 0; i < 30; i++) {
await app.press("ArrowDown")
}
// Item 30 should be visible
expect(app.text).toContain("Item 30")
// Item 1 should have scrolled out of view
expect(app.text).not.toContain("Item 1")
})Features Used
| Feature | Usage |
|---|---|
createRenderer() | Virtual terminal for headless testing |
app.text | Full rendered text content |
app.press() | Keyboard input simulation |
app.getByText() | Find elements by text content |
app.getByTestId() | Find elements by testID prop |
app.locator() | CSS-like element queries |
| Snapshot testing | Visual regression via toMatchSnapshot() |
Full ANSI Testing with termless
createRenderer() strips ANSI and gives you plain text — fast and simple for most tests. When you need to verify actual terminal output (box drawing characters, colors, cursor positioning), use createTerm() with a termless emulator:
import { createXtermBackend } from "@termless/xtermjs"
import "@termless/test/matchers"
import { createTerm } from "@silvery/term"
import { run } from "@silvery/term/runtime"
test("renders box borders correctly", async () => {
using term = createTerm(createXtermBackend(), { cols: 40, rows: 10 })
const handle = await run(<MyApp />, term)
// termless assertions — full ANSI fidelity
expect(term.screen).toContainText("Hello")
expect(term.screen.getText()).toContain("╭") // box drawing characters
// Interaction via handle.press()
await handle.press("j")
expect(term.screen).toContainText("Count: 1")
handle.unmount()
})createTerm(backend, dims) creates a silvery Term backed by a real terminal emulator. When passed to run(), it auto-wires headless mode and routes all ANSI output through the render pipeline to the emulator. Input comes from handle.press(). This gives you:
- Real ANSI output — borders, colors, cursor movement, everything the user sees
- In-process — no PTY subprocess, no timing issues, millisecond-fast
- termless assertions —
toContainText(), region selectors, scrollback inspection - One object —
term.screenandterm.scrollbackfor assertions, cleanup viausing
Use createRenderer() for most tests. Use createTerm(emulator) when ANSI fidelity matters.
Best Practices
- Test behavior, not implementation — Assert on what the user sees (
app.text), not internal state - Use
testIDfor stability — Text content can change;testIDprops are stable identifiers - One assertion per press — Verify the state after each key press for clear failure messages
- Test at the right layer — Use
createRendererfor component integration tests, unit tests for pure logic
Exercises
- Write tests for a todo list — Test add, toggle, delete, and filter operations
- Write tests for a form wizard — Test all steps including validation errors
- Add snapshot tests — Capture and verify the visual output of a dashboard
- Test scrolling behavior — Verify overflow indicators and scroll-to-selection