Testing

Test @visulima/tui components with mock streams, frame capture, and input simulation — no real terminal required

Testing

@visulima/tui/test provides a framework-agnostic testing utility for TUI components. It renders components into mock stdout/stderr/stdin streams so you can assert on output without a real terminal.

Inspired by ink-testing-library.

Install

The testing utilities are included in the main package — no extra install needed:

npm install @visulima/tui react

Quick Start

import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

describe("my component", () => {
    it("renders greeting", () => {
        const { lastFrame } = render(<Text>Hello World</Text>);

        expect(lastFrame()).toBe("Hello World");
    });
});

API

render(node, options?)

Renders a React element for testing with mock streams. The component renders synchronously into an in-memory buffer — no real terminal is involved.

import { render } from "@visulima/tui/test";

const instance = render(<MyComponent />);

Parameters:

ParameterTypeDescription
nodeReactElementThe React element to render
options.columnsnumberTerminal column width for mock streams (default: 100)
options.optionsRenderOptionsAdditional render options passed to the renderer

Returns: TestInstance

PropertyTypeDescription
lastFrame() => string | undefinedMost recently rendered stdout frame
framesreadonly string[]All captured stdout frames
rerender(node: ReactElement) => voidReplace the rendered component
unmount() => voidUnmount the component
cleanup() => voidUnmount and clean up resources
stdoutTestInstanceStdoutMock stdout with lastFrame(), frames, write()
stderrTestInstanceStderrMock stderr with lastFrame(), frames, write()
stdinTestInstanceStdinMock stdin with write() for simulating input

cleanup()

Unmounts and cleans up all rendered test instances. Call this in your test teardown to prevent leaks between tests.

import { cleanup } from "@visulima/tui/test";

afterEach(() => {
    cleanup();
});

Examples

Rendering a component

The simplest use case — render a component and check its output:

import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Box, Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

it("renders a greeting", () => {
    const { lastFrame } = render(<Text>Hello World</Text>);

    expect(lastFrame()).toBe("Hello World");
});

it("renders nested layout", () => {
    const { lastFrame } = render(
        <Box flexDirection="column">
            <Text>Line 1</Text>
            <Text>Line 2</Text>
        </Box>,
    );

    expect(lastFrame()).toContain("Line 1");
    expect(lastFrame()).toContain("Line 2");
});

Rerendering with new props

Use rerender() to update the component with new props and verify the output changes:

import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

const Counter = ({ count }: { count: number }) => <Text>Count: {count}</Text>;

it("updates on rerender", () => {
    const { lastFrame, rerender } = render(<Counter count={0} />);

    expect(lastFrame()).toBe("Count: 0");

    rerender(<Counter count={1} />);

    expect(lastFrame()).toBe("Count: 1");

    rerender(<Counter count={42} />);

    expect(lastFrame()).toBe("Count: 42");
});

Inspecting render history with frames

Every render produces a frame. Use frames to inspect the full render history:

import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

it("tracks all rendered frames", () => {
    const { frames, rerender } = render(<Text>Frame 1</Text>);

    rerender(<Text>Frame 2</Text>);
    rerender(<Text>Frame 3</Text>);

    expect(frames[0]).toBe("Frame 1");
    expect(frames.at(-1)).toBe("Frame 3");
    expect(frames.length).toBeGreaterThanOrEqual(3);
});

Simulating keyboard input

Use stdin.write() to simulate user input. This works with components that use useInput:

import React, { useState } from "react";
import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text, useInput } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

const InputEcho = () => {
    const [text, setText] = useState("");

    useInput((input) => {
        setText((prev) => prev + input);
    });

    return <Text>{text || "Type something..."}</Text>;
};

it("receives keyboard input", () => {
    const { stdin, lastFrame } = render(<InputEcho />);

    expect(lastFrame()).toBe("Type something...");

    stdin.write("h");
    stdin.write("i");

    expect(lastFrame()).toBe("hi");
});

Simulating special keys

For special keys (arrows, enter, escape), write the corresponding ANSI escape sequences:

import React, { useState } from "react";
import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text, useInput } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

const Menu = () => {
    const [selected, setSelected] = useState(0);
    const items = ["Apple", "Banana", "Cherry"];

    useInput((_input, key) => {
        if (key.downArrow) {
            setSelected((prev) => Math.min(prev + 1, items.length - 1));
        }
        if (key.upArrow) {
            setSelected((prev) => Math.max(prev - 1, 0));
        }
    });

    return <Text>{items[selected]}</Text>;
};

it("navigates with arrow keys", () => {
    const { stdin, lastFrame } = render(<Menu />);

    expect(lastFrame()).toBe("Apple");

    // Down arrow: ESC [ B
    stdin.write("\u001B[B");

    expect(lastFrame()).toBe("Banana");

    // Down arrow again
    stdin.write("\u001B[B");

    expect(lastFrame()).toBe("Cherry");

    // Up arrow: ESC [ A
    stdin.write("\u001B[A");

    expect(lastFrame()).toBe("Banana");
});

Testing components that exit

Components that call useApp().exit() can be tested by awaiting waitUntilExit on the underlying instance:

import React, { useEffect } from "react";
import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text, useApp } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

const AutoExit = ({ message }: { message: string }) => {
    const { exit } = useApp();

    useEffect(() => {
        exit();
    }, [exit]);

    return <Text>{message}</Text>;
};

it("renders and exits", () => {
    const { lastFrame } = render(<AutoExit message="Done!" />);

    expect(lastFrame()).toContain("Done!");
});

Capturing stderr output

Components that write to stderr (via useStderr) have their output captured in a separate stream:

import React, { useEffect } from "react";
import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text, useStderr } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

const Logger = () => {
    const { write } = useStderr();

    useEffect(() => {
        write("Warning: something happened");
    }, [write]);

    return <Text>All good</Text>;
};

it("captures stderr separately", () => {
    const { lastFrame, stderr } = render(<Logger />);

    expect(lastFrame()).toBe("All good");
    expect(stderr.lastFrame()).toBe("Warning: something happened");
});

Stripping ANSI codes for assertions

When components use colors or styles, the output contains ANSI escape sequences. Use @visulima/ansi (or any ANSI strip utility) for clean assertions:

import { afterEach, expect, it } from "vitest";
import { strip } from "@visulima/ansi";
import { cleanup, render } from "@visulima/tui/test";
import { Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

it("renders colored text", () => {
    const { lastFrame } = render(<Text color="green">Success</Text>);

    // Raw output contains ANSI codes
    expect(lastFrame()).toContain("Success");

    // Strip ANSI for exact matching
    expect(strip(lastFrame()!)).toBe("Success");
});

Custom terminal width

Pass columns to control the virtual terminal width. This affects text wrapping and layout:

import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Box, Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

it("wraps text in narrow terminal", () => {
    const { lastFrame } = render(
        <Box width={20}>
            <Text>This is a long line that should wrap</Text>
        </Box>,
        { columns: 20 },
    );

    const output = lastFrame()!;
    const lines = output.split("\n");

    expect(lines.length).toBeGreaterThan(1);
});

Testing multiple independent instances

Each render() call creates an isolated instance with its own streams:

import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

it("renders multiple isolated instances", () => {
    const a = render(<Text>Instance A</Text>);
    const b = render(<Text>Instance B</Text>);

    expect(a.lastFrame()).toBe("Instance A");
    expect(b.lastFrame()).toBe("Instance B");

    // Rerendering one doesn't affect the other
    a.rerender(<Text>Updated A</Text>);

    expect(a.lastFrame()).toBe("Updated A");
    expect(b.lastFrame()).toBe("Instance B");
});

Unmounting and lifecycle

Use unmount() for a single instance or cleanup() for all instances:

import { afterEach, expect, it } from "vitest";
import { cleanup, render } from "@visulima/tui/test";
import { Text } from "@visulima/tui";

afterEach(() => {
    cleanup();
});

it("unmounts a single instance", () => {
    const { lastFrame, unmount } = render(<Text>Hello</Text>);

    expect(lastFrame()).toBe("Hello");

    unmount();

    // After unmount, the renderer writes a final empty frame
    expect(lastFrame()).toBe("\n");
});

it("cleanup unmounts all instances", () => {
    render(<Text>One</Text>);
    render(<Text>Two</Text>);
    render(<Text>Three</Text>);

    // cleanup() unmounts all three at once
    cleanup();
});

Common ANSI Escape Sequences for stdin

When simulating special keys via stdin.write(), use these escape sequences:

KeyEscape sequenceCode
Enter\rCarriage return
Escape\u001BESC
Up arrow\u001B[AESC [ A
Down arrow\u001B[BESC [ B
Right arrow\u001B[CESC [ C
Left arrow\u001B[DESC [ D
Tab\tHT
Backspace\u007FDEL
Delete\u001B[3~ESC [ 3 ~
Space Space
Ctrl+C\u0003ETX

Works With Any Test Runner

The testing utilities use plain EventEmitter-based mock streams with no test framework dependencies. They work with Vitest, Jest, Node.js built-in test runner, or any other framework:

// Vitest
import { afterEach } from "vitest";
import { cleanup } from "@visulima/tui/test";
afterEach(() => cleanup());

// Jest
afterEach(() => {
    const { cleanup } = require("@visulima/tui/test");
    cleanup();
});

// Node.js test runner
import { afterEach } from "node:test";
import { cleanup } from "@visulima/tui/test";
afterEach(() => cleanup());

Tips

  • Always call cleanup() in afterEach to avoid state leaks between tests
  • Use lastFrame() for simple assertions on the current output
  • Use frames to verify render history or animation sequences
  • The stdout and stderr objects expose the same lastFrame() and frames API independently
  • stdin.write() emits both readable and data events on the mock stream
  • Use ANSI stripping (e.g., @visulima/ansi) when asserting on styled output
  • The default terminal width is 100 columns — pass { columns } to test responsive layouts
  • render() uses debug: true internally, so each render produces a separate frame instead of overwriting
Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues