Testing Tauri Apps with WebDriver: A Practical Guide

Testing Tauri Apps with WebDriver: A Practical Guide

Tauri is the newer, leaner alternative to Electron for building desktop applications with web frontends. Where Electron bundles Chromium, Tauri uses the OS's native WebView — WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux. The result is dramatically smaller binaries and lower memory usage, but it also means the testing story is different. Playwright's Electron integration doesn't apply here. Tauri's official testing path is WebDriver, specifically through tauri-driver, a thin wrapper that makes the standard WebDriver protocol work with Tauri's architecture.

This guide walks through the complete setup: building Tauri in test mode, configuring tauri-driver, writing tests with WebdriverIO, and handling Tauri-specific patterns like commands and events.

Understanding Tauri's Testing Architecture

Tauri apps consist of a Rust backend (the "core") and a WebView frontend. The WebDriver interface connects to the WebView, so your tests can interact with the frontend exactly as you would a web page. The Rust backend is harder to reach from WebDriver — for command testing, you typically expose test helpers via JavaScript that invoke Tauri commands and surface the results.

tauri-driver is the bridge between standard WebDriver clients and Tauri's native WebView. It:

  • Wraps msedgedriver on Windows (WebView2 is Edge-based)
  • Wraps WebKitWebDriver on Linux
  • On macOS, uses Safari's WebDriver infrastructure

Prerequisites

Install the platform-specific WebDriver backend:

macOS: Safari's WebDriver is available if you enable it:

safaridriver --enable

Linux:

sudo apt-get install webkit2gtk-driver

Windows: WebView2 ships with modern Windows. Download msedgedriver matching your WebView2 version from the Edge Driver downloads page.

Install tauri-driver:

cargo install tauri-driver

And your Node.js test dependencies:

npm install --save-dev @wdio/cli @wdio/local-runner @wdio/mocha-framework @wdio/spec-reporter webdriverio

Building Tauri for Testing

Before running tests, build your app in a way that allows WebDriver connections. The key is enabling the --webdriver feature during the build, or more practically, running in development mode with WebDriver enabled.

Add a test script to package.json:

{
  "scripts": {
    "test:e2e": "cargo tauri build --debug && wdio run wdio.conf.ts",
    "test:e2e:dev": "concurrently \"cargo tauri dev\" \"wait-on http://localhost:1420 && wdio run wdio.conf.ts\""
  }
}

For CI and reproducibility, prefer the debug build approach over tauri dev. The binary ends up in src-tauri/target/debug/your-app-name.

WebdriverIO Configuration

Create wdio.conf.ts:

import { Options } from '@wdio/types';
import { spawn, spawnSync } from 'child_process';
import { join } from 'path';

// Platform-specific binary name
const APP_BINARY = process.platform === 'win32'
  ? 'your-app.exe'
  : 'your-app';

const APP_PATH = join(
  __dirname,
  'src-tauri/target/debug',
  APP_BINARY
);

export const config: Options.Testrunner = {
  runner: 'local',
  specs: ['./tests/e2e/**/*.spec.ts'],
  maxInstances: 1, // Desktop apps should not run parallel instances
  
  capabilities: [{
    // tauri-driver acts as the WebDriver server
    'tauri:options': {
      application: APP_PATH,
    },
  }],

  // tauri-driver binary
  services: [
    ['tauri', {
      tauriDriverPath: 'tauri-driver', // assumes it's in PATH
    }],
  ],

  framework: 'mocha',
  reporters: ['spec'],

  mochaOpts: {
    ui: 'bdd',
    timeout: 60000,
  },
  
  before: async () => {
    // Give the app time to fully initialize
    await new Promise(resolve => setTimeout(resolve, 2000));
  },
};

Note the maxInstances: 1 — unlike web tests, you generally cannot run multiple desktop app instances in parallel on the same machine without port conflicts and display server issues.

Writing Your First Test

import { browser, $ } from '@wdio/globals';
import { expect } from '@wdio/globals';

describe('Application startup', () => {
  it('shows the main window', async () => {
    const title = await browser.getTitle();
    expect(title).toBe('My Tauri App');
  });

  it('renders the main navigation', async () => {
    const nav = await $('[data-testid="main-nav"]');
    await expect(nav).toBeDisplayed();
  });

  it('navigation links are all present', async () => {
    const links = await $$('[data-testid="nav-link"]');
    expect(links.length).toBeGreaterThanOrEqual(3);
  });
});

The $ and $$ functions are WebdriverIO's CSS selector shortcuts. They work against the WebView's DOM exactly as you'd expect. All the standard WebdriverIO assertions, waits, and element interactions apply.

Interacting with the UI

describe('Settings page', () => {
  before(async () => {
    // Navigate to settings
    const settingsLink = await $('[data-testid="settings-nav"]');
    await settingsLink.click();
    await browser.waitUntil(
      async () => (await $('[data-testid="settings-panel"]')).isDisplayed(),
      { timeout: 5000, timeoutMsg: 'Settings panel did not appear' }
    );
  });

  it('saves theme preference', async () => {
    const darkModeToggle = await $('[data-testid="dark-mode-toggle"]');
    const initialState = await darkModeToggle.getAttribute('aria-checked');
    
    await darkModeToggle.click();
    
    const newState = await darkModeToggle.getAttribute('aria-checked');
    expect(newState).not.toBe(initialState);
    
    // Verify persistence — trigger a save
    const saveButton = await $('[data-testid="save-settings"]');
    await saveButton.click();
    
    const toast = await $('[data-testid="save-toast"]');
    await expect(toast).toBeDisplayed();
    await expect(toast).toHaveText('Settings saved');
  });

  it('validates required fields', async () => {
    const nameInput = await $('[data-testid="display-name-input"]');
    await nameInput.clearValue();
    
    const saveButton = await $('[data-testid="save-settings"]');
    await saveButton.click();
    
    const error = await $('[data-testid="name-error"]');
    await expect(error).toBeDisplayed();
    await expect(error).toHaveText('Display name is required');
  });
});

Testing Tauri Commands via JavaScript Bridge

Tauri commands (Rust functions callable from the frontend via invoke()) are the core of the Tauri architecture. You can't call Rust directly from WebDriver, but you can execute JavaScript in the WebView context that calls invoke() and returns the result:

describe('Tauri commands', () => {
  it('greet command returns personalized message', async () => {
    const result = await browser.execute(async () => {
      // @ts-ignore - Tauri's invoke is injected globally
      const response = await window.__TAURI__.invoke('greet', {
        name: 'TestUser',
      });
      return response;
    });
    
    expect(result).toBe('Hello, TestUser! You\'ve been greeted from Rust!');
  });

  it('get_app_config command returns valid config', async () => {
    const config = await browser.execute(async () => {
      return await window.__TAURI__.invoke('get_app_config');
    });
    
    expect(config).toHaveProperty('version');
    expect(config).toHaveProperty('theme');
    expect(typeof config.version).toBe('string');
  });

  it('handles command errors gracefully', async () => {
    const error = await browser.execute(async () => {
      try {
        await window.__TAURI__.invoke('read_file', { path: '/nonexistent/path' });
        return null;
      } catch (e) {
        return String(e);
      }
    });
    
    expect(error).toContain('No such file');
  });
});

The window.__TAURI__ global is injected by Tauri into every WebView. Using browser.execute() to run async code and return results is the standard WebdriverIO pattern for JavaScript evaluation.

Testing Tauri Events

Tauri has a bidirectional event system. You can listen for events emitted by the Rust backend and also emit events from the frontend. Testing event flows requires setting up listeners before triggering the action:

it('emits download-complete event on file download', async () => {
  // Set up an event listener in the WebView before triggering the action
  await browser.execute(async () => {
    window.__testEventLog = [];
    window.__TAURI__.event.listen('download-complete', (event) => {
      window.__testEventLog.push(event.payload);
    });
  });
  
  // Trigger the download action
  const downloadButton = await $('[data-testid="download-sample"]');
  await downloadButton.click();
  
  // Wait for the event to arrive
  await browser.waitUntil(
    async () => {
      const log = await browser.execute(() => window.__testEventLog);
      return log.length > 0;
    },
    { timeout: 10000, timeoutMsg: 'download-complete event never fired' }
  );
  
  const events = await browser.execute(() => window.__testEventLog);
  expect(events[0]).toHaveProperty('filePath');
  expect(events[0]).toHaveProperty('size');
});

The pattern of storing event data in window.__testEventLog is a reliable bridge between async Tauri events and synchronous WebdriverIO assertions.

Handling Native File Dialogs

Native dialogs are a recurring challenge in desktop testing. Tauri exposes them through its dialog plugin. For testing, mock the dialog at the JavaScript level:

it('opens file and displays content', async () => {
  // Mock the dialog before clicking
  await browser.execute(async () => {
    // Override the open dialog to return a known test file
    const originalDialog = window.__TAURI__.dialog;
    window.__TAURI__.dialog = {
      ...originalDialog,
      open: async () => '/tmp/test-fixture.txt',
    };
  });
  
  const openButton = await $('[data-testid="open-file-button"]');
  await openButton.click();
  
  const contentArea = await $('[data-testid="file-content"]');
  await expect(contentArea).toBeDisplayed();
  
  // Verify the file path is shown in the UI
  const pathLabel = await $('[data-testid="current-file-path"]');
  await expect(pathLabel).toHaveText('/tmp/test-fixture.txt');
});

Multi-Window Testing

Tauri supports creating additional windows. WebdriverIO handles multiple windows through window handles:

it('opens and interacts with a secondary window', async () => {
  const mainWindowHandle = await browser.getWindowHandle();
  
  // Trigger secondary window creation
  await $('[data-testid="open-about"]').click();
  
  // Wait for the new window
  await browser.waitUntil(
    async () => (await browser.getWindowHandles()).length > 1,
    { timeout: 5000 }
  );
  
  const handles = await browser.getWindowHandles();
  const secondaryHandle = handles.find(h => h !== mainWindowHandle);
  
  // Switch to the secondary window
  await browser.switchToWindow(secondaryHandle);
  
  const version = await $('[data-testid="app-version"]');
  await expect(version).toBeDisplayed();
  
  // Close the secondary window and return to main
  await browser.closeWindow();
  await browser.switchToWindow(mainWindowHandle);
});

Test Helpers and Custom Commands

For complex setups, add WebdriverIO custom commands to reduce repetition:

// tests/helpers/commands.ts
import { browser } from '@wdio/globals';

export function addCustomCommands() {
  browser.addCommand('invokeCommand', async function(command: string, args: Record<string, unknown> = {}) {
    return this.execute(async (cmd, a) => {
      return await window.__TAURI__.invoke(cmd, a);
    }, command, args);
  });

  browser.addCommand('waitForToast', async function(text: string) {
    await this.waitUntil(
      async () => {
        const toasts = await this.$$('[data-testid="toast"]');
        for (const toast of toasts) {
          const t = await toast.getText();
          if (t.includes(text)) return true;
        }
        return false;
      },
      { timeout: 5000, timeoutMsg: `Toast with text "${text}" never appeared` }
    );
  });
}

Known Limitations and Workarounds

WebKitGTK on Linux: The webkit2gtk-driver version must match the WebKitGTK version bundled with your system. On older Ubuntu versions, you may need to install a newer WebKitGTK via PPA. Check the Tauri prerequisite docs for the specific version matrix.

macOS quarantine: Debug builds on macOS may trigger quarantine, causing the app to fail to launch in CI. Run xattr -cr src-tauri/target/debug/your-app in the CI step before running tests.

Missing window.__TAURI__: If you're loading a remote URL in development and Tauri's JS injection isn't running, window.__TAURI__ may be undefined. Ensure you're testing the actual built Tauri app, not just opening the dev server URL in a regular browser.

Conclusion

Testing Tauri apps with WebDriver requires more setup than Playwright for Electron, but the fundamental pattern is sound: connect through tauri-driver, drive the WebView DOM with standard WebdriverIO APIs, and bridge to Tauri-specific functionality through browser.execute() and the window.__TAURI__ global. The investment pays off as your app grows — WebDriver-based tests catch regressions across the full frontend-to-Rust-backend integration, which is exactly the layer most likely to break during development.

Read more

Start now free