SuperAPI AI Directory

testing-patterns

Testing & quality

View repository

Updated 10-13 15:13

About this skill

Testing Patterns

TypeScript / JavaScript Testing (Vitest)

File Conventions

  • Colocated tests: src/foo.test.ts next to src/foo.ts (primary pattern)
  • E2E tests: *.e2e.test.ts in test/ directory or colocated
  • Live tests: *.live.test.ts for tests requiring real API keys
  • Setup file: test/setup.ts for global beforeEach/afterEach hooks
  • Config layering: base vitest.config.ts extended by vitest.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
```typescript
// 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 maxWorkers above 16 (diminishing returns, flaky on CI)
  • Do NOT test implementation details (internal state, private methods)
  • Do NOT use vi.mock when 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 tests at 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's Cargo.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 @Observable class directly in the test
  • Use @MainActor annotation for UI-bound state
  • For UserDefaults-dependent code, use a withUserDefaults helper 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.go in the same package as the code under test
  • Same package access: tests use package crypto (not package 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.