Docs/Contributing/Testing

Testing

How to run and write tests for PurePoint.

Running tests

Rust

just test              # all Rust tests
cargo test -p pu-core  # single crate
cargo test -p pu-engine
cargo test -p pu-cli

just test runs cargo test --all-targets across all crates.

Swift

just test-app          # macOS app tests (unsigned)

Runs xcodebuild test with signing disabled.

Full CI locally

just ci-rust           # fmt-check + lint + test + deny
just ci                # ci-rust + build-app + test-app

Filtering tests

cargo test -p pu-core given_manifest   # name substring match
cargo test -p pu-engine -- --nocapture # show stdout

Test structure

Unit tests

Each module has a #[cfg(test)] mod tests block at the bottom:

crates/pu-core/src/
  types.rs          # serialization round-trips, status mapping
  protocol.rs       # request/response serialization (all 49 variants)
  manifest.rs       # file I/O with tempdir
  config.rs         # YAML parsing, default generation
  template.rs       # parse, render, variable extraction
  schedule_def.rs   # YAML parsing, next_occurrence calculation
  trigger_def.rs    # YAML parsing, event/action variants
  validation.rs     # name validation, path traversal protection
  id.rs             # ID generation format

crates/pu-engine/src/
  output_buffer.rs  # circular buffer, idle detection, ANSI stripping
  engine.rs         # agent spawning, state transitions
  agent_monitor.rs  # effective status computation
  gate.rs           # gate evaluation (async)
  daemon_lifecycle.rs # PID file management
  attach_handler.rs # APC escape parsing
  git.rs            # worktree operations

Integration tests

crates/pu-engine/tests/integration.rs -- 11 tests that start a real IPC server, send requests, and verify responses. Uses a TestHarness that manages server lifecycle:

struct TestHarness {
    _tmp: TempDir,
    sock: std::path::PathBuf,
    project: std::path::PathBuf,
    handle: tokio::task::JoinHandle<()>,
}

Swift tests

apps/purepoint-macos/purepoint-macosTests/ -- uses Apple's Testing framework (@Test, #expect):

FileWhat it tests
ChatStateTests.swiftPoint Guard Screen terminal state
DaemonClientParseTests.swiftNDJSON response parsing
TranscriptParserTests.swiftAgent transcript extraction
HexEncodingTests.swiftHex byte encoding/decoding
AgentsHubModelsTests.swiftAgent def / template models
AgentsHubStateTests.swiftHub tab state management
ActiveProjectRoutingTests.swiftProject selection routing
ClaudeConversationIndexTests.swiftConversation index parsing

Writing tests

Naming convention

All tests use Given/Should naming:

Rust (snake_case):

#[test]
fn given_manifest_should_write_and_read_back_identical() { ... }

#[tokio::test]
async fn given_passing_gate_should_return_passed() { ... }

Swift (camelCase):

@Test func givenNewConversationShouldClearMessagesAndSessionId() { ... }

The pattern is: given_<precondition>_should_<expected_behavior>. Every test follows this -- no exceptions.

Filesystem isolation

Use tempfile::TempDir for any test that touches the filesystem:

use tempfile::TempDir;

#[test]
fn given_config_should_load_from_file() {
    let tmp = TempDir::new().unwrap();
    let config_path = tmp.path().join("config.yaml");
    std::fs::write(&config_path, "default_agent_type: claude").unwrap();
    // test against config_path...
}

TempDir auto-cleans on drop. Never write to real paths in tests.

Async tests

Use #[tokio::test] for async tests. Integration tests use single-threaded runtime for determinism:

#[tokio::test(flavor = "current_thread")]
async fn given_full_lifecycle_should_init_status_and_shutdown() {
    let harness = TestHarness::new().await;
    // ...
    harness.shutdown().await;
}

Mocks and doubles

Hand-written -- no mocking libraries.

Rust: Helper functions that build test data:

fn make_gate_trigger(cmd: &str, inject: Option<&str>) -> TriggerAction { ... }

Swift: Protocol-conforming mock structs:

final class MockClaudeProcess: ClaudeProcessProvider, @unchecked Sendable {
    var events: [StreamEvent] = []
    var cancelCalled = false
    // ...
}

Round-trip testing

Common pattern for serialization types -- serialize then deserialize and compare:

#[test]
fn given_all_agent_statuses_should_round_trip_json() {
    for status in [AgentStatus::Streaming, AgentStatus::Waiting, AgentStatus::Broken] {
        let json = serde_json::to_string(&status).unwrap();
        let parsed: AgentStatus = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed, status);
    }
}

What to test

  • pu-core: Serialization round-trips, YAML parsing, file I/O, validation edge cases
  • pu-engine: Status computation, buffer behavior, gate evaluation, IPC request handling
  • Swift: State transitions, NDJSON parsing, model encoding/decoding