zudo-test-wisdom
GitHub repository

Type to search...

to open search from anywhere

Tauri App Testing

Testing patterns specific to Tauri v2 desktop applications -- the dual WebKit-first (local) + Chromium-CI strategy, the core crate pattern, backend bridge mocking, and the full 8-step escalation ladder.

WebKit First: Matching the Production Engine

Tauri's rendering engine is platform-dependent, not a single engine everywhere: WKWebView on macOS, WebKitGTK on Linux, and WebView2 (Chromium-based) on Windows. When writing Playwright E2E tests for a Tauri frontend, include WebKit to match the macOS/Linux production engine, and include Chromium too — it matches Windows exactly and runs more reliably in headless CI:

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  projects: [
    {
      name: "webkit",
      use: { ...devices["Desktop Safari"] },
    },
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
  ],
});

Warning

Testing only in Chromium gives false confidence for macOS and Linux users — those platforms render the production app in WebKit (WKWebView / WebKitGTK), so Chromium-only coverage can miss real WebKit-specific bugs.

Recommended dual strategy:

  • WebKit for local verification — matches the production engine on macOS and Linux. Use this for any test that exercises WebKit-specific behavior (CSS quirks, focus handling, keyboard shortcuts via the app's shortcut engine).

  • Chromium for CI — more reliable in headless CI environments, and for Windows users it isn't a tradeoff at all: Tauri's Windows webview (WebView2) is Chromium-based, so a Chromium CI run gives Windows real engine coverage for free.

The key rule: always verify locally with WebKit for macOS/Linux coverage, and keep both projects in your Playwright matrix. Using Chromium in CI for DOM-only tests is a pragmatic tradeoff for macOS/Linux, and genuine coverage for Windows — not a shortcut either way.

The Core Crate Pattern

Extract platform-independent business logic into a separate Rust crate that has no Tauri dependencies. This crate can be tested with standard cargo test without needing a Tauri application context:

src-tauri/
  Cargo.toml          # depends on core + tauri
  src/
    main.rs           # Tauri setup, commands
    commands.rs       # #[tauri::command] handlers
core/
  Cargo.toml          # no Tauri dependency
  src/
    lib.rs
    settings.rs       # pure Rust logic
    file_ops.rs       # file operations
    transforms.rs     # data transforms
# core/Cargo.toml
[package]
name = "myapp-core"
version = "0.1.0"
edition = "2021"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# No tauri dependency here
// core/src/settings.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, PartialEq)]
pub struct Settings {
    pub theme: String,
    pub font_size: u32,
}

impl Settings {
    pub fn with_theme(mut self, theme: &str) -> Self {
        self.theme = theme.to_string();
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_with_theme() {
        let settings = Settings {
            theme: "light".to_string(),
            font_size: 14,
        };
        let updated = settings.with_theme("dark");
        assert_eq!(updated.theme, "dark");
        assert_eq!(updated.font_size, 14);
    }
}

Tip

The core crate pattern lets you run cargo test in CI without building the full Tauri app. This is fast, reliable, and catches logic bugs early.

Warning

Once cd core && cargo test becomes the project's only Rust gate, a #[cfg(test)] module added to the app crate because it touches Tauri dependencies silently never runs — not in CI, not in the pre-push gate, and not in any scheduled re-exam. This happened in practice in zudo-text (a Tauri desktop text editor; private monorepo): seven security-relevant transport tests covering redirect fail-closed behavior, SSE boundary parsing, and token redaction sat in an app crate outside every automated tier (zudolab/zudo-text#5855).

Close both sides of the gap: either extract the pure logic into the core crate, or wire an explicit app-crate test run on a runner that can build the Tauri crate. When adopting the core crate pattern, also audit the app crate for stray #[cfg(test)] modules so existing tests do not disappear from the gate unnoticed.

Backend Bridge Mock Adapter Pattern

In a Tauri app, the frontend communicates with the Rust backend through IPC commands. For frontend testing, mock this bridge at the adapter seam. The createMockBackend() example below is the minimal form: a table of functions with canned defaults and targeted overrides, which is the right fit for unit and component tests.

// src/adapters/backend.ts -- the real adapter
import { invoke } from "@tauri-apps/api/core";

export interface BackendAdapter {
  getSettings(): Promise<Settings>;
  saveSettings(settings: Settings): Promise<void>;
  readFile(path: string): Promise<string>;
}

export const tauriBackend: BackendAdapter = {
  async getSettings() {
    return invoke<Settings>("get_settings");
  },
  async saveSettings(settings) {
    return invoke("save_settings", { settings });
  },
  async readFile(path) {
    return invoke<string>("read_file", { path });
  },
};
// src/adapters/mock-backend.ts -- for testing
import type { BackendAdapter, Settings } from "./backend";

export function createMockBackend(
  overrides: Partial<BackendAdapter> = {}
): BackendAdapter {
  return {
    async getSettings() {
      return { theme: "dark", fontSize: 14 };
    },
    async saveSettings() {},
    async readFile() {
      return "mock file content";
    },
    ...overrides,
  };
}
// In the app entry point
import { tauriBackend } from "./adapters/backend";
import { createMockBackend } from "./adapters/mock-backend";

// Runtime detection, not build-mode detection: import.meta.env.MODE === "test"
// only ever fires under Vitest. Playwright drives the built app in a real
// browser, so the mock must be selected by checking for the Tauri WebView's
// injected global -- otherwise E2E runs would hit the real IPC bridge.
const backend =
  "__TAURI_INTERNALS__" in window ? tauriBackend : createMockBackend();

In a packaged Tauri app, the transport is Rust-native HTTP and SSE behind Tauri IPC channels. Browser-level network mocking such as MSW cannot see that traffic, so the adapter seam is a choice forced by the architecture, not merely a testing preference. See Choosing the External-Service Test Seam for the full seam decision.

A full-journey e2e test needs the stateful fake service form instead of this minimal stub: an in-memory service that preserves the real server's guards, exposes deterministic failure controls, and records a redacted request trace. Keep the small adapter for focused unit and component cases; use the stateful form when the journey must prove that earlier requests drive later responses across the client state machine.

Automating the Real Tauri Boundary

The browser lane and the native lane answer different questions. Playwright against a dev server proves frontend behavior in a real browser engine; it does not prove Tauri IPC, native window creation, cross-window event delivery, AppKit focus, or OS close handling. For those cases, build a dedicated test-only Tauri bundle with an embedded WebDriver and drive its real WebViews through WebdriverIO.

Treat the native lane as a narrow integration test, not a replacement for browser E2E:

  1. Gate the Rust WebDriver plugin behind an opt-in Cargo feature and a dedicated Tauri capability/configuration.

  2. Give the bundle a unique identifier, isolated fixture state, incognito WebView stores, and no personal account credentials.

  3. Serialize the suite when it owns a fixed driver port or creates real application windows.

  4. Use W3C keyboard and pointer actions. A DOM dispatchEvent() is not evidence that native focus, clipboard, or editor input paths work.

  5. Capture sanitized evidence from every WebDriver window on failure, then clean fixture state and terminate only the exact test-bundle process.

  6. Separately prove that the ordinary production build excludes the driver dependency, permission, alternate bootstrap, and test markers.

This lane is especially valuable for a regression that crosses renderer realms. A jsdom test can model separate module graphs and a shared fake backend, but only the native lane can establish that a real child window sent its handback, the host applied it before restore, and the OS close destroyed the child exactly once.

The 8-Step Escalation Ladder

For Tauri apps, the escalation ladder extends beyond the standard testing levels:

StepMethodWhat It Catches
1cargo test on core cratePure Rust logic bugs
2Vitest unit testsFrontend logic bugs
3Vitest + jsdom component testsComponent behavior bugs
4Playwright WebKit (dev server)Frontend rendering bugs
5Playwright WebKit (production build)Build-specific frontend bugs
6verify-ui + headless-browserCSS/visual bugs in frontend
7Test-only Tauri bundle + embedded WebDriver (or manual dev-mode fallback)IPC, native-window, focus, and close-lifecycle bugs
8Tauri production build manual testFull app packaging bugs

Note

Steps 1-6 are broadly automatable and should be in CI. Step 7 can also be automated on a platform-capable runner with a feature-isolated embedded WebDriver; otherwise it falls back to manual testing. Step 8 remains the final production-bundle inspection and manual journey.

Step-by-Step Guide

Steps 1-3: Fast, automatable, no browser needed

# Step 1: Rust core logic
cd core && cargo test

# Step 2: Frontend unit tests
pnpm vitest --project unit

# Step 3: Frontend component tests
pnpm vitest --project component

Steps 4-6: Need a browser, still automatable

# Step 4: E2E against dev server (WebKit only)
# playwright.config.ts's webServer starts `pnpm dev` and waits for it to be
# ready -- no manual backgrounding, so Playwright can't race an unready server.
npx playwright test --project=webkit

# Step 5: E2E against production build
pnpm build
# webServer's command is `pnpm preview` here (see CI Configuration below);
# Playwright starts it and waits for port 4173 before any test runs.
npx playwright test --project=webkit

# Step 6: Visual verification -- extract computed styles + screenshots, then
# compare the JSON `styles` output against the expected values (verify-ui is
# a plain Node script, not a CLI with its own --check flag)
node $HOME/.claude/skills/verify-ui/scripts/verify-styles.mjs \
  "http://localhost:4173" ".app" "./verify-ui-out" "400,800,1200" "light,dark"

Steps 7-8: Need full Tauri app

# Step 7: isolated native integration lane (project-specific command)
pnpm native-e2e:build
pnpm native-e2e:smoke
# If no native driver harness exists, test the same journey manually in tauri dev.

# Step 8: Production build
pnpm tauri build
# Test the built .dmg / .msi / .AppImage

CI Configuration for Tauri Projects

Point webServer at the already-built preview server instead of backgrounding a build+boot chain in the workflow step — this mirrors the Production Build Verification pattern from the sibling Playwright page:

// playwright.config.ts (excerpt)
export default defineConfig({
  webServer: {
    command: "pnpm preview",
    port: 4173,
    reuseExistingServer: !process.env.CI,
  },
  // ...projects from "WebKit First: Matching the Production Engine" above
});
# .github/workflows/test.yml
jobs:
  rust-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cd core && cargo test

  frontend-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install
      - run: pnpm vitest run

  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install
      - run: npx playwright install webkit --with-deps
      - run: pnpm build
      - run: npx playwright test --project=webkit

Warning

Tauri E2E tests need the WebKit browser binary installed regardless of OS — Playwright always drives its own bundled WebKit build, never the system's Safari. npx playwright install webkit is required on every runner, macOS included. Only the --with-deps flag (which installs Linux system libraries WebKit needs) is Ubuntu-specific; on a macOS runner, npx playwright install webkit alone is enough.

The Scheduled Re-exam as Safety Net

Tauri keyboard-shortcut and native-platform tests fall into the platform-incapable category: they are only trustworthy on real WebKit/macOS. The CI configuration above covers the CI-safe layers (frontend unit tests and DOM-only Playwright specs), but the thin native-integration layer — @interactive and @macos-only tagged specs — requires a scheduled macOS job as the safety net.

The local heavy lane (exam) is convenient but bypassable; it is also machine-dependent (the same suite false-reds dozens of specs on a Linux/WSL2 host). A scheduled CI re-exam on a macOS runner provides the paper trail that makes these platform-bound specs part of a real regression gate.

See Scheduled Re-exam & Night Exam for the complete workflow: the cron + workflow_dispatch skeleton, deduped failure issue filing, and on-demand pre-merge dispatch.

Revision History

CreatedUpdated