Skip to content

Testing

A widget test runs your UI without opening a terminal, so dart test runs the same way in CI. Tests drive the UI through a tester instead of calling runApp, which needs a real terminal and throws without one, as in most CI jobs. Each example below pairs a running widget with its source and a test you can try.

button('Add one') finds the button by its role (the kind of control) and label (its name). Button supplies both automatically.

The Logical test invokes its action with press(). The Keyboard test sends Enter through the focus system. Try the counter, then compare the two:

counter.dart
Source · editable
class _CounterState extends State<Counter> {
int count = 0;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Count: $count'),
Button(
text: 'Add one',
autofocus: true,
onPressed: () => setState(() => count++),
),
],
);
}
Try it
Live · interactive
click & type to interact ⓘ how this demo runs
Tests

Logical test

import 'dart:async';
import 'package:fleury/fleury.dart';
import 'package:fleury_test/fleury_test.dart';
import 'package:my_app/counter.dart';
import 'package:test/test.dart';
void main() {
testWidgets('adds one', (tester) async {
tester.pumpWidget(const Counter());
await tester.button('Add one').press();
expect(tester.exists(text('Count: 1')), isTrue);
});
}

Keyboard test

testWidgets('adds one using the keyboard', (tester) {
tester.pumpWidget(const Counter());
expect(tester.button('Add one'), isFocused);
tester.press(KeySequence.enter);
expect(tester.exists(text('Count: 1')), isTrue);
});

pumpWidget mounts the widget and completes its first frame. testWidgets provides a fresh tester for each test and disposes it afterward.

expect(actual, matcher) checks the result. Here, exists(text(...)) returns a boolean; isTrue is Dart’s standard matcher for true. A logical press checks the button’s behavior; use keyboard or pointer tests to check input routing.

Set up and run the tests

Projects created with fleury create already include fleury_test and test. To add them to another app during the pre-release Git installation, use:

dev_dependencies:
fleury_test:
git:
url: https://github.com/danReynolds/fleury.git
path: packages/fleury_test
test: ^1.26.3
dependency_overrides:
fleury:
git:
url: https://github.com/danReynolds/fleury.git
path: packages/fleury

The fleury dependency override keeps the app and the tester on the same checkout. Run dart test from your application package. In a Fleury checkout, run every example from this guide with:

Terminal window
cd website/examples
dart test test/testing_guide_test.dart

Keep the imports and main from the first test. The remaining tests show individual cases to add inside it.

Both forms have a Name field and an Email updates checkbox. Select the Work widget by its type and key, then change only its controls. Use Run test to watch the actions and assertions, or type into Work and use Tab to explore:

Source · editable
class _PreferencesState extends State<Preferences> {
String name = '';
bool emailUpdates = false;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 26,
child: TextInput(
semanticLabel: 'Name',
placeholder: 'Name',
autofocus: widget.autofocus,
onChanged: (value) => setState(() => name = value),
),
),
Checkbox(
label: 'Email updates',
value: emailUpdates,
onChanged: (value) => setState(() => emailUpdates = value),
),
Text(emailUpdates ? 'Updates for $name' : 'Email updates off'),
],
);
}
Try it
Live · interactive
click & type to interact ⓘ how this demo runs
Tests

Test

testWidgets('edits only the work preferences', (tester) async {
tester.pumpWidget(preferencesPair());
final work = tester.target(type: Preferences, key: const ValueKey('work'));
await work.field('Name').fill('Ada');
await work.checkbox('Email updates').check();
expect(work.field('Name'), hasValue('Ada'));
expect(work.checkbox('Email updates'), isChecked);
final personal = tester.target(key: const ValueKey('personal'));
expect(personal.field('Name'), hasValue(''));
expect(personal.checkbox('Email updates'), isUnchecked);
});

fill replaces the field’s text. check leaves the option checked, including when it was already on. Your own widgets, like Preferences, use these same queries and actions.

Try Save to see its pending state. The live demo completes after a short delay; the test uses a Completer from dart:async to finish the request itself:

Source · editable
class _SaveStatusState extends State<SaveStatus> {
Future<void>? request;
@override
Widget build(BuildContext context) => FutureBuilder<void>(
future: request,
builder: (context, snapshot) {
final saving = snapshot.connectionState == ConnectionState.waiting;
final status = saving
? 'Saving…'
: snapshot.hasError
? 'Save failed'
: snapshot.connectionState == ConnectionState.done
? 'Saved'
: 'Ready';
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Button(
text: 'Save',
onPressed: saving
? null
: () => setState(() => request = widget.save()),
),
Text(status),
],
);
},
);
}
Try it
Live · interactive
click & type to interact ⓘ how this demo runs
Tests

Test

testWidgets('chooses when the save finishes', (tester) async {
final request = Completer<void>();
tester.pumpWidget(SaveStatus(save: () => request.future));
await tester.button('Save').press();
expect(tester.exists(text('Saving…')), isTrue);
expect(tester.button('Save'), isDisabled);
request.complete();
await tester.settle();
expect(tester.exists(text('Saved')), isTrue);
expect(tester.button('Save'), isEnabled);
});

After request.complete(), await tester.settle() lets the future callback run and processes the resulting frames. This makes the pending and completed states easy to check separately. Use settle() whenever a future or stream has to deliver; the synchronous pump methods never give it a chance to run.

Press Animate to move between 0% and 100%. The test advances Fleury’s animation clock to the halfway point, then finishes the animation:

animated_upload.dart
Source · editable
class _AnimatedUploadState extends State<AnimatedUpload> {
double target = 0;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimationBuilder<double>(
target,
duration: const Duration(seconds: 1),
curve: Curves.linear,
builder: (_, value, _) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 26,
child: ProgressBar(value: value, semanticLabel: 'Upload'),
),
Text('${(value * 100).round()}%'),
],
),
),
Button(
text: 'Animate',
onPressed: () => setState(() => target = target == 0 ? 1 : 0),
),
],
);
}
Try it
Live · interactive
click & type to interact ⓘ how this demo runs
Tests

Test

testWidgets('inspects the upload halfway through its animation', (
tester,
) async {
tester.pumpWidget(const AnimatedUpload());
await tester.button('Animate').press();
final upload = tester.target(role: SemanticRole.progress, label: 'Upload');
tester.pump(const Duration(milliseconds: 500));
expect(upload, hasValue(0.5));
tester.pumpAndSettle();
expect(upload, hasValue(1.0));
});

pump(duration) advances animation time immediately. pumpAndSettle() runs frames until the animation is quiet. Both are synchronous: they move the animation clock without waiting on wall time, but they don’t let pending futures complete. The progress bar also shows how to select other control kinds with target(role: ..., label: ...).

This draft editor registers Save as a command with a Ctrl+S shortcut. Edit the draft, then save with the button or Ctrl+S:

Live · interactive
click & type to interact ⓘ how this demo runs
AppCommand get saveCommand => AppCommand(
id: const CommandId('editor.save'),
title: 'Save draft',
shortcuts: [KeySequence.ctrl.s],
enabled: (_) => dirty && !saving,
run: (_) => save(),
);

The editor registers the command in a CommandScope around its content, and its Save button is a CommandButton for the same ID. A test can run the command by that ID, without finding the button:

testWidgets('saves the draft by its command ID', (tester) async {
String? saved;
tester.pumpWidget(
FleuryApp(
title: 'Draft editor',
home: DraftEditor(save: (text) async => saved = text),
),
);
await tester.field('Draft').fill('Ready for review.');
final result = await tester.invokeCommand(const CommandId('editor.save'));
expect(result.completed, isTrue);
expect(saved, 'Ready for review.');
final again = await tester.invokeCommand(const CommandId('editor.save'));
expect(again.status, CommandInvocationStatus.disabled);
});

invokeCommand waits for the command to finish and returns its result. completed is false when the command was disabled, missing, or threw; status says which. Here the second save is disabled, because there is nothing left to save.

To check that the shortcut reaches the command from the focused field, press it the way a user would:

testWidgets('saves the draft with Ctrl+S', (tester) async {
String? saved;
tester.pumpWidget(
FleuryApp(
title: 'Draft editor',
home: DraftEditor(save: (text) async => saved = text),
),
);
final editor = tester.target(type: DraftEditor);
expect(editor.field('Draft'), isFocused);
tester.type(' Ready for review.');
tester.press(KeySequence.ctrl.s);
await tester.settle();
expect(saved, 'Ship the testing guide. Ready for review.');
expect(tester.exists(text('All changes saved')), isTrue);
expect(editor.button('Save'), isDisabled);
});

A shortcut starts the command without waiting for it, so the test calls settle() before checking the save.

The complete test file behind this guide also covers failed saves and a dialog that restores focus when it closes. For custom controls, see how widgets publish semantics.

The testing API reference covers additional actions, query rules, failure diagnostics, and golden tests.