How to Test a Flutter Desktop App Without Appium

· TestDriver Team

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 .deb your 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 your flutter build linux bundle with exec().
  • Start your binary in the second exec() call. dpkg -L <package> shows where it’s installed.
  • On Windows, pass os: "windows" to TestDriver() and point the installer at your .exe or .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.