Recipes
Real-world testing patterns for common scenarios.
Testing a Go TUI App (Bubbletea)
Spawn the compiled binary and interact via PTY:
import { test, expect } from "vitest"
import { createTestTerminal } from "@termless/test"
test("bubbletea app navigates list", async () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
await term.spawn(["./my-bubbletea-app"])
await expect(term.screen).toContainText("Welcome", { timeout: 5000 })
term.press("ArrowDown")
term.press("ArrowDown")
term.press("Enter")
await expect(term.screen).toContainText("Selected: item 3", { timeout: 5000 })
term.press("q")
await expect(term).not.toBeInMode("altScreen", { timeout: 3000 })
})Testing a Rust TUI App (Ratatui)
Same pattern -- spawn the release binary and drive it with keypresses:
test("ratatui dashboard renders", async () => {
const term = createTestTerminal({ cols: 120, rows: 40 })
await term.spawn(["cargo", "run", "--release"])
await expect(term.screen).toContainText("Dashboard", { timeout: 15000 })
expect(term).toBeInMode("altScreen")
// Navigate tabs
term.press("Tab")
await expect(term.screen).toContainText("Settings", { timeout: 5000 })
})Build time
Rust builds can be slow. Use cargo build --release in a setup step and spawn the binary directly to avoid rebuilding on every test run.
Testing a Python TUI App (Textual)
Spawn with python -m:
test("textual app shows form", async () => {
const term = createTestTerminal({ cols: 100, rows: 30 })
await term.spawn(["python", "-m", "myapp"])
await expect(term.screen).toContainText("Submit", { timeout: 10000 })
// Tab to input field, type text
term.press("Tab")
term.type("hello@example.com")
await expect(term.screen).toContainText("hello@example.com", { timeout: 3000 })
})Testing a React TUI App (Silvery)
Silvery provides its own test integration that renders components directly into a Termless terminal -- no process spawning needed:
import { test, expect } from "vitest"
import { createTermless } from "@silvery/test"
import { run } from "@silvery/ag-term"
import { App } from "./App.tsx"
test("app renders dashboard", async () => {
using term = createTermless({ cols: 80, rows: 24 })
await run(<App />, term)
expect(term.screen).toContainText("Dashboard")
expect(term.cell(0, 0)).toBeBold()
})This is in-process and deterministic -- no PTY, no timing issues.
Testing Color Themes
Use term.cell(r, c) to verify foreground and background colors:
test("error messages render in red", () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
term.feed("\x1b[38;2;255;0;0mError:\x1b[0m file not found")
expect(term.cell(0, 0)).toHaveFg("#ff0000")
expect(term.cell(0, 0)).not.toBeBold()
})
test("dark theme background", () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
term.feed("\x1b[48;2;30;30;30m\x1b[38;2;200;200;200m Dark Mode \x1b[0m")
expect(term.cell(0, 1)).toHaveBg({ r: 30, g: 30, b: 30 })
expect(term.cell(0, 1)).toHaveFg({ r: 200, g: 200, b: 200 })
})Testing Responsive Layouts
Resize the terminal and re-assert:
test("layout adapts to narrow terminal", () => {
const term = createTestTerminal({ cols: 120, rows: 40 })
term.feed("Wide layout: sidebar | content")
expect(term.screen).toContainText("sidebar | content")
// Resize to narrow
term.resize(40, 24)
term.feed("\x1b[2J\x1b[H") // Clear screen (simulate app redraw)
term.feed("Narrow layout:\nsidebar\ncontent")
expect(term.row(1)).toContainText("sidebar")
expect(term.row(2)).toContainText("content")
})For PTY apps that respond to SIGWINCH:
test("app reflows on resize", async () => {
const term = createTestTerminal({ cols: 120, rows: 40 })
await term.spawn(["./my-tui-app"])
await expect(term.screen).toContainText("wide mode", { timeout: 5000 })
term.resize(40, 24)
await expect(term.screen).toContainText("compact mode", { timeout: 5000 })
})CI Integration
Termless runs headless with no display dependencies. Add it to any CI pipeline:
name: Terminal Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
- run: bun vitest run
# No display needed -- Termless runs headlessterminal-tests:
image: oven/bun:latest
script:
- bun install
- bun vitest runNo xvfb, no DISPLAY variable, no Docker-in-Docker. Tests run anywhere Bun or Node.js runs.
Screenshot Regression Testing
Capture terminal state as SVG snapshots for visual regression testing:
import { terminalSerializer, terminalSnapshot } from "@termless/test"
expect.addSnapshotSerializer(terminalSerializer)
test("renders header correctly", () => {
const term = createTestTerminal({ cols: 60, rows: 10 })
term.feed("\x1b[1mMy App\x1b[0m v2.1.0\r\n\x1b[2m" + "─".repeat(60) + "\x1b[0m")
expect(terminalSnapshot(term)).toMatchSnapshot()
})Or save SVG files for manual review:
import { writeFileSync } from "node:fs"
test("generate screenshot", () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
term.feed("Hello, World!")
const svg = term.screenshotSvg()
writeFileSync("test-output/hello.svg", svg)
})Testing Scrollback
Verify content that has scrolled off-screen:
test("scrollback preserves history", () => {
const term = createTestTerminal({ cols: 80, rows: 5 })
// Feed more lines than the screen can hold
for (let i = 0; i < 20; i++) {
term.feed(`Line ${i}\r\n`)
}
// Screen shows the last 5 lines
expect(term.screen).toContainText("Line 19")
expect(term.screen).not.toContainText("Line 0")
// Scrollback has the history
expect(term.scrollback).toContainText("Line 0")
// Buffer has everything
expect(term.buffer).toContainText("Line 0")
expect(term.buffer).toContainText("Line 19")
})Testing Cursor State
Assert on cursor position, visibility, and style:
test("cursor follows input", () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
term.feed("Hello")
expect(term).toHaveCursorAt(5, 0)
term.feed("\r\nWorld")
expect(term).toHaveCursorAt(5, 1)
})
test("app hides cursor", async () => {
const term = createTestTerminal({ cols: 80, rows: 24 })
await term.spawn(["./my-tui-app"])
await expect(term).toHaveCursorHidden({ timeout: 5000 })
})