| title | Testing |
|---|---|
| nav_order | 12 |
| permalink | /testing/ |
{: .no_toc }
pnpm add -D @textui/testingThe harness drives a real application against a virtual terminal, so what a test asserts is what a terminal would receive.
import { render } from '@textui/testing';
it('lists the services it was given', async () => {
const harness = await render(<Services />, { width: 80, height: 24 });
expect(harness.getByRole('table')).toBeDefined();
harness.press('down');
expect(harness.hasText('billing-worker')).toBe(true);
await harness.unmount();
});render mounts a node. renderApp takes a whole application - a shell, mounts, registered commands - for the cases where the thing under test is the wiring rather than one component.
Queries are semantic first, because a test pinned to exact escape sequences fails on every legitimate change and passes on none of the interesting bugs.
t.getByRole('button', { name: 'Restart' });
t.getAllByRole('listitem');
t.getByLabel('Password');
t.getByText('billing-worker');
t.getByComponent('Table');
t.queryByRole('dialog'); // null rather than throwingA missing match prints the frame. An ambiguous one lists what matched and asks you to narrow it, rather than silently picking the first.
This is what role and label on a node are for. A component that sets neither can only be found by its text, which is the assertion most likely to change for reasons that are not bugs - see Base props.
t.press('ctrl+k');
t.pressAll('tab', 'tab', 'enter');
t.type('softov'); // one key at a time, with a render between
t.paste('a whole clipboard');
t.click(10, 4);
t.clickOn(t.getByRole('button'));
t.wheel(10, 4, -3);
t.feed('\x1b[A'); // raw bytes, through the real decodertype renders between keystrokes, which is what a terminal does. Without that, a handler closing over stale props drops characters - a bug worth having a test for rather than a harness that hides it.
t.resize(40, 12);
t.setCapabilities({ unicode: 'ascii', colorDepth: 0 });
t.setTheme('paper');
t.setShell('workbench');
t.advance(500); // the animation clock, by hand
await t.settle(); // let promises land, then renderThe capability call is the most valuable one in the file. Every component is supposed to degrade; this is how you find out whether it does. See Glyphs, borders and colour depth for what is expected to change and what is not.
settle takes a turn of the clock as well as of the microtask queue, so it waits for the filesystem and not only for work already queued. That matters for how you wait: a fixed count - for (let i = 0; i < 8; i++) await t.settle() - is a budget denominated in turns of the loop, and a readdir is denominated in milliseconds. Prefer waiting for the thing itself, and give it a deadline:
const until = async (done: () => boolean, ms = 5000) => {
const deadline = Date.now() + ms;
while (!done() && Date.now() < deadline) await t.settle();
};
await until(() => t.hasText('.hidden'));t.tree(); // the semantic tree
t.lines(); // the frame, row by row
t.errors(); // anything the runtime caught
t.stats(); // renders, runs, mounted instancesimport { snapshot } from '@textui/testing';
expect(snapshot(t)).toMatchSnapshot();
expect(snapshot(t, { ruler: true })).toMatchSnapshot(); // with a column rulerstats().runs is how many terminal writes the last frame cost. If a one-cell change reports a hundred runs, the diff has stopped working - which is a test worth writing for anything performance-sensitive.
playground/test/playgrounds.test.tsx is the pattern worth copying. For every playground it mounts it, asserts it rendered something, asserts no unregistered component leaked through, resizes to 40 columns, strips Unicode and colour away, and renders it statically with no application at all.
A showcase nobody checks is a showcase that rots: someone changes a default, the gallery renders an empty box, and nobody notices for a month.