Testing Plugins
Testing strategies for Orbis plugins
Testing Plugins
Comprehensive guide to testing your Orbis plugins.
Testing Strategies
| Level | Scope | Tools | SDK Support |
|---|---|---|---|
| Unit | Rust functions | cargo test | Full |
| Integration | Handler logic | cargo test + mocks | SDK helpers |
| E2E | Full WASM | wasmtime | Works seamlessly |
| UI | Full UI flow | Playwright/Cypress | N/A |
Unit Testing with SDK
Testing Business Logic
rust
// src/lib.rs
use orbis_plugin_api::sdk::prelude::*;
use serde_json::json;
orbis_plugin!();
pub fn calculate_total(items: &[Item]) -> f64 {
items.iter().map(|i| i.price * i.quantity as f64).sum()
}
pub fn get_total_impl(ctx: Context) -> Result<Response> {
let items: Vec<Item> = ctx.body_as()?;
let total = calculate_total(&items);
Ok(Response::json(&json!({ "total": total })))
}
wrap_handler!(get_total, get_total_impl);
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct Item {
price: f64,
quantity: i32,
}
#[test]
fn test_calculate_total() {
let items = vec![
Item { price: 10.0, quantity: 2 },
Item { price: 5.0, quantity: 3 },
];
assert_eq!(calculate_total(&items), 35.0);
}
#[test]
fn test_calculate_total_empty() {
let items: Vec<Item> = vec![];
assert_eq!(calculate_total(&items), 0.0);
}
} Run tests:
bash
cargo test Testing SDK Helpers
rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_response_json() {
let data = json!({"status": "ok"});
let response = Response::json(&data);
assert_eq!(response.status_code, 200);
assert!(response.body.contains("ok"));
}
#[test]
fn test_response_text() {
let response = Response::text("Hello, World!");
assert_eq!(response.status_code, 200);
assert_eq!(response.body, "Hello, World!");
}
} Testing State Operations
rust
#[cfg(test)]
mod tests {
use super::*;
// Note: These tests work with real state if running in WASM
// For pure unit tests, you'd mock the state layer
#[test]
fn test_state_serialization() {
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, PartialEq, Debug)]
struct User {
name: String,
age: i32,
}
let user = User {
name: "Alice".to_string(),
age: 30,
};
// Test that our types serialize correctly
let json = serde_json::to_string(&user).unwrap();
let parsed: User = serde_json::from_str(&json).unwrap();
assert_eq!(user, parsed);
}
} Integration Testing with SDK
Testing Handlers with Mock Context
rust
// tests/integration.rs
use orbis_plugin_api::sdk::prelude::*;
use serde_json::json;
// Mock context for testing
fn mock_context(method: &str, path: &str, body: Option<Value>) -> Context {
let request = json!({
"method": method,
"path": path,
"query": {},
"headers": {},
"body": body.unwrap_or(json!({}))
});
// Note: This is pseudo-code - actual Context creation
// would need access to the internal Context constructor
// In practice, you'd test the handler functions directly
// or use the full WASM integration test approach below
}
#[test]
fn test_get_greeting_handler() {
// Direct function testing (best approach with SDK)
use my_plugin::handlers::get_greeting;
// Test with mock data
let result = get_greeting_impl("Alice");
assert_eq!(result, "Hello, Alice!");
}
// Better: Separate testable logic from handlers
mod my_plugin {
pub mod handlers {
pub fn get_greeting_impl(name: &str) -> String {
format!("Hello, {}!", name)
}
}
} Integration Testing
Test Harness Setup
Create a test project that loads your plugin:
rust
// tests/integration.rs
use wasmtime::*;
use std::fs;
fn load_plugin() -> (Store<()>, Instance) {
let engine = Engine::default();
let mut store = Store::new(&engine, ());
let wasm_bytes = fs::read("target/wasm32-unknown-unknown/release/my_plugin.wasm")
.expect("Failed to read WASM file");
let module = Module::new(&engine, &wasm_bytes)
.expect("Failed to compile module");
let instance = Instance::new(&mut store, &module, &[])
.expect("Failed to instantiate");
(store, instance)
}
#[test]
fn test_plugin_init() {
let (mut store, instance) = load_plugin();
let init = instance.get_typed_func::<(), i32>(&mut store, "init")
.expect("init not found");
let result = init.call(&mut store, ())
.expect("init failed");
assert_eq!(result, 0); // Success
}
#[test]
fn test_plugin_execute() {
let (mut store, instance) = load_plugin();
// Get memory and functions
let memory = instance.get_memory(&mut store, "memory")
.expect("memory not found");
let alloc = instance.get_typed_func::<i32, i32>(&mut store, "alloc")
.expect("alloc not found");
let execute = instance.get_typed_func::<(i32, i32), i32>(&mut store, "execute")
.expect("execute not found");
// Write request to memory
let request = r#"{"route":"/items","method":"GET"}"#;
let ptr = alloc.call(&mut store, request.len() as i32)
.expect("alloc failed");
memory.write(&mut store, ptr as usize, request.as_bytes())
.expect("memory write failed");
// Call execute
let response_ptr = execute.call(&mut store, (ptr, request.len() as i32))
.expect("execute failed");
// Read response
let response = read_string(&mut store, &memory, response_ptr);
let json: serde_json::Value = serde_json::from_str(&response).unwrap();
assert!(json["success"].as_bool().unwrap());
} Mock Host Functions
rust
fn create_mock_imports(store: &mut Store<TestState>) -> Linker<TestState> {
let mut linker = Linker::new(store.engine());
// Mock database query
linker.func_wrap("env", "db_query", |mut caller: Caller<'_, TestState>, ptr: i32, len: i32| -> i32 {
let state = caller.data_mut();
state.db_calls.push(read_string_from_mem(&caller, ptr, len));
// Return mock response
let response = r#"{"rows":[{"id":1,"name":"test"}]}"#;
write_to_memory(&mut caller, response)
}).unwrap();
// Mock logging
linker.func_wrap("env", "log_info", |_caller: Caller<'_, TestState>, ptr: i32, len: i32| {
// Capture logs for testing
}).unwrap();
linker
} Schema Testing
Manifest Validation
rust
use serde_json;
use orbis_plugin::manifest::PluginManifest;
#[test]
fn test_manifest_valid() {
let manifest_json = include_str!("../manifest.json");
let manifest: PluginManifest = serde_json::from_str(manifest_json)
.expect("Failed to parse manifest");
assert!(!manifest.name.is_empty());
assert!(manifest.version.contains('.'));
assert!(!manifest.pages.is_empty());
} Page Schema Validation
rust
#[test]
fn test_page_schemas_valid() {
let manifest_json = include_str!("../manifest.json");
let manifest: serde_json::Value = serde_json::from_str(manifest_json).unwrap();
for page in manifest["pages"].as_array().unwrap() {
// Required fields
assert!(page["title"].is_string());
assert!(page["route"].is_string());
assert!(page["sections"].is_array());
// Route format
let route = page["route"].as_str().unwrap();
assert!(route.starts_with('/'));
}
} Component Schema Validation
typescript
// tests/schema.test.ts
import { describe, it, expect } from 'vitest';
import manifest from '../manifest.json';
describe('Component Schemas', () => {
const pages = manifest.pages;
it('all pages have valid sections', () => {
for (const page of pages) {
expect(page.sections).toBeDefined();
expect(Array.isArray(page.sections)).toBe(true);
for (const section of page.sections) {
expect(section.type).toBeDefined();
}
}
});
it('all components have valid types', () => {
const validTypes = [
'Container', 'Flex', 'Grid', 'Text', 'Heading',
'Button', 'Form', 'Table', 'Card', 'Modal', // ...
];
function validateComponent(component: any) {
expect(validTypes).toContain(component.type);
if (component.children) {
for (const child of component.children) {
validateComponent(child);
}
}
}
for (const page of pages) {
for (const section of page.sections) {
validateComponent(section);
}
}
});
}); E2E Testing
Playwright Setup
typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
baseURL: 'http://localhost:5173',
},
webServer: {
command: 'bun run tauri dev',
port: 5173,
reuseExistingServer: !process.env.CI,
},
}); Plugin E2E Tests
typescript
// e2e/my-plugin.spec.ts
import { test, expect } from '@playwright/test';
test.describe('My Plugin', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/my-plugin');
await page.waitForSelector('[data-testid="plugin-loaded"]');
});
test('displays dashboard', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('loads items list', async ({ page }) => {
await page.goto('/my-plugin/items');
// Wait for data to load
await page.waitForSelector('table tbody tr');
// Check items displayed
const rows = await page.locator('table tbody tr').count();
expect(rows).toBeGreaterThan(0);
});
test('creates new item', async ({ page }) => {
await page.goto('/my-plugin/items');
await page.click('button:has-text("New Item")');
// Fill form
await page.fill('input[name="name"]', 'Test Item');
await page.fill('textarea[name="description"]', 'Test Description');
await page.selectOption('select[name="category"]', 'a');
// Submit
await page.click('button[type="submit"]');
// Verify success
await expect(page.getByText('Item created!')).toBeVisible();
await expect(page).toHaveURL('/my-plugin/items');
});
test('handles form validation', async ({ page }) => {
await page.goto('/my-plugin/items/new');
// Submit empty form
await page.click('button[type="submit"]');
// Check validation error
await expect(page.getByText('Name is required')).toBeVisible();
});
}); Testing Actions
typescript
test('update_state action works', async ({ page }) => {
await page.goto('/my-plugin');
// Initial state
await expect(page.getByTestId('counter')).toHaveText('0');
// Click increment button
await page.click('button:has-text("Increment")');
// State updated
await expect(page.getByTestId('counter')).toHaveText('1');
});
test('call_api action fetches data', async ({ page }) => {
await page.goto('/my-plugin/items');
// Loading state
await expect(page.getByTestId('loading')).toBeVisible();
// Data loaded
await page.waitForSelector('table');
await expect(page.getByTestId('loading')).not.toBeVisible();
});
test('navigate action changes route', async ({ page }) => {
await page.goto('/my-plugin');
await page.click('a:has-text("Settings")');
await expect(page).toHaveURL('/my-plugin/settings');
}); Test Data
Fixtures
typescript
// fixtures/items.ts
export const mockItems = [
{ id: '1', name: 'Item 1', category: 'a', createdAt: '2024-01-01' },
{ id: '2', name: 'Item 2', category: 'b', createdAt: '2024-01-02' },
];
export const mockEmptyState = {
items: [],
selectedItem: null,
isLoading: false,
}; API Mocking
typescript
test.beforeEach(async ({ page }) => {
// Mock API responses
await page.route('**/api/my-plugin/items', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: mockItems }),
});
});
}); Coverage
Rust Coverage
bash
# Install grcov
cargo install grcov
# Build with coverage
CARGO_INCREMENTAL=0 \
RUSTFLAGS='-Cinstrument-coverage' \
LLVM_PROFILE_FILE='cargo-test-%p-%m.profraw' \
cargo test
# Generate report
grcov . --binary-path ./target/debug/deps/ -s . -t html --branch --ignore-not-existing --ignore '../*' -o coverage/ TypeScript Coverage
bash
# Run with coverage
bun test --coverage
# Or with vitest
vitest --coverage Debugging Tips
WASM Debugging
rust
// Add debug logging
#[cfg(debug_assertions)]
macro_rules! debug {
($($arg:tt)*) => {
// Call host log function
unsafe { log_debug(format!($($arg)*).as_ptr(), format!($($arg)*).len() as i32); }
};
}
#[cfg(not(debug_assertions))]
macro_rules! debug {
($($arg:tt)*) => {};
}
// Use in code
debug!("Processing item: {:?}", item); Browser DevTools
- Open Chrome DevTools → Sources
- Find WASM file under
wasm:// - Set breakpoints in source-mapped code
- Inspect memory with
WebAssembly.Memory
Console Logging
json
{
"events": {
"on_click": [
{ "type": "update_state", "path": "debug.lastClick", "value": "{{$now}}" }
]
}
} Check state in React DevTools → Components → PageStateProvider.
CI Integration
GitHub Actions
yaml
name: Test Plugin
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-action@stable
with:
targets: wasm32-unknown-unknown
- name: Unit tests
run: cargo test
- name: Build WASM
run: cargo build --target wasm32-unknown-unknown --release
- name: Schema validation
run: |
python3 -c "import json; json.load(open('manifest.json'))"
- name: Install deps
run: bun install
- name: E2E tests
run: bun run test:e2e
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run coverage
run: cargo tarpaulin --out Xml
- uses: codecov/codecov-action@v4 Best Practices
- Test Manifest First - Validate JSON before testing WASM
- Mock Host Functions - Isolate plugin logic from platform
- Test State Transitions - Verify all state changes
- Test Error Paths - Simulate API failures
- Test Loading States - Verify async UX
- Keep Tests Fast - Use unit tests for logic, E2E for flows
Next Steps
- Best Practices - Production-ready plugins
- Components - Component reference
- Actions - Action reference
On This Page
- Testing Strategies
- Unit Testing with SDK
- Testing Business Logic
- Testing SDK Helpers
- Testing State Operations
- Integration Testing with SDK
- Testing Handlers with Mock Context
- Integration Testing
- Test Harness Setup
- Mock Host Functions
- Schema Testing
- Manifest Validation
- Page Schema Validation
- Component Schema Validation
- E2E Testing
- Playwright Setup
- Plugin E2E Tests
- Testing Actions
- Test Data
- Fixtures
- API Mocking
- Coverage
- Rust Coverage
- TypeScript Coverage
- Debugging Tips
- WASM Debugging
- Browser DevTools
- Console Logging
- CI Integration
- GitHub Actions
- Best Practices
- Next Steps