Skip to Content
Test an app

Test an app

Test what your application promises, at the narrowest boundary that proves it. Noir does not export a widget-test harness, so application tests should not import repository helpers or anything under package:noir/src/.

dart pub add --dev test dart test

Choose the boundary

What you need to proveUse
A rule, command, controller, or state transitionA plain Dart unit test
The public app tree can mount and disposerunTuiApp(..., headless: true)
What the running app actually paintsnoir_driver
A terminal accepts raw input or an escape protocolA check in the target terminal

This split keeps application tests fast and independent of terminal rendering.

Test the behavior you own

Move an application command into a small state owner, then test that owner without mounting a widget tree:

import 'package:noir/noir.dart'; import 'package:test/test.dart'; final class CounterController extends ValueNotifier<int> { CounterController() : super(0); void increment() { value++; } } void main() { test('increment advances the count', () { final counter = CounterController(); addTearDown(counter.dispose); counter.increment(); expect(counter.value, 1); }); }

The Button in the application calls counter.increment. The test covers the promised state change without knowing how the Button is painted or which key activated it. Use the same boundary for validation, asynchronous state, and commands invoked from keyboard, pointer, paste, or widget callbacks.

Mount the public lifecycle when it is the behavior

Use a headless app check when construction, inherited dependencies, or deterministic disposal is part of what you promise:

import 'package:noir/noir.dart'; import 'package:test/test.dart'; void main() { test('mounts and disposes headlessly', () { final app = runTuiApp(const Text('ready'), headless: true); expect(app.isHeadless, isTrue); app.dispose(); }); }

Headless mode mounts Noir’s normal widget and element tree without an owned terminal renderer. It proves that the tree builds and tears down. It does not show a rendered frame, exercise raw-mode stdin, or prove that a terminal accepts an escape sequence.

Always dispose the app handle, focus nodes, editing and scroll controllers, notifiers, subscriptions, and animation controllers in the layer that created them.

What a passing test does not prove

  • A headless mount is not evidence of raw-mode input, alternate-screen teardown, or protocol support. See Platform support.
  • Terminal images, OSC52 clipboard behavior, and enhanced keyboard reporting depend on the reader’s terminal. Check those in the terminal you target.

Drive the whole app

When what you need to prove is what the app paints, run it. Noir ships drive mode: with NOIR_DRIVE=1 in its environment, runTuiApp mounts the app into a headless binding that paints into OpenTUI’s non-terminal testing renderer and publishes a VM-service surface. Nothing in your main() changes.

noir_driver is the client:

dart pub add --dev noir_driver
import 'package:noir_driver/noir_driver.dart'; import 'package:test/test.dart'; void main() { test('the counter counts', () async { final driver = await NoirDriver.launch('bin/app.dart'); addTearDown(driver.quit); await driver.clickLocator(const DriverLocator.byKey('increment')); expect(await driver.capture(), DriverFrameMatchers.containsText('Count: 1')); }); }

Keys and mouse reports are encoded to escape bytes and injected through the app’s production ANSI parser, and locator clicks resolve through the production render tree’s hit testing, so a driven interaction takes the path a real terminal would. A failed frame assertion prints the captured rows.

It needs no TTY and no raw mode, which also makes it the way to check painted output in CI. The same client drives an app from the command line:

dart run noir_driver:drive bin/app.dart --size 100x30

This drives a live app process rather than mounting widgets, so reach for it when the rendered result is the thing under test, and keep unit tests for the logic underneath. Its output is real Noir rendering, but it is not evidence of raw-mode cleanup or of any particular terminal emulator — see Platform support.

Known limits: a continuously animating app never reports stable: true, and capture keeps working anyway; an app whose own quit path calls exit ends the session; reload inherits reassemble()’s limits, so main() and initState bodies still need a restart; and DriverFrame.lines is right-trimmed, so trailing-space bugs need captureCells.

Contributing to Noir itself

The repository has four framework test compositions and a live-process driver. They are deliberately absent from the published package, so an application cannot import them. If you are changing the framework, read CONTRIBUTING.md  and the test helper guide .

Last updated on