Tauri App Testing: Testing Rust-Based Desktop Applications
Tauri is a framework for building lightweight desktop applications using web technologies for the frontend and Rust for the backend. Unlike Electron, which bundles Chromium, Tauri uses the OS's native WebView — resulting in apps that are typically 10-100x smaller. Testing a Tauri app means testing two distinct layers: the Rust backend (commands, plugins, system integration) and the web frontend (your JavaScript/TypeScript UI).
Tauri's Architecture for Testers
┌─────────────────────────────────┐
│ Frontend (WebView) │
│ HTML/CSS/JavaScript/TypeScript│
│ React / Vue / Svelte / etc. │
└──────────────┬──────────────────┘
│ invoke() / events
▼
┌─────────────────────────────────┐
│ Tauri Core (Rust) │
│ Commands, Plugins, Filesystem │
│ Notifications, System Tray │
└─────────────────────────────────┘Test each layer with the appropriate tools:
- Rust backend: Rust's built-in test framework (
#[test],#[cfg(test)]) - Frontend: Vitest or Jest for unit tests
- End-to-end: Playwright via the
tauri-driverWebDriver implementation
Testing the Rust Backend
Rust has excellent built-in testing. Tauri commands are just Rust functions — test them directly:
// src-tauri/src/commands.rs
use tauri::State;
use std::sync::Mutex;
pub struct AppState {
pub count: Mutex<i32>,
}
#[tauri::command]
pub fn increment(state: State<AppState>) -> i32 {
let mut count = state.count.lock().unwrap();
*count += 1;
*count
}
#[tauri::command]
pub fn get_count(state: State<AppState>) -> i32 {
*state.count.lock().unwrap()
}
#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[cfg(test)]
mod tests {
use super::*;
// Pure function — test directly
#[test]
fn test_greet() {
assert_eq!(greet("Alice"), "Hello, Alice!");
assert_eq!(greet(""), "Hello, !");
}
#[test]
fn test_greet_with_special_chars() {
assert_eq!(greet("José"), "Hello, José!");
}
}For commands that use State, extract the core logic into a regular function and test that:
// Extract business logic
pub fn increment_counter(count: &mut i32) -> i32 {
*count += 1;
*count
}
#[tauri::command]
pub fn increment(state: State<AppState>) -> i32 {
let mut count = state.count.lock().unwrap();
increment_counter(&mut count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_increment_counter() {
let mut count = 0;
assert_eq!(increment_counter(&mut count), 1);
assert_eq!(increment_counter(&mut count), 2);
assert_eq!(increment_counter(&mut count), 3);
}
}Run Rust tests:
cd src-tauri
cargo <span class="hljs-built_in">test
<span class="hljs-comment"># With output
cargo <span class="hljs-built_in">test -- --nocapture
<span class="hljs-comment"># Specific test
cargo <span class="hljs-built_in">test test_greetTesting File System Operations
Rust file operations should be tested with temporary directories:
use std::fs;
use tempdir::TempDir; // cargo add tempdir --dev
pub fn read_config(path: &str) -> Result<String, std::io::Error> {
fs::read_to_string(path)
}
pub fn write_config(path: &str, content: &str) -> Result<(), std::io::Error> {
fs::write(path, content)
}
#[cfg(test)]
mod tests {
use super::*;
use tempdir::TempDir;
#[test]
fn test_write_and_read_config() {
let dir = TempDir::new("tauri-test").unwrap();
let config_path = dir.path().join("config.json");
let path_str = config_path.to_str().unwrap();
let content = r#"{"theme": "dark"}"#;
write_config(path_str, content).unwrap();
let read_back = read_config(path_str).unwrap();
assert_eq!(read_back, content);
}
#[test]
fn test_read_nonexistent_file() {
let result = read_config("/nonexistent/path.json");
assert!(result.is_err());
}
}Testing the Frontend
The frontend is a standard web application. Test it with your usual JavaScript tools:
npm install --save-dev vitest @vitest/coverage-v8 jsdom// src/lib/formatter.test.js
import { describe, test, expect } from 'vitest';
import { formatFileSize, truncateFilename } from './formatter';
describe('formatFileSize', () => {
test('formats bytes', () => {
expect(formatFileSize(512)).toBe('512 B');
});
test('formats kilobytes', () => {
expect(formatFileSize(1024)).toBe('1.0 KB');
expect(formatFileSize(2048)).toBe('2.0 KB');
});
test('formats megabytes', () => {
expect(formatFileSize(1048576)).toBe('1.0 MB');
});
});Mocking Tauri APIs in Frontend Tests
The frontend calls Tauri commands via @tauri-apps/api:
import { invoke } from '@tauri-apps/api/tauri';
export async function getFiles(directory) {
return invoke('list_files', { directory });
}In tests, mock the module:
// src/lib/fileService.test.js
import { describe, test, expect, vi } from 'vitest';
import { getFiles } from './fileService';
// Mock the entire @tauri-apps/api/tauri module
vi.mock('@tauri-apps/api/tauri', () => ({
invoke: vi.fn()
}));
import { invoke } from '@tauri-apps/api/tauri';
describe('getFiles', () => {
test('calls invoke with correct command and args', async () => {
invoke.mockResolvedValue([
{ name: 'document.txt', size: 1024 },
{ name: 'image.png', size: 204800 }
]);
const files = await getFiles('/home/user/documents');
expect(invoke).toHaveBeenCalledWith('list_files', {
directory: '/home/user/documents'
});
expect(files).toHaveLength(2);
expect(files[0].name).toBe('document.txt');
});
test('propagates errors from invoke', async () => {
invoke.mockRejectedValue(new Error('Permission denied'));
await expect(getFiles('/root')).rejects.toThrow('Permission denied');
});
});For Tauri event listeners, mock @tauri-apps/api/event:
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn(() => Promise.resolve(() => {})), // returns unlisten function
emit: vi.fn(),
}));End-to-End Testing with tauri-driver
Tauri provides tauri-driver for WebDriver-based E2E testing:
# Install tauri-driver
cargo install tauri-driver
<span class="hljs-comment"># Install WebDriver client
npm install --save-dev @tauri-apps/driver webdriverio// e2e/counter.test.js
const { remote } = require('webdriverio');
const { spawn, spawnSync } = require('child_process');
let tauriDriver;
let client;
beforeEach(async () => {
// Build the app in test mode
spawnSync('cargo', ['tauri', 'build', '--debug'], { stdio: 'inherit' });
// Start tauri-driver
tauriDriver = spawn(
process.env.TAURI_DRIVER || 'tauri-driver',
[],
{ stdio: [null, process.stdout, process.stderr] }
);
client = await remote({
capabilities: {
tauri: {
application: './src-tauri/target/debug/my-app'
}
}
});
});
afterEach(async () => {
await client?.deleteSession();
tauriDriver?.kill();
});
test('counter increments on click', async () => {
const button = await client.$('[data-testid="increment-btn"]');
const counter = await client.$('[data-testid="counter-value"]');
expect(await counter.getText()).toBe('0');
await button.click();
expect(await counter.getText()).toBe('1');
await button.click();
expect(await counter.getText()).toBe('2');
});Using Playwright with Tauri (Alternative)
Playwright can also work with Tauri by connecting to the WebView's debug port:
const { chromium } = require('playwright');
const { spawn } = require('child_process');
test('tauri app with playwright', async () => {
// Launch the Tauri app with remote debugging
const app = spawn('./src-tauri/target/debug/my-app', [], {
env: { ...process.env, WEBKIT_DISABLE_SANDBOX: '1' }
});
// Connect Playwright to the debug endpoint
const browser = await chromium.connectOverCDP('http://localhost:9222');
const context = browser.contexts()[0];
const page = context.pages()[0];
await page.click('[data-testid="increment-btn"]');
await expect(page.locator('[data-testid="counter-value"]')).toHaveText('1');
await browser.close();
app.kill();
});CI Configuration
Tauri requires platform-specific system libraries:
# .github/workflows/test.yml
jobs:
test-rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
- run: cd src-tauri && cargo test
test-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm testMonitoring Tauri App Backend Services
Unit and E2E tests run during development. For production monitoring of the server-side APIs your Tauri app communicates with, HelpMeTest provides continuous health checks and alerts. When your Tauri app's backend API becomes slow or unavailable, you know before users report issues.
Summary
Testing Tauri apps means testing two things separately:
- Rust backend: Extract logic from Tauri command handlers and test with
#[test]. UseTempDirfor file system tests. - Frontend: Test with Vitest/Jest; mock
@tauri-apps/api/tauriwithvi.mockorjest.mock - E2E: Use
tauri-driverwith WebdriverIO or Playwright connecting via CDP
The discipline of separating command logic from Tauri state makes Rust code more testable by design — the same principle that makes any code unit-testable. Write the logic first, wrap it in a command second.