Test Editor
The visual step editor in the Flutternaut desktop app lets you build E2E tests without writing JSON by hand. Pick actions from a searchable list, select targets from your keys, configure options, and the editor generates valid test JSON automatically.
Getting Started
Open the Editor tab in the desktop app sidebar. You can create a new test file or open an existing one. The editor shows two panels:
- Step Editor (left) — visual list of steps with add, delete, reorder, and inline editing.
- JSON Editor (right, toggle with the code icon) — raw JSON view that stays in sync with the visual editor.
Select a Keys Project
Before adding steps, select a Keys Project from the dropdown at the top of the editor. This loads the element labels generated by flutternaut_generator so you can pick targets from a dropdown instead of typing them manually.
Keys are grouped by view (from @FlutternautView annotations). Dynamic keys like todo_text_{index} are shown with their type and are handled specially for list actions.
Adding Steps
Click Add Step to open the action picker dialog. Each action shows its name and a short description of what it does.
- Search — type in the search bar to filter actions by name or description. For example, typing "list" shows
iterate_over_listandfind_in_list. - Categories — actions are grouped into Interactions, Assertions, Waits, Control Flow, System, Device, and Other (comments).
Editing a Step
Tap a step card to expand it and reveal its configuration fields. The fields shown depend on the action:
- Target — dropdown of keys from your selected project (with a toggle to switch to manual text input). For list actions, the dropdown shows label prefixes.
- Value — text field for values like typed text, expected assertions, or scroll directions.
- Timeout — optional duration (e.g.
10s) for wait and tap actions. - Sub-steps — for control flow actions (if, repeat, iterate, find_in_list), you can add nested steps. An info banner reminds you to use
${index}in targets for list actions.
Every change auto-saves immediately — there is no save button to press.
Adding Comments
Select _comment from the Other category in the action picker. Comment steps are displayed as italic cards in the step list. Tap to expand and edit the text. Comments are ignored by the engine — use them to label sections of your test.
Special Field Behaviors
- drag_and_drop — both the source (Target) and destination (Drop target) fields use keys dropdowns.
- iterate_over_list / find_in_list — the Target dropdown shows label prefixes extracted from dynamic keys (e.g.
todo_text_). Sub-step target dropdowns automatically substitute${index}into dynamic keys. - find_in_list — has a "Text to find" field for the search text and a "Scroll direction" dropdown stored in options.
- repeat_until_visible — shows "Delay between iterations" and "Max iterations" fields for fine-tuning the polling loop.
Action Reference
Every available action with its editor fields and generated JSON. You don't write this JSON — the step editor generates it — but it helps to understand what each field does.
Interactions
tap
Tap an element. In the editor: select a target from the keys dropdown. Optionally set a timeout.
{ "action": "tap", "target": "login_button" }type
Type text into an input field. Appends to existing text — use clear first if you need to replace. In the editor: select the input target, then type the value in the Value field.
{ "action": "type", "target": "email_input", "value": "[email protected]" }clear
Clear all text from an input field. In the editor: just select the target. No value needed.
{ "action": "clear", "target": "email_input" }back
Press the system back button (Android hardware back or iOS navigation back). No fields needed. On iOS, set target to the back button label if the default doesn't work.
{ "action": "back" }scroll
Scroll in a direction. In the editor: pick a direction from the dropdown. The direction is the physical swipe — "up" swipes up to reveal content below. Options: amount (0-1, default 0.3), duration (ms). Set target to constrain scrolling to a specific container element.
{ "action": "scroll", "value": "up", "options": { "amount": 0.5 } }swipe
Swipe in a direction. Similar to scroll but with a larger default distance (0.7). In the editor: pick direction from dropdown. Commonly used with Dismissible widgets.
{ "action": "swipe", "value": "left" }long_press
Long press an element. In the editor: select the target. Options: duration (ms, default 1500). Use for context menus, reorder handles, or any GestureDetector.onLongPress.
{ "action": "long_press", "target": "menu_item", "options": { "duration": 2000 } }double_tap
Double tap an element. In the editor: just select the target. Use for like buttons, zoom gestures, or any GestureDetector.onDoubleTap.
{ "action": "double_tap", "target": "like_button" }drag_and_drop
Drag from one element to another. In the editor: both the source (Target) and destination (Drop target) use keys dropdowns so you can select them visually.
{ "action": "drag_and_drop", "target": "drag_source", "value": "drag_target" }Assertions
Assertions check the current state of the UI instantly. If the condition is not met, the step fails immediately. Use waits instead if you need to wait for the condition to become true.
expect_text
Assert that an element's text matches the value exactly. In the editor: select target, type the expected text in Value.
{ "action": "expect_text", "target": "counter_value", "value": "3" }expect_contains
Assert that an element's text contains a substring. More lenient than expect_text — use when the full text may vary (e.g. timestamps, dynamic prefixes).
{ "action": "expect_contains", "target": "welcome_message", "value": "Hello" }expect_visible
Assert that an element is currently visible on screen. Fails if the element doesn't exist or is hidden.
{ "action": "expect_visible", "target": "success_banner" }expect_not_visible
Assert that an element is NOT visible. Passes if the element doesn't exist or is hidden. Use to confirm error messages are gone, dialogs are dismissed, etc.
{ "action": "expect_not_visible", "target": "error_text" }expect_enabled
Assert that an element is enabled (interactive). Use to verify buttons become clickable after form validation.
{ "action": "expect_enabled", "target": "submit_button" }expect_disabled
Assert that an element is disabled (not interactive). Use to verify buttons are greyed out before required input is provided.
{ "action": "expect_disabled", "target": "submit_button" }Waits
Waits poll repeatedly until a condition is met or the timeout expires. Use after navigation, API calls, or any async operation.
wait_visible
Poll until an element appears on screen. Use after every navigation to ensure the new screen is loaded before interacting with it. In the editor: select target, set timeout.
{ "action": "wait_visible", "target": "home_screen", "timeout": "10s" }wait_not_visible
Poll until an element disappears. Use after actions that trigger loading — wait for the spinner/indicator to vanish before proceeding.
{ "action": "wait_not_visible", "target": "loading_indicator", "timeout": "5s" }wait_text
Poll until an element's text matches the expected value. Use when content updates asynchronously (e.g. after adding an item, wait for the count to update).
{ "action": "wait_text", "target": "todo_count", "value": "3 items", "timeout": "5s" }Control Flow
Control flow actions contain sub-steps. In the editor, clicking "Add sub-step" opens the same action picker inside the parent step.
if
Run steps conditionally based on a condition. In the editor: select a target, pick a condition from the dropdown, and add sub-steps for the "Then" branch. Optionally add "Else" steps.
Available conditions: visible, not_visible, text_contains, text_equals, var_equals, var_not_equals. For text/var conditions, set the expected value in the Value field.
{
"action": "if",
"target": "login_button",
"options": { "condition": "visible" },
"steps": [
{ "action": "tap", "target": "login_button" }
],
"elseSteps": [
{ "action": "tap", "target": "logout_button" }
]
}repeat_n_times
Run sub-steps a fixed number of times. In the editor: set the count, then add sub-steps. ${iteration} holds the current loop index (starts at 0) — use it in targets or values if needed.
{
"action": "repeat_n_times",
"options": { "count": 3 },
"steps": [
{ "action": "tap", "target": "increment_button" }
]
}repeat_until_visible
Run sub-steps repeatedly until a target element appears. In the editor: select the target to wait for, configure delay between iterations (default 500ms) and max iterations (default 20), then add sub-steps that perform the action each cycle (e.g. tap a refresh button).
{
"action": "repeat_until_visible",
"target": "success_element",
"options": { "delay": "1s", "max_iterations": 5 },
"steps": [
{ "action": "tap", "target": "refresh_button" }
]
}iterate_over_list
Run sub-steps on every item in a list. In the editor: the target dropdown shows label prefixes from your dynamic keys (e.g. todo_text_). The engine probes todo_text_0, todo_text_1, etc. until one isn't found. Sub-step target dropdowns auto-fill ${index}. Index starts at 0.
{
"action": "iterate_over_list",
"target": "todo_text_",
"steps": [
{ "action": "expect_visible", "target": "todo_text_${index}" }
]
}find_in_list
Find one item in a list by its text content, then act on it. Scrolls automatically to discover off-screen items. In the editor: select the label prefix as target, type the text to search for in "Text to find", pick a scroll direction, then add sub-steps. ${index} is set to the matched item's index so sub-steps can target sibling elements (e.g. delete button).
Options: match can be exact (default) or contains. scroll_target constrains scrolling to a specific container. max_scrolls (default 10).
{
"action": "find_in_list",
"target": "todo_text_",
"value": "Buy groceries",
"options": { "direction": "down", "match": "exact" },
"steps": [
{ "action": "tap", "target": "delete_${index}" }
]
}scroll_until_visible
Scroll in a direction until a specific element appears on screen. In the editor: select the target element, pick a direction. Options: max_scrolls (default 10), scroll_target (constrains scrolling to a container), amount (scroll distance 0-1).
{
"action": "scroll_until_visible",
"target": "hidden_at_bottom",
"value": "down",
"options": { "scroll_target": "scroll_list", "max_scrolls": 15 }
}set_variable
Store a value for use in later steps. Two modes: set a literal value in the Value field, or read the current text from an element using the from_element option. Reference the variable later with ${varname}.
{ "action": "set_variable", "target": "username", "value": "john" }{ "action": "set_variable", "target": "count", "options": { "from_element": "todo_count" } }System
App lifecycle controls. These manage the app process on the device.
open_app
Open (launch) the app under test. Use after close_app to relaunch. No fields needed.
{ "action": "open_app" }close_app
Close (terminate) the app. The app process is killed. No fields needed.
{ "action": "close_app" }restart_app
Close and immediately reopen the app. Equivalent to close_app + open_app. Use to test fresh-start behavior.
{ "action": "restart_app" }background_app
Send the app to the background. Options: seconds (how long to stay backgrounded, -1 = indefinitely). Follow with resume_app to bring it back.
{ "action": "background_app", "options": { "seconds": 3 } }resume_app
Bring the app back to the foreground after background_app. No fields needed.
{ "action": "resume_app" }deep_link
Navigate the app via a deep link URL. In the editor: type the URL in the URL field. The app must handle the URL scheme.
{ "action": "deep_link", "target": "myapp://profile/123" }Device
rotate_screen
Rotate the device to portrait or landscape. In the editor: pick the orientation from a dropdown. Use to test responsive layouts.
{ "action": "rotate_screen", "value": "landscape" }manual_step
Pause the test and show an instruction to the tester. The test resumes when the tester confirms. In the editor: type the instruction in the Instruction field. Use for hardware actions (Bluetooth, camera, biometrics), physical device interactions, or visual verifications that can't be automated.
{ "action": "manual_step", "value": "Connect Bluetooth headset and verify audio output" }Working with Lists
Lists require indexed labels in your Flutter code so the engine can target individual items:
ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => ListTile(
title: Flutternaut.text(
label: 'todo_text_$index',
value: todos[index].text,
child: Text(todos[index].text),
),
trailing: Flutternaut.button(
label: 'delete_$index',
child: IconButton(
icon: Icon(Icons.delete),
onPressed: () => _delete(index),
),
),
),
)Two actions handle lists:
- iterate_over_list — act on every item. Target dropdown shows prefixes like
todo_text_. Sub-step targets auto-filltodo_text_${index}. - find_in_list — find one item by text. Enter the text to search for. The engine scrolls automatically and sets
${index}so sub-steps can target sibling elements (e.g. the delete button at the same index).
See Practical Examples below for full end-to-end walkthroughs with Flutter code and test steps.
Practical Examples
Each example shows the Flutter code (how to wrap your widgets) and the corresponding test steps (what the editor generates). Copy any JSON below and paste it into the editor's JSON panel (toggle with the code icon) to see the steps visualized as cards.
Example 1: Find an item and open its details
A list of products. Find "Running Shoes" and tap its details button.
Flutter code
@FlutternautView('Products')
class ProductListScreen extends StatefulWidget { ... }
// In build:
ListView.builder(
itemBuilder: (context, index) => ListTile(
title: Flutternaut.text(
label: 'product_name_$index',
value: products[index].name,
child: Text(products[index].name),
),
trailing: Flutternaut.button(
label: 'open_details_$index',
child: IconButton(
icon: Icon(Icons.arrow_forward),
onPressed: () => _openDetails(index),
),
),
),
)Test steps
{
"name": "Find product and open details",
"config": { "timeout": "60s", "retries": 0, "failFast": true, "screenshotOnFailure": true },
"steps": [
{ "_comment": "Navigate to products screen" },
{ "action": "wait_visible", "target": "product_name_0", "timeout": "10s" },
{ "_comment": "Find 'Running Shoes' and open its details" },
{
"action": "find_in_list",
"target": "product_name_",
"value": "Running Shoes",
"steps": [
{ "action": "tap", "target": "open_details_${index}" }
]
},
{ "action": "wait_visible", "target": "product_detail_screen", "timeout": "5s" }
]
}In the editor: Add find_in_list, select product_name_ prefix from the target dropdown, type "Running Shoes" in "Text to find", then add a sub-step tap with target open_details_${index} from the dynamic keys dropdown. Or paste the JSON above into the JSON panel to see the steps as cards.
Example 2: Login, add an item, verify count
A common end-to-end flow: log in with credentials, add a todo item, and verify the item count updates.
Flutter code
@FlutternautView('Login')
class LoginScreen extends StatefulWidget { ... }
// Labels: email_input, password_input, login_button, error_text
@FlutternautView('Home')
class HomeScreen extends StatefulWidget { ... }
// Labels: todo_input, add_button, todo_count
// Dynamic: todo_text_$index, delete_$index, check_$indexTest steps
{
"name": "Login and add todo",
"config": { "timeout": "60s", "retries": 0, "failFast": true, "screenshotOnFailure": true },
"steps": [
{ "action": "wait_visible", "target": "email_input", "timeout": "10s" },
{ "action": "type", "target": "email_input", "value": "[email protected]" },
{ "action": "type", "target": "password_input", "value": "password123" },
{ "action": "tap", "target": "login_button" },
{ "action": "wait_text", "target": "todo_count", "value": "2 items", "timeout": "10s" },
{ "_comment": "Add a new item" },
{ "action": "type", "target": "todo_input", "value": "Buy milk" },
{ "action": "tap", "target": "add_button" },
{ "action": "wait_text", "target": "todo_count", "value": "3 items", "timeout": "5s" },
{ "action": "expect_contains", "target": "todo_text_2", "value": "Buy milk" }
]
}Paste this JSON into the editor to see all 10 steps as visual cards. Each step shows its action, target (from your keys), and value.
Example 3: Conditional — dismiss onboarding if visible
Your app may show an onboarding overlay on first launch. Use if to dismiss it only when present, so the test works whether or not the overlay appears.
Flutter code
// Optional overlay — only shown on first launch
if (_showOnboarding)
Flutternaut.button(
label: 'dismiss_onboarding',
child: ElevatedButton(onPressed: _dismiss, child: Text('Got it')),
),
// Always present
Flutternaut.text(
label: 'home_title',
value: 'Dashboard',
child: Text('Dashboard'),
)Test steps
{
"name": "Handle optional onboarding",
"config": { "timeout": "60s", "retries": 0, "failFast": true, "screenshotOnFailure": true },
"steps": [
{ "action": "wait_visible", "target": "home_title", "timeout": "10s" },
{
"action": "if",
"target": "dismiss_onboarding",
"options": { "condition": "visible" },
"steps": [
{ "action": "tap", "target": "dismiss_onboarding" }
]
},
{ "action": "expect_visible", "target": "home_title" }
]
}In the editor: Add if, select dismiss_onboarding as target, pick visible from the condition dropdown, then add a tap sub-step. The test skips the tap if the overlay isn't there.
Example 4: Iterate all items, then delete one by name
First verify every item in the list is visible using iterate_over_list, then use find_in_list to find a specific item by text and delete it.
Flutter code (same Home screen as Example 2)
ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) => ListTile(
title: Flutternaut.text(
label: 'todo_text_$index',
value: todos[index].text,
child: Text(todos[index].text),
),
trailing: Flutternaut.button(
label: 'delete_$index',
child: IconButton(
icon: Icon(Icons.delete),
onPressed: () => _delete(index),
),
),
),
)Test steps
{
"name": "Iterate and delete by name",
"config": { "timeout": "60s", "retries": 0, "failFast": true, "screenshotOnFailure": true },
"steps": [
{ "_comment": "Verify all items are visible" },
{
"action": "iterate_over_list",
"target": "todo_text_",
"steps": [
{ "action": "expect_visible", "target": "todo_text_${index}" }
]
},
{ "_comment": "Find 'Read a book' and delete it" },
{
"action": "find_in_list",
"target": "todo_text_",
"value": "Read a book",
"steps": [
{ "action": "tap", "target": "delete_${index}" }
]
},
{ "action": "wait_text", "target": "todo_count", "value": "1 items", "timeout": "5s" }
]
}In the editor: The iterate_over_list and find_in_list steps both show the info banner reminding you to use ${index} in sub-step targets. The target dropdown auto-fills todo_text_${index} and delete_${index} from your dynamic keys.
Try It Yourself
The flutternaut package includes an example app with 5 screens and a testdata/ folder containing 5 pre-built tests covering every action. To try them:
- Clone the example app and run it on an emulator or physical device in debug mode.
- Open the Flutternaut desktop app and import the generated
flutternaut_keys.jsonin the Keys tab. - Copy the test files from
testdata/into the Editor tab or load them directly in the Test Runner. - Configure a device and Appium config, then run the tests.