Easy Tauri Integration Tests with Vitest<br>About<br>Open-SourcePricingBlogDocs<br>Engineering
May 14, 2026<br>|4min. read
Easy Tauri Integration Tests with Vitest<br>Having a hard time writing integration tests for Tauri? It's very easy with this little trick, even on macOS!
By Simon Binder
← Blog<br>Recently, we introduced our PowerSync SDK for Tauri.<br>The SDK is a Tauri plugin, meaning that it consists of two parts:
A Rust crate used to access a local SQLite database, sync changes via PowerSync, and notify JavaScript apps about changes.
A small JavaScript library acting as a type-safe wrapper around the raw Tauri IPC commands supported by our Rust crate.
As part of the development work, we also looked at ways to test our new SDK.<br>Tauri basically offers three modes of testing:
A MockRuntime for Rust, allowing us to write unit tests for our Rust crate by mocking out JavaScript.
A fake Tauri environment for JavaScript, allowing us to write unit tests for our JavaScript package by mocking out Rust.
A WebDriver integration, allowing us to test everything together.
Especially when writing Tauri plugins, options 1 and 2 provide very little value.<br>How our JavaScript and Rust sources interact is the most interesting thing we want to test, as our SDK wouldn't work if anything about that was wrong.<br>Two tests each mocking the other half provide no guarantees.
So the only option left available to us was to spin up a demo app and use WebDriver support built into Tauri for integration tests.<br>However, that option has its own big issues:
It's designed to test Tauri apps, while we're interested in testing plugin functionality.
It doesn't work on macOS, which is what most of us use.
In the end, we found a neat way to very reliably test everything in our Tauri plugin nonetheless: Instead of driving an app through WebDriver,<br>what if we launched a Tauri app that simply... tested itself?<br>After all, this is exactly how we test most of our JavaScript nowadays: Instead of mocking web or React APIs to run tests in Node, we let vitest<br>spawn a browser and use the real thing. That loads a web page running our tests, reporting results back to a local vitest server which will print them and set<br>an appropriate exit code for CI.
Using Tauri as a vitest browser
Starting from version 4, vitest has pretty decent support for custom browsers. So our plan to test our SDK was fairly straightforward:
Write a tiny Tauri app that allows loading any URL and loads the PowerSync Tauri plugin.
Tell vitest to launch that app with a custom URL instead of spawning a browser.
You can see the whole thing in action here.
The main.rs for our test app is very simple: It receives a URL as a CLI argument before opening a window with that URL.<br>Using dev_url skips some IPC checks and simplifies the setup:
use std::env;<br>use url::Url;
fn main() {<br>// Use default options, but open window with URL from args.<br>let mut context = tauri::generate_context!();<br>if let Some(url) = env::args().skip(1).next() {<br>let config = context.config_mut();<br>config.build.dev_url = Some(Url::parse(&url).expect("Could not parse URL"));
tauri::Builder::default()<br>.plugin(tauri_plugin_powersync::init())<br>.run(context)<br>.expect("error while running tauri application");
For permissions in capabilities/default.json, we used these:
"$schema": "../gen/schemas/desktop-schema.json",<br>"identifier": "default",<br>"description": "enables the default permissions",<br>"windows": ["*"],<br>"remote": {<br>"urls": ["http://127.0.0.1:*"]<br>},<br>"permissions": [<br>"core:default",<br>"powersync:default",<br>"core:webview:allow-create-webview-window",<br>"core:window:allow-set-title"<br>This would not be a good idea for real Tauri apps, but permission checks would just stand in the way for integration tests.
To launch this app in vitest, we wrote a custom BrowserProvider in vitest.config.ts:
const serverFactory = preview().serverFactory;<br>// Relative path to the integration test runner app built with cargo.<br>const testRunnerExecutable = path.resolve('../../target/debug/test-runner');
class TauriBrowserProvider implements BrowserProvider {<br>#tauriApp?: ChildProcess;<br>#isClosing = false;
// ... some boring methods omitted
async openPage(_sessionId: string, url: string, _options: { parallel: boolean; }) {<br>if (this.#tauriApp != null) {<br>throw new Error('TODO: Calling openPage multiple times is not supported');
// Ensure the target app spawning webviews is up-to-date.<br>const buildResult = spawnSync('cargo', ['build', '-p', 'test-runner'], { stdio: 'inherit' });<br>if (buildResult.status !== 0) {<br>throw new Error(`cargo build failed with exit code ${buildResult.status}`);<br>const app = spawn(testRunnerExecutable, [url]);<br>this.#tauriApp = app;
app.on('exit', (code) => {<br>if (!this.#isClosing) {<br>console.log('Test runner exited with code', code);<br>process.exit(1);<br>});
await new Promisevoid>((resolve, reject) => {<br>app.once('spawn', () => resolve());<br>app.once('error', reject);<br>});
async close() {<br>this.#isClosing = true;<br>this.#tauriApp?.kill();<br>And that's...