Tauri App Testing Strategies: Rust Backend and WebView Frontend

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's invoke function to test hooks and components
  • Integration tests: Use tauri::test mock 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.

Read more