slint_testing

Test Slint UIs from Dart, through the accessibility tree.

A component is compiled and instantiated on Slint's testing backend (i-slint-backend-testing) — no window, no renderer, no event loop — and exposed as elements you can find, click, fill in, and assert on. Tests describe what a user can perceive and do, not which pixels changed.

import 'package:slint_testing/slint_testing.dart';
        import 'package:test/test.dart';
        import 'package:todo_example/todo.g.dart';
        
        void main() {
          test('adding a todo reaches the typed handler', () {
            final ui = SlintTestApp.compile(TodoApp.slintSource,
                component: TodoApp.componentName, files: TodoApp.slintFiles);
            final app = TodoApp(ui);
            addTearDown(app.dispose);
        
            final added = <String>[];
            app.onAddTodo(added.add);
            ui.findById('TodoView::edit').single.setValue('buy milk');
            ui.findByLabel('Add').single.click();
        
            expect(added, ['buy milk']);
          });
        }
        

SlintTestApp is a SlintComponent, so the wrapper slint_generator generated from the .slint wraps it like any backend's instance. Elements are found and acted on through the test app; properties and callbacks go through the wrapper's typed members, and nothing in the test names a Slint property or callback. The wrapper embeds the source and everything it imports, so the test reads no file.

No device, no window, no golden files. The package itself needs no Flutter and its own tests run under plain dart test; a test that imports an app's wrapper runs under flutter test, like the app's other tests. The native library is built by the package's own build hook.

Finding elements

Method Matches
findByLabel(label) the accessible label — a button's text, a checkbox's caption
findById(id) an element id qualified by the component that declares it, TodoView::edit
findByType(name) the element's type, Button, LineEdit
findByRole(role) the accessible role, Button, Checkbox, TextInput
findAll() every element in the tree

Each returns a List<SlintElement> carrying id, typeName, role, label, value, placeholder, description, checked, checkable, enabled, itemIndex, itemCount, and the element's x, y, width, height in Slint logical pixels relative to the window.

Those fields come from SlintElementInfo, the element model this package owns. slint_patrol reuses it for the live app, and both are filled in by one Rust implementation in slint-dart-interpreter, so the same element reads the same headlessly and on screen. Geometry is only meaningful once the component has been laid out, which headless tests never trigger — it is there for slint_patrol, which needs it to know where to click.

The tree is the whole tree: it descends into the widgets' own implementation, so a LineEdit is followed by the Rectangle, TextInput, and Text that make it up, and a widget repeats its role on the inner elements implementing it. Prefer findByLabel and findById — they name what the test is actually about; findAll is for exploring an unfamiliar component.

Results are a snapshot, and each query replaces the previous one. An element therefore belongs to the query that produced it: acting on one from an earlier query throws, rather than silently acting on whatever now sits at that position. Query, act, and query again.

Interacting

click() invokes the element's accessible default action — pressing a button, toggling a checkbox. setValue(String) sets its accessible value — typing into an input, moving a slider. Both are synchronous: the testing backend runs without an event loop, so the async pointer-event helpers that need one are deliberately not exposed.

Using a component or element after dispose() throws (SlintTestException) — the Dart side never sends a freed handle back into native code.

elapse(Duration) advances the backend's mock clock, driving animations and timers without waiting in real time.

Properties and callbacks

SlintTestApp implements SlintComponent: getProperty, setProperty, setCallbackHandler and invokeCallback cross the same JSON bridge the interpreter uses, so Dart maps and lists map onto Slint structs and models. They are the layer a generated wrapper calls. A test uses the wrapper's members instead (app.todoModel, app.onAddTodo(...)), because only generated code spells Slint names.

A handler runs synchronously, inside the click() or invokeCallback that fired it. Its return value becomes the callback's, and null reads as the declared type's default. A handler that throws is reported to the current zone, which fails the test, and Slint gets the default. Disposing the app from inside a handler is safe: the native side is freed once the call that ran the handler returns.

Nothing here renders, so the wrapper's renderTarget throws over this backend.

Choosing the component

component: may be omitted only when the source exports exactly one. The compiler returns components unordered, so with several exports there is no meaningful "first" to fall back on; compile throws and names what it found instead of picking one arbitrarily. Pass the wrapper's componentName rather than spelling it.

files: takes what the source reads besides itself, imported .slint files and @image-url resources, as a wrapper's slintFiles holds them. They are written to a temporary tree, so imports and images resolve without the test knowing where the app's ui/ lives.

Layout

lib/slint_testing.dart      public API
        lib/src/testing.dart        SlintTestApp, SlintElement
        lib/src/bindings.g.dart     generated ffigen bindings — do not hand-edit
        hook/build.dart             builds the Rust crate as a native asset
        rust/                       slint-testing-ffi: the C ABI over the testing backend
        

The Rust crate links its own copy of Slint, so its testing platform is independent of the software platform slint_interpreter installs. Reuses slint-dart-interpreter for compilation, the JSON property bridge, and the element query itself.

Testing the running app instead

This package tests a component headlessly — fast, but not the app a user touches. slint_patrol covers that: Patrol finders over the live component in a real widget tree, tapping and typing through real Flutter gestures.