Tauri App Testing Strategies: Rust Backend and WebView Frontend
Tauri builds desktop applications with a Rust backend and a web frontend (React, Vue, Svelte, or plain HTML/JS). Testing Tauri apps requires a split strategy: Rust unit tests for backend commands, JavaScript/TypeScript tests for the frontend, and integration tests that validate the communication between them.
This guide covers testing every layer of a Tauri application.
Tauri Architecture
┌─────────────────────────────┐
│ WebView (Frontend) │ ← HTML/CSS/JS (React, Vue, etc.)
│ Vitest / Testing Lib │
├─────────────────────────────┤
│ Tauri Bridge (IPC) │ ← invoke() calls Rust commands
│ Mock tauri.invoke() │
├─────────────────────────────┤
│ Rust Core (Backend) │ ← Rust commands, file I/O, native APIs
│ cargo test │
└─────────────────────────────┘Testing Rust Commands with cargo test
Tauri commands are annotated Rust functions. Test them like any Rust function:
// src-tauri/src/commands/file_ops.rs
use std::fs;
use std::path::PathBuf;
use tauri::command;
#[derive(serde::Serialize, serde::Deserialize, Debug)]
pub struct FileInfo {
pub path: String,
pub name: String,
pub size: u64,
pub is_dir: bool,
}
#[command]
pub fn read_directory(path: String) -> Result<Vec<FileInfo>, String> {
let dir_path = PathBuf::from(&path);
if !dir_path.exists() {
return Err(format!("Path does not exist: {}", path));
}
if !dir_path.is_dir() {
return Err(format!("Path is not a directory: {}", path));
}
let entries = fs::read_dir(&dir_path)
.map_err(|e| format!("Failed to read directory: {}", e))?;
let mut files = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let metadata = entry.metadata()
.map_err(|e| format!("Failed to read metadata: {}", e))?;
files.push(FileInfo {
path: entry.path().to_string_lossy().to_string(),
name: entry.file_name().to_string_lossy().to_string(),
size: metadata.len(),
is_dir: metadata.is_dir(),
});
}
Ok(files)
}
#[command]
pub fn write_file(path: String, content: String) -> Result<(), String> {
fs::write(&path, &content)
.map_err(|e| format!("Failed to write file: {}", e))
}// src-tauri/src/commands/file_ops_test.rs
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_temp_dir() -> TempDir {
tempfile::tempdir().expect("Failed to create temp dir")
}
#[test]
fn test_read_directory_returns_files() {
let temp = setup_temp_dir();
// Create test files
fs::write(temp.path().join("file1.txt"), "content 1").unwrap();
fs::write(temp.path().join("file2.txt"), "content 2").unwrap();
fs::create_dir(temp.path().join("subdir")).unwrap();
let result = read_directory(temp.path().to_string_lossy().to_string());
assert!(result.is_ok());
let files = result.unwrap();
assert_eq!(files.len(), 3);
let names: Vec<&str> = files.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"file1.txt"));
assert!(names.contains(&"file2.txt"));
assert!(names.contains(&"subdir"));
}
#[test]
fn test_read_directory_identifies_directories() {
let temp = setup_temp_dir();
fs::create_dir(temp.path().join("mydir")).unwrap();
let result = read_directory(temp.path().to_string_lossy().to_string()).unwrap();
let dir = result.iter().find(|f| f.name == "mydir").unwrap();
assert!(dir.is_dir);
}
#[test]
fn test_read_directory_nonexistent_path() {
let result = read_directory("/nonexistent/path/12345".to_string());
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_read_directory_file_not_directory() {
let temp = setup_temp_dir();
let file_path = temp.path().join("file.txt");
fs::write(&file_path, "content").unwrap();
let result = read_directory(file_path.to_string_lossy().to_string());
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a directory"));
}
#[test]
fn test_write_file_creates_file() {
let temp = setup_temp_dir();
let file_path = temp.path().join("output.txt").to_string_lossy().to_string();
let result = write_file(file_path.clone(), "Hello, Tauri!".to_string());
assert!(result.is_ok());
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "Hello, Tauri!");
}
#[test]
fn test_write_file_overwrites_existing() {
let temp = setup_temp_dir();
let file_path = temp.path().join("existing.txt").to_string_lossy().to_string();
write_file(file_path.clone(), "original".to_string()).unwrap();
write_file(file_path.clone(), "updated".to_string()).unwrap();
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "updated");
}
}Run: cargo test --manifest-path src-tauri/Cargo.toml
Testing State Management in Rust
Tauri's state management (managed state) can be unit tested:
// src-tauri/src/state/app_state.rs
use std::sync::Mutex;
#[derive(Default)]
pub struct AppSettings {
pub theme: String,
pub font_size: u32,
pub auto_save: bool,
}
pub struct AppState {
pub settings: Mutex<AppSettings>,
}
impl AppState {
pub fn new() -> Self {
Self {
settings: Mutex::new(AppSettings {
theme: "light".to_string(),
font_size: 14,
auto_save: true,
}),
}
}
}
#[tauri::command]
pub fn update_theme(state: tauri::State<AppState>, theme: String) -> Result<(), String> {
let mut settings = state.settings.lock()
.map_err(|_| "Failed to lock settings".to_string())?;
settings.theme = theme;
Ok(())
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_state_default_values() {
let state = AppState::new();
let settings = state.settings.lock().unwrap();
assert_eq!(settings.theme, "light");
assert_eq!(settings.font_size, 14);
assert!(settings.auto_save);
}
#[test]
fn test_settings_mutation() {
let state = AppState::new();
{
let mut settings = state.settings.lock().unwrap();
settings.theme = "dark".to_string();
settings.font_size = 16;
}
let settings = state.settings.lock().unwrap();
assert_eq!(settings.theme, "dark");
assert_eq!(settings.font_size, 16);
}
}Testing the Frontend (Mocking tauri.invoke)
In the browser environment, Tauri commands are called via @tauri-apps/api:
// src/hooks/useFileSystem.ts
import { invoke } from '@tauri-apps/api/tauri';
interface FileInfo {
path: string;
name: string;
size: number;
isDir: boolean;
}
export function useFileSystem() {
const readDirectory = async (path: string): Promise<FileInfo[]> => {
return invoke<FileInfo[]>('read_directory', { path });
};
const writeFile = async (path: string, content: string): Promise<void> => {
return invoke<void>('write_file', { path, content });
};
return { readDirectory, writeFile };
}// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
deps: {
inline: ['@tauri-apps/api'],
},
},
});// tests/__mocks__/@tauri-apps/api/tauri.ts
export const invoke = vi.fn();// src/hooks/useFileSystem.test.ts
import { renderHook, act } from '@testing-library/react';
import { invoke } from '@tauri-apps/api/tauri';
import { useFileSystem } from './useFileSystem';
vi.mock('@tauri-apps/api/tauri');
describe('useFileSystem', () => {
beforeEach(() => vi.clearAllMocks());
it('readDirectory invokes the correct Rust command', async () => {
const mockFiles = [
{ path: '/home/file.txt', name: 'file.txt', size: 100, isDir: false },
];
vi.mocked(invoke).mockResolvedValue(mockFiles);
const { result } = renderHook(() => useFileSystem());
let files;
await act(async () => {
files = await result.current.readDirectory('/home');
});
expect(invoke).toHaveBeenCalledWith('read_directory', { path: '/home' });
expect(files).toEqual(mockFiles);
});
it('handles Rust command errors', async () => {
vi.mocked(invoke).mockRejectedValue(new Error('Path does not exist: /bad'));
const { result } = renderHook(() => useFileSystem());
await expect(result.current.readDirectory('/bad')).rejects.toThrow('Path does not exist');
});
});Integration Testing with WebDriver
For full integration tests, use Tauri's WebDriver support:
# src-tauri/Cargo.toml
[dev-dependencies]
tauri = { version = "1", features = ["test"] }// src-tauri/tests/integration_test.rs
use tauri::test::{mock_context, noop_assets, MockRuntime};
#[test]
fn test_read_directory_command_integration() {
let app = tauri::test::mock_builder()
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![read_directory, write_file])
.build(mock_context(noop_assets()))
.unwrap();
let webview = tauri::test::get_ipc_response::<Vec<FileInfo>>(
&app,
tauri::test::InvokePayload {
cmd: "read_directory".to_string(),
tauri_module: None,
invoke_key: Some(tauri::test::INVOKE_KEY.to_string()),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
inner: serde_json::json!({ "path": "/tmp" }),
},
);
assert!(webview.is_ok());
}Summary
Tauri testing strategy across all layers:
- Rust unit tests (
cargo test): Test commands, state management, and file I/O directly — no Tauri runtime needed - Frontend unit tests (Vitest): Mock
@tauri-apps/api/tauri'sinvokefunction to test hooks and components - Integration tests: Use
tauri::testmock builder to test commands with the Tauri runtime - E2E tests: WebDriver against the packaged app for final validation on each platform
Testing both layers independently speeds up development cycles significantly — Rust tests run in ~1 second, frontend Vitest tests run in ~2 seconds, and full E2E tests run in minutes. Use the fast layers for TDD and E2E for release verification.