Testing Patterns
TypeScript / JavaScript Testing (Vitest)
File Conventions
- Colocated tests:
src/foo.test.tsnext tosrc/foo.ts(primary pattern) - E2E tests:
*.e2e.test.tsintest/directory or colocated - Live tests:
*.live.test.tsfor tests requiring real API keys - Setup file:
test/setup.tsfor global beforeEach/afterEach hooks - Config layering: base
vitest.config.tsextended byvitest.unit.config.ts,vitest.e2e.config.ts,vitest.live.config.ts
Test Structure
import { describe, expect, it, vi } from "vitest";
describe("resolvePermission", () => {
it("auto-approves safe tools without prompting", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermission(makeRequest(), { prompt });
expect(res).toEqual({ outcome: "allow" });
expect(prompt).not.toHaveBeenCalled();
});
it("prompts for dangerous operations", async () => {
const prompt = vi.fn(async () => true);
await resolvePermission(makeDangerousRequest(), { prompt });
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith("exec", "exec: rm -rf /");
});
});
Mock Patterns
// Function mocks for dependency injection
const sendFn = vi.fn(async () => ({ messageId: "test" }));
// Module mocks (use sparingly -- prefer DI)
vi.mock("./network.js", () => ({
fetch: vi.fn().mockResolvedValue({ ok: true }),
}));
// Spy on existing methods
const spy = vi.spyOn(store, "save");
expect(spy).toHaveBeenCalledWith(expectedData);
// Stub environment variables (auto-cleaned with unstubEnvs: true)
vi.stubEnv("API_KEY", "test-key-123");
// Fake timers (guard against leaks in afterEach)
vi.useFakeTimers();
vi.advanceTimersByTime(5000);
// Always restore: vi.useRealTimers()
Test Setup Pattern (Global)
// test/setup.ts
import { afterAll, afterEach, beforeEach, vi } from "vitest";
import { withIsolatedTestHome } from "./test-env.js";
process.env.VITEST = "true";
const testEnv = withIsolatedTestHome(); // Isolated HOME per worker
afterAll(() => testEnv.cleanup());
afterEach(() => {
if (vi.isFakeTimers()) vi.useRealTimers(); // Prevent cross-test leaks
});
Factory Functions for Test Data
// Prefer factory functions over raw object literals
function makePermissionRequest(
overrides: Partial<PermissionRequest> = {},
): PermissionRequest {
const base: PermissionRequest = {
sessionId: "session-1",
toolCall: { toolCallId: "tool-1", title: "read: src/index.ts", status: "pending" },
options: [{ kind: "allow_once", optionId: "allow" }],
};
return { ...base, ...overrides };
}
Coverage Configuration
// vitest.config.ts
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
all: false, // Only count files exercised by tests
thresholds: { lines: 70, functions: 70, branches: 55, statements: 70 },
include: ["./src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/cli/**", "src/gateway/**"],
}
Anti-Patterns
- Do NOT set
maxWorkersabove 16 (diminishing returns, flaky on CI) - Do NOT test implementation details (internal state, private methods)
- Do NOT use
vi.mockwhen dependency injection via function params works - Do NOT leak fake timers across tests -- always restore in
afterEach - Do NOT skip edge cases: empty input, null, boundary values, error paths
Rust Testing (cargo test)
File Conventions
- Inline unit tests:
#[cfg(test)] mod testsat the bottom of each source file (primary pattern) - Integration tests:
tests/directory at crate root (rare in workspace projects) - Test-only dependencies: in
[dev-dependencies]of each crate'sCargo.toml - Run all:
cargo test --lib| Single crate:cargo test -p claudio-gateway --lib - Filter by name:
cargo test --lib test_name_substring - Skip slow tests:
cargo test --lib -- --skip concurrent
Unit Test Structure
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn insert_and_get() {
let map = BoundedMap::new(10, Duration::from_secs(60));
assert!(map.insert("a", 1));
assert_eq!(map.get("a"), Some(1));
}
#[test]
fn capacity_enforced() {
let map = BoundedMap::new(3, Duration::from_secs(60));
map.insert("a", 1);
map.insert("b", 2);
map.insert("c", 3);
map.insert("d", 4); // should evict one
assert!(map.len() <= 3);
}
#[test]
fn ttl_expiry_on_get() {
let map = BoundedMap::new(10, Duration::from_millis(1));
map.insert("a", 1);
std::thread::sleep(Duration::from_millis(5));
assert_eq!(map.get("a"), None);
}
}
Async Tests
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[tokio::test]
async fn returns_immediately_when_no_tool_calls() {
let provider = Arc::new(MockToolProvider::new(vec![final_response("Hello!")]));
let mut executor = AgentExecutor::new(provider, "http://unused:9999", 10);
let result = executor.execute(test_request()).await.unwrap();
assert_eq!(result.iterations, 1);
assert_eq!(result.tool_calls_made, 0);
}
#[tokio::test]
async fn max_iterations_produces_final_response() {
// Provider always returns tool calls -- should hit max iterations
let provider = Arc::new(MockToolProvider::new(vec![tool_call_response()]));
let mut executor = AgentExecutor::new(provider, "http://unused:9999", 3);
let result = executor.execute(test_request()).await.unwrap();
assert!(result.iterations <= 3);
}
}
Stub/Mock Providers (Trait-Based Mocking)
/// Stub: always returns a fixed response. No network calls.
pub struct StubProvider { provider_name: String }
#[async_trait]
impl Provider for StubProvider {
fn name(&self) -> &str { &self.provider_name }
async fn chat_completion(&self, req: &ChatCompletionRequest)
-> Result<ChatCompletionResponse, ProviderError> {
Ok(ChatCompletionResponse { /* canned fields */ })
}
}
/// Scripted: returns pre-configured responses in sequence.
/// Useful for multi-turn agent loops.
pub struct ScriptedProvider {
responses: std::sync::Mutex<Vec<ChatCompletionResponse>>,
}
Testing Patterns for Workspace Projects
- Each crate has its own
#[cfg(test)]modules (typical: 100+ test files in a workspace) - Shared test utilities live in dedicated stub modules (e.g.,
providers/src/stub.rs) - Use
Arc<dyn Trait>for mock injection in async contexts - Test crates independently:
cargo test -p crate-name --lib
Tauri Command Testing
// Minimal inline tests for Tauri commands
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_detects_version_bump() {
assert!(is_newer("1.0.1", "1.0.0"));
assert!(!is_newer("1.0.0", "1.0.0"));
assert!(!is_newer("0.9.9", "1.0.0"));
}
}
Swift / iOS Testing (Swift Testing)
Framework: Swift Testing (not XCTest)
Projects use the modern Testing framework with @Test macros and #expect assertions.
Test Structure
import Foundation
import Testing
@testable import YourApp
@Suite struct KeychainStoreTests {
@Test func saveLoadUpdateDeleteRoundTrip() {
let service = "com.example.tests.\(UUID().uuidString)"
let account = "value"
#expect(KeychainStore.delete(service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == nil)
#expect(KeychainStore.saveString("first", service: service, account: account))
#expect(KeychainStore.loadString(service: service, account: account) == "first")
}
}
Serialized Suites (Shared State)
@Suite(.serialized) struct GatewayConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
withUserDefaults([displayKey: nil, "node.instanceId": "ios-test"]) {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let resolved = controller._test_resolvedDisplayName(defaults: .standard)
#expect(!resolved.isEmpty)
}
}
}
Async Test Methods
@Suite(.serialized) struct VoiceWakeManagerStateTests {
@Test @MainActor func suspendAndResumeCycleUpdatesState() async {
let manager = VoiceWakeManager()
manager.isEnabled = true
manager.isListening = true
let suspended = manager.suspendForExternalAudioCapture()
#expect(suspended == true)
#expect(manager.isListening == false)
manager.resumeAfterExternalAudioCapture(wasSuspended: true)
try? await Task.sleep(nanoseconds: 900_000_000)
#expect(manager.statusText.contains("Voice Wake") == true)
}
}
Testing @Observable ViewModels
- Instantiate the
@Observableclass directly in the test - Use
@MainActorannotation for UI-bound state - For UserDefaults-dependent code, use a
withUserDefaultshelper that snapshots and restores
Test Helper Pattern: Expose Internals
// In production code, expose test-only accessors:
func _test_resolvedDisplayName(defaults: UserDefaults) -> String { ... }
func _test_currentCaps() -> [String] { ... }
func _test_handleRecognitionCallback(transcript: String?, ...) { ... }
Actor-Based Test Capture
actor CaptureBox {
var value: String?
func set(_ next: String) { self.value = next }
}
let capture = CaptureBox()
manager.configure { cmd in await capture.set(cmd) }
Build & Test Commands
# Run tests on simulator
xcodebuild test -project App.xcodeproj -scheme App \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'
# Build only (catches compile errors)
xcodebuild -project App.xcodeproj -scheme App \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
Go Testing (go test)
File Conventions
- Test files:
*_test.goin the same package as the code under test - Same package access: tests use
package crypto(notpackage crypto_test) for internal access - Run:
go test ./...| Single package:go test ./internal/crypto/
Standard Test Pattern
package crypto
import (
"bytes"
"testing"
)
func TestEncryptDecrypt(t *testing.T) {
key := make([]byte, chacha20poly1305.KeySize)
rand.Read(key)
plaintext := []byte("Hello, World!")
encrypted, err := Encrypt(plaintext, key, nil)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decrypted, err := Decrypt(encrypted, key, nil)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Errorf("Decrypted text doesn't match.\nGot: %q\nWant: %q", decrypted, plaintext)
}
}
Error Case Testing
func TestEncryptInvalidKey(t *testing.T) {
shortKey := make([]byte, 16)
_, err := Encrypt([]byte("test"), shortKey, nil)
if err == nil {
t.Error("Encrypt should reject short key")
}
}
Integration Tests with Channels
func TestHandshakeClientServer(t *testing.T) {
serverResult := make(chan *HandshakeResult, 1)
serverErr := make(chan error, 1)
server := network.NewServer(handler)
if err := server.Listen("127.0.0.1:0"); err != nil {
t.Fatalf("Failed to listen: %v", err)
}
go server.Serve()
defer server.Close()
conn, err := network.Dial(server.Addr().String())
if err != nil {
t.Fatalf("Failed to dial: %v", err)
}
defer conn.Close()
select {
case err := <-serverErr:
t.Fatalf("Server handshake failed: %v", err)
case result := <-serverResult:
if result.PeerID != "test-client" {
t.Errorf("Server got peer ID = %q, want %q", result.PeerID, "test-client")
}
case <-time.After(2 * time.Second):
t.Fatal("Server handshake timeout")
}
}
Uniqueness / Determinism Tests
func TestGenerateKeyUniqueness(t *testing.T) {
k1, _ := GenerateIdentityKey()
k2, _ := GenerateIdentityKey()
if bytes.Equal(k1.PublicKey, k2.PublicKey) {
t.Error("Generated identical public keys (should be unique)")
}
}
func TestFingerprintDeterministic(t *testing.T) {
keypair, _ := GenerateIdentityKey()
f1 := keypair.Fingerprint()
f2 := keypair.Fingerprint()
if f1 != f2 {
t.Error("Fingerprint is not deterministic")
}
}
Race Detection
go test -race ./... # Always run in CI
Cross-Language Guidelines
When to Write Tests
- Always test: boundary logic, parsing, serialization, crypto, state machines, error paths
- Always test: bug fixes (write the failing test first)
- Skip testing: trivial getters/setters, framework wiring, one-line delegation
- Skip testing: third-party library behavior (that is their job)
Test Naming Conventions
| Language | Convention | Example |
|---|---|---|
| TypeScript | describe("module") > it("does X when Y") |
it("auto-approves safe tools") |
| Rust | snake_case function names |
fn insert_and_get() |
| Swift | camelCase method names on @Suite struct |
func saveLoadUpdateDeleteRoundTrip() |
| Go | TestXxx with PascalCase |
func TestEncryptDecrypt(t *testing.T) |
The Testing Pyramid
/ E2E \ *.e2e.test.ts, Docker scripts
/ Integration \ Server tests, handshake tests
/ Unit Tests \ #[test], @Test, TestXxx, it()
Ratio target: ~70% unit, ~20% integration, ~10% e2e.
What NOT to Test
- Framework internals (Tauri IPC plumbing, axum routing wiring, SwiftUI view layout)
- Third-party API responses (mock them instead)
- Trivial wrappers that just delegate to another function
- Generated code (protocol buffers, codegen output)
CI Integration (GitHub Actions)
TypeScript (Vitest):
- name: Test
run: pnpm test
- name: Coverage
run: pnpm test:coverage
Rust (cargo test):
- name: Test
run: cargo test --lib
- name: Clippy
run: cargo clippy -- -D warnings
Swift (xcodebuild):
- name: Test
run: |
xcodebuild test -project App.xcodeproj -scheme App \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'
Go (go test):
- name: Test
run: go test -race -cover ./...
Quick Reference
| Language | Framework | Run Command | Config File | Convention |
|---|---|---|---|---|
| TypeScript | Vitest 4.x | pnpm test / vitest run |
vitest.config.ts |
*.test.ts colocated |
| Rust | cargo test | cargo test --lib |
Cargo.toml (workspace) |
#[cfg(test)] mod tests |
| Swift | Swift Testing | xcodebuild test |
.xcodeproj scheme |
*Tests.swift in Tests/ |
| Go | go test | go test ./... |
None (built-in) | *_test.go same pkg |
Mock Strategy Per Language
| Language | Primary Mock Approach | Avoid |
|---|---|---|
| TypeScript | vi.fn() + dependency injection via params |
Heavy vi.mock() of whole modules |
| Rust | Trait-based stubs (StubProvider) |
mockall for simple cases |
| Swift | _test_ accessor methods + actor captures |
Swizzling, runtime patching |
| Go | Interface-based injection + channels | Monkey patching, reflect |
Property-Based Testing
Use when the function has invariants you can express as a rule (commutativity, round-trip, idempotency, monotonicity). One property test replaces dozens of example cases.
| Language | Library | Quick example |
|---|---|---|
| TypeScript | fast-check |
fc.assert(fc.property(fc.string(), s => decode(encode(s)) === s)) |
| Rust | proptest |
proptest! { #[test] fn round_trip(s in any::<String>()) { prop_assert_eq!(decode(&encode(&s)), s); } } |
| Swift | swift-testing + manual generators |
Loop with Int.random(in:) over #expect |
| Go | testing/quick (stdlib) or gopter |
quick.Check(func(s string) bool { return decode(encode(s)) == s }, nil) |
Sweet spots: parsers, codecs, serialization, math utilities, sort/merge logic. Skip for pure I/O wrappers.
Coverage Targets
| Layer | Target |
|---|---|
| Pure logic, parsers | 90%+ |
| Service / handler code | 70-80% |
| UI / view layer | 40-60% |
| Glue, framework wiring | not measured |
Coverage is a floor, not a goal. 100% line coverage with no assertions on outputs proves nothing.