A UI integration testing library for Tauri, that exposes Tauri commands to JSdom via N-API bridge, allowing full JS-driven integration testing against application UI and Rust source code.
In your Tauri app crate, add tauri-test:
[lib]
crate-type = ["cdylib", "rlib", "staticlib"]
[build-dependencies]
napi-build = "2"
[dependencies]
tauri = "2"
tauri-test = "0.2.1"
napi = { version = "2", default-features = false, features = ["napi8", "async", "serde-json"] }
napi-derive = "2"
serde_json = "1"Add serde with the derive feature only if a command takes/returns a
custom type, or you use the state option below (both need #[derive(Serialize/Deserialize)]):
serde = { version = "1", features = ["derive"] }Use napi_build::setup() in build.rs:
fn main() {
napi_build::setup();
tauri_build::build();
}Tauri commands will automatically get invoke(...):
#[tauri_test::setup(init = init_test_state, state = AppState)]
pub struct App;
// `state = AppState` needs `Serialize` so `getAppState()` can return it as JSON.
#[derive(serde::Serialize)]
struct AppState {
label: String,
}
// optional: initialize the Rust state your commands read via `tauri::State`
fn init_test_state() -> (TodoDb, AppState) {
(TodoDb::new(), AppState { label: "integration-test".into() })
}
#[tauri::command]
fn greet(name: String) -> String {
format!("Hello, {name}! You've been greeted from Rust!")
}Both keys are optional and may be given in any order.
| Key | Purpose |
|---|---|
init = path::to::fn |
Returns the state values to register. Return a tuple to register several. |
state = SomeType |
Exposes a getAppState() export returning that registered value as JSON. SomeType must implement serde::Serialize. |
| Parameter | Provided by the harness as |
|---|---|
Any serde::Deserialize type |
The matching key from the invoke args object |
Option<T> |
The key if present, otherwise None |
tauri::State<'_, T> |
The value registered by your init function |
tauri::AppHandle / tauri::WebviewWindow |
Whatever was passed to tauri_test::runtime::register_app_handle / register_webview_window |
Commands taking an AppHandle or WebviewWindow dispatch normally, but fail
with a descriptive error unless a handle has been registered. A handle can only
come from a harness that builds a real App — tauri::test::mock_app produces
an App<MockRuntime>, whose handle type differs from the AppHandle<Wry> your
commands expect, so there is no automatic fallback.
T, Result<T, E>, and single-type-argument aliases whose name ends in
Result (e.g. type AppResult<T> = Result<T, String>) are all unwrapped, so
invoke resolves with the bare value and rejects on Err. Because a proc
macro cannot resolve type aliases, the alias check is name-based: an unrelated
single-generic type named QueryResult<T> would also be treated as a Result.
Build the src-tauri library to generate the addon loader:
{
"devDependencies": {
"vitest": "^3.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.0.0",
"jsdom": "^26.0.0"
},
"scripts": {
"pretest": "cd src-tauri && cargo build --lib",
"test": "vitest run"
}
}In tests/setup.ts, load the compiled addon and register a Vitest mock for @tauri-apps/api/core:
import { createRequire } from "node:module";
import { vi } from "vitest";
const require = createRequire(import.meta.url);
export const tauriTest = require("../src-tauri/target") as {
invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
getAppState: () => { label: string };
};
vi.mock("@tauri-apps/api/core", () => ({
invoke: tauriTest.invoke
}));When #[tauri_test::setup] is given state = SomeType, the addon also exports
getAppState(), which serializes the registered value to JSON. Useful for
asserting on state your UI does not surface:
expect(tauriTest.getAppState()).toEqual({ label: "integration-test" });Write a real integration test:
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "../src/App";
it("greets through real Rust", async () => {
render(<App />);
await userEvent.type(screen.getByPlaceholderText("Enter a name..."), "World");
await userEvent.click(screen.getByRole("button", { name: /greet/i }));
await waitFor(() => {
expect(
screen.getByText(/Hello, World! You've been greeted from Rust!/i),
).toBeInTheDocument();
});
});