How to Test a Flutter Desktop App Without Appium
Flutter testing for desktop apps with plain-language steps on screenshots instead of widget keys or Appium.
Automate and scale manual testing with AI ->
Above: the test below running against LocalSend, sped up with idle time removed.
You can do Flutter testing end-to-end on a desktop build without Appium or widget keys by describing each step in plain language. TestDriver matches each description against screenshots of the installed app, so:
- you test the same
.debyour users install, not a debug build with a test harness - you don’t add
Keys to widgets or keep finders in sync - your test reads like the steps you’d give a teammate
The example installs LocalSend, an open-source file-sharing app built with Flutter, renames the device in Settings and checks the new name on the Receive screen. The full project is on GitHub: testdriverai/example-flutter-desktop-app.
Why not Flutter integration tests or Appium
Flutter’s integration tests run inside your app’s source tree and find widgets with finders, often by a ValueKey you add to the widget. Appium’s Flutter drivers reach the same widget tree from outside:
// Flutter integration_test finder (illustrative, not run)
await tester.tap(find.byKey(const ValueKey('settings_alias_field')));
TestDriver describes the same element the way a user sees it:
await testdriver.find("the Device name text field in the Network section").click();
The finder needs a key in the code and a rebuild when it changes. The description only changes when the UI does.
Watch the full replay
The replay shows every step with its screenshot and log.
The test
import { describe, expect, it } from "vitest";
import { TestDriver } from "testdriverai/vitest/hooks";
const LOCALSEND_DEB =
"https://github.com/localsend/localsend/releases/download/v1.18.2/LocalSend-1.18.2-linux-x86-64.deb";
describe("LocalSend (Flutter)", () => {
it("renames the device and shows the new name on the Receive screen", async (context) => {
const testdriver = TestDriver(context);
// Download and install the .deb. We start the app ourselves below.
await testdriver.provision.installer({ url: LOCALSEND_DEB, launch: false });
// Pull in missing runtime dependencies (including the tray icon library), then start LocalSend.
await testdriver.exec(
"sh",
"sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -f -y -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq libayatana-appindicator3-1",
240000,
);
await testdriver.exec("sh", "nohup localsend_app >/tmp/localsend.log 2>&1 &", 10000);
// Wait for the window. LocalSend opens on the Receive screen with a random device name.
await testdriver.find("the LocalSend window showing the Receive screen with a device name", {
timeout: 60000,
});
// Open Settings and scroll to the bottom of the list.
await testdriver.find("Settings tab in the LocalSend navigation").click();
await testdriver.find("the Animations row in the General settings section").hover();
await testdriver.scroll("down", { amount: 2000 });
// The device name lives in the Network section, which Advanced settings reveals.
await testdriver.find("the Advanced settings checkbox near the bottom of the Settings page").click();
await testdriver.find("the Device name text field in the Network section").click();
// Replace the name in the dialog and confirm.
await testdriver.find("the text input inside the Device name dialog").click();
await testdriver.pressKeys(["ctrl", "a"]);
await testdriver.type("TestDriver Laptop");
await testdriver.find("the green Confirm button in the Device name dialog").click();
// LocalSend asks for a server restart before the new name is used.
await testdriver.find("the circular restart arrow button in the Server row of the Network section").click();
// Go back to Receive, wait for the new name, then check it.
await testdriver.find("Receive tab in the LocalSend navigation").click();
await testdriver.find('the device name "TestDriver Laptop" shown under the logo on the Receive screen', {
timeout: 15000,
});
const renamed = await testdriver.assert(
'the LocalSend Receive screen shows the device name "TestDriver Laptop" under the logo',
);
expect(renamed).toBeTruthy();
});
});
How it works
Install and launch the release build
provision.installer() downloads the .deb and installs it on a fresh Linux sandbox. The two exec() calls install missing dependencies, including the tray icon library LocalSend needs, and start the app in the background.
Describe elements by what users see
Each find() takes a description that names the label, the look and the position, such as “the circular restart arrow button in the Server row”. Flutter paints its own widgets, so there’s no native control tree to query, but every widget is on screen. See locating elements.
Wait for results, then assert
find() with a timeout keeps looking until the element appears, so the test waits for the window and the renamed device without fixed sleeps. assert() then checks a plain-language statement against the screen and returns true or false. See making assertions.
Run it yourself
Clone the example repo, or set up TestDriver in your own project and run the test:
npx testdriverai init
npx vitest run flutter-desktop-app.test.mjs
To test your own Flutter desktop app:
- Point
provision.installer()at your release.deb, or download yourflutter build linuxbundle withexec(). - Start your binary in the second
exec()call.dpkg -L <package>shows where it’s installed. - On Windows, pass
os: "windows"toTestDriver()and point the installer at your.exeor.msi.
FAQ
Do I need widget keys or semantics labels?
No. Steps are matched on screenshots, so there are no Keys or semantics labels to add or keep in sync.
Do I need Appium or Flutter Driver? No. TestDriver drives the installed app from the screen, so there’s no driver to match to your Flutter version.
Can I run it in CI? Yes. It’s a normal Vitest file and the app runs in TestDriver’s cloud sandbox. See CI/CD.
Automate and scale manual testing with AI
TestDriver uses computer-use AI to test any app - write tests in plain English and run them anywhere.