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 testChoose the boundary
| What you need to prove | Use |
|---|---|
| A rule, command, controller, or state transition | A plain Dart unit test |
| The public app tree can mount and dispose | runTuiApp(..., headless: true) |
| What the running app actually paints | noir_driver |
| A terminal accepts raw input or an escape protocol | A 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_driverimport '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 100x30This 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 .