Tauri Testing with Rust: Unit and Integration Testing for Desktop Apps
Tauri is a framework for building lightweight desktop apps with a Rust backend and a web frontend. Its architecture—Rust commands exposed to the frontend via a bridge—creates a natural testing boundary: Rust logic is tested with Rust's toolchain, frontend logic with JavaScript testing tools, and the integration between them with WebDriver.
This guide covers the full testing stack for Tauri applications.
Tauri's Architecture and Testing Strategy
A Tauri app has three layers:
- Rust backend — business logic, file system access, native APIs, exposed as "commands"
- IPC bridge — serialized JSON messages between frontend and backend
- Web frontend — HTML/CSS/JS (any framework) that calls
invoke()to reach Rust
Testing strategy maps directly to this layering:
| Layer | Tool | Speed |
|---|---|---|
| Rust commands | cargo test |
Milliseconds |
| Frontend components | Jest/Vitest | Seconds |
| IPC + integration | WebDriver / tauri-driver | Minutes |
Start with comprehensive Rust unit tests, add frontend component tests, and use integration tests sparingly for critical paths.
Unit Testing Rust Commands
Tauri commands are Rust functions annotated with #[tauri::command]. The key to testing them is to keep business logic separate from the Tauri-specific annotation:
// src-tauri/src/commands/files.rs
use std::path::PathBuf;
use tauri::State;
use crate::AppState;
#[derive(Debug, serde::Serialize)]
pub struct FileInfo {
pub path: String,
pub size: u64,
pub is_directory: bool,
}
// Pure business logic — no Tauri types, fully testable
pub fn read_directory_entries(path: &PathBuf) -> Result<Vec<FileInfo>, String> {
let entries = std::fs::read_dir(path)
.map_err(|e| format!("Failed to read directory: {}", e))?;
let mut result = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let metadata = entry.metadata().map_err(|e| e.to_string())?;
result.push(FileInfo {
path: entry.path().to_string_lossy().to_string(),
size: metadata.len(),
is_directory: metadata.is_dir(),
});
}
Ok(result)
}
// Tauri command wrapper — thin, no business logic
#[tauri::command]
pub fn list_directory(path: String, state: State<AppState>) -> Result<Vec<FileInfo>, String> {
let path = PathBuf::from(&path);
read_directory_entries(&path)
}Test the pure function directly:
// src-tauri/src/commands/files.rs (continued)
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_temp_dir() -> TempDir {
let dir = tempfile::tempdir().expect("Failed to create temp dir");
fs::write(dir.path().join("file.txt"), "content").unwrap();
fs::write(dir.path().join("notes.md"), "# Notes").unwrap();
fs::create_dir(dir.path().join("subdir")).unwrap();
dir
}
#[test]
fn test_read_directory_returns_all_entries() {
let dir = setup_temp_dir();
let result = read_directory_entries(dir.path()).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_read_directory_identifies_directories() {
let dir = setup_temp_dir();
let result = read_directory_entries(dir.path()).unwrap();
let dirs: Vec<_> = result.iter().filter(|e| e.is_directory).collect();
let files: Vec<_> = result.iter().filter(|e| !e.is_directory).collect();
assert_eq!(dirs.len(), 1);
assert_eq!(files.len(), 2);
}
#[test]
fn test_read_directory_reports_file_sizes() {
let dir = setup_temp_dir();
let result = read_directory_entries(dir.path()).unwrap();
let file = result.iter().find(|e| e.path.ends_with("file.txt")).unwrap();
assert_eq!(file.size, 7); // "content" = 7 bytes
}
#[test]
fn test_read_nonexistent_directory_returns_error() {
let result = read_directory_entries(&PathBuf::from("/nonexistent/path/abc"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to read directory"));
}
}Run with:
cd src-tauri
cargo <span class="hljs-built_in">testTesting State Management
Tauri's managed state is passed to commands via State<T>. Test state logic independently:
// src-tauri/src/state.rs
use std::sync::Mutex;
#[derive(Default)]
pub struct AppState {
pub recent_files: Mutex<Vec<String>>,
pub max_recent: usize,
}
impl AppState {
pub fn new(max_recent: usize) -> Self {
Self {
recent_files: Mutex::new(Vec::new()),
max_recent,
}
}
pub fn add_recent_file(&self, path: String) {
let mut files = self.recent_files.lock().unwrap();
files.retain(|f| f != &path); // remove duplicate
files.insert(0, path);
if files.len() > self.max_recent {
files.truncate(self.max_recent);
}
}
pub fn get_recent_files(&self) -> Vec<String> {
self.recent_files.lock().unwrap().clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_recent_file_prepends_to_list() {
let state = AppState::new(10);
state.add_recent_file("/home/user/a.txt".into());
state.add_recent_file("/home/user/b.txt".into());
let files = state.get_recent_files();
assert_eq!(files[0], "/home/user/b.txt");
assert_eq!(files[1], "/home/user/a.txt");
}
#[test]
fn test_add_recent_file_removes_duplicates() {
let state = AppState::new(10);
state.add_recent_file("/home/user/a.txt".into());
state.add_recent_file("/home/user/b.txt".into());
state.add_recent_file("/home/user/a.txt".into()); // duplicate
let files = state.get_recent_files();
assert_eq!(files.len(), 2);
assert_eq!(files[0], "/home/user/a.txt");
}
#[test]
fn test_recent_files_respects_max_limit() {
let state = AppState::new(3);
for i in 0..5 {
state.add_recent_file(format!("/file{}.txt", i));
}
let files = state.get_recent_files();
assert_eq!(files.len(), 3);
}
}Testing Async Commands
Tauri supports async commands. Test them with Tokio:
// src-tauri/src/commands/network.rs
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateInfo {
pub version: String,
pub url: String,
}
pub async fn fetch_update_info(base_url: &str) -> Result<UpdateInfo, String> {
let client = reqwest::Client::new();
let response = client
.get(format!("{}/releases/latest", base_url))
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
response
.json::<UpdateInfo>()
.await
.map_err(|e| format!("Parse error: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};
#[tokio::test]
async fn test_fetch_update_returns_version() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/releases/latest"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"version": "2.0.0",
"url": "https://example.com/download"
})))
.mount(&mock_server)
.await;
let result = fetch_update_info(&mock_server.uri()).await.unwrap();
assert_eq!(result.version, "2.0.0");
}
#[tokio::test]
async fn test_fetch_update_handles_server_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/releases/latest"))
.respond_with(ResponseTemplate::new(500))
.mount(&mock_server)
.await;
let result = fetch_update_info(&mock_server.uri()).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("HTTP 500"));
}
}Add to Cargo.toml:
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
wiremock = "0.5"
tempfile = "3"Frontend Testing (Vitest)
The frontend side of Tauri can use any JS testing tool. Vitest works well with Vite-based Tauri frontends:
npm install --save-dev vitest @vitest/ui jsdom @testing-library/reactMock the @tauri-apps/api package:
// src/__mocks__/@tauri-apps/api/tauri.js
export const invoke = vi.fn();// src/components/FileList.test.jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { invoke } from '@tauri-apps/api/tauri';
import FileList from './FileList';
vi.mock('@tauri-apps/api/tauri');
describe('FileList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('displays files returned from Rust', async () => {
invoke.mockResolvedValueOnce([
{ path: '/home/user/notes.txt', size: 1024, is_directory: false },
{ path: '/home/user/docs', size: 0, is_directory: true },
]);
render(<FileList directory="/home/user" />);
await waitFor(() => {
expect(screen.getByText('notes.txt')).toBeInTheDocument();
expect(screen.getByText('docs')).toBeInTheDocument();
});
expect(invoke).toHaveBeenCalledWith('list_directory', {
path: '/home/user',
});
});
it('shows error message on command failure', async () => {
invoke.mockRejectedValueOnce('Failed to read directory: Permission denied');
render(<FileList directory="/root" />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Permission denied');
});
});
});Integration Testing with WebDriver
For tests that exercise the full Tauri runtime, use tauri-driver with WebdriverIO:
npm install --save-dev @wdio/cli @wdio/tauri-service// wdio.conf.js
const os = require('os');
exports.config = {
specs: ['./tests/e2e/**/*.spec.js'],
capabilities: [{
'tauri:options': {
application: './src-tauri/target/release/myapp',
},
maxInstances: 1,
}],
services: ['tauri'],
framework: 'mocha',
reporters: ['spec'],
};// tests/e2e/file-operations.spec.js
describe('File operations', () => {
it('can open a file dialog and display content', async () => {
const openButton = await $('button[data-testid="open-file"]');
await openButton.click();
// In real tests, you'd interact with the native dialog
// or mock it through Tauri's test utilities
const content = await $('[data-testid="file-content"]');
await content.waitForDisplayed({ timeout: 5000 });
});
});Build in release mode before running integration tests:
cargo build --release
npm run test:e2eCI Configuration
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
rust-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: cd src-tauri && cargo test
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.0-dev libssl-dev libgtk-3-dev
- run: cd src-tauri && cargo build --release
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: xvfb-run npm run test:e2eTesting Patterns That Work
Thin commands, pure functions. Keep #[tauri::command] functions as thin wrappers. All logic goes in plain Rust functions that are easy to call in tests.
Test error handling explicitly. Tauri commands return Result<T, String> — test both the Ok and Err paths. The frontend gets these as resolved/rejected promises.
Use tempfile for file system tests. Never use real paths in tests. tempfile::tempdir() creates isolated directories that clean up automatically.
Mock async at the HTTP boundary. Use wiremock to mock HTTP servers in tests. Don't mock reqwest itself — that couples tests to implementation.
Separate Tauri state from business logic. State structs (AppState) should be plain Rust structs with no Tauri dependencies. This makes them testable without a running Tauri app.
Summary
Tauri's architecture naturally supports testing at every layer. Rust's built-in test runner handles unit and integration tests for commands with zero configuration. Vitest or Jest covers the frontend. WebDriver handles end-to-end tests for critical paths.
The key is the separation pattern: keep business logic in plain Rust functions, make commands thin wrappers, and you can test 90% of your app with fast, reliable unit tests.