Testing
This guide covers testing practices and strategies for SwissArmyHammer development.
Overview
SwissArmyHammer uses a comprehensive testing approach:
- Unit tests - Test individual components
- Integration tests - Test component interactions
- End-to-end tests - Test complete workflows
- Property tests - Test with generated inputs
- Benchmark tests - Test performance
Test Organization
swissarmyhammer/
├── src/
│ └── *.rs # Unit tests in source files
├── tests/
│ ├── integration/ # Integration test files
│ ├── common/ # Shared test utilities
│ └── fixtures/ # Test data files
├── benches/ # Benchmark tests
└── examples/ # Example code (also tested)
Unit Testing
Basic Unit Tests
Place unit tests in the same file as the code:
// src/prompts/prompt.rs
pub struct Prompt {
pub name: String,
pub title: String,
pub content: String,
}
impl Prompt {
pub fn parse(content: &str) -> Result<Self> {
// Implementation
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_prompt() {
let content = r#"---
name: test
title: Test Prompt
---
Content here"#;
let prompt = Prompt::parse(content).unwrap();
assert_eq!(prompt.name, "test");
assert_eq!(prompt.title, "Test Prompt");
assert!(prompt.content.contains("Content here"));
}
#[test]
fn test_parse_missing_name() {
let content = r#"---
title: Test Prompt
---
Content"#;
let result = Prompt::parse(content);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("name"));
}
}
Testing Private Functions
#[cfg(test)]
mod tests {
use super::*;
// Test private functions by making them pub(crate) in test mode
#[test]
fn test_private_helper() {
// Can access private functions within the module
let result = validate_prompt_name("test-name");
assert!(result);
}
}
Mock Dependencies
#[cfg(test)]
mod tests {
use super::*;
use mockall::*;
#[automock]
trait FileSystem {
fn read_file(&self, path: &Path) -> io::Result<String>;
}
#[test]
fn test_with_mock_filesystem() {
let mut mock = MockFileSystem::new();
mock.expect_read_file()
.returning(|_| Ok("file content".to_string()));
let result = process_with_fs(&mock, "test.md");
assert!(result.is_ok());
}
}
Integration Testing
Basic Integration Test
Create files in tests/integration/
:
// tests/integration/prompt_loading.rs
use swissarmyhammer::{PromptManager, Config};
use tempfile::tempdir;
use std::fs;
#[test]
fn test_load_prompts_from_directory() {
// Create temporary directory
let temp_dir = tempdir().unwrap();
let prompt_path = temp_dir.path().join("test.md");
// Write test prompt
fs::write(&prompt_path, r#"---
name: test-prompt
title: Test Prompt
---
Test content"#).unwrap();
// Test loading
let mut config = Config::default();
config.prompt_directories.push(temp_dir.path().to_path_buf());
let manager = PromptManager::with_config(config).unwrap();
manager.load_prompts().unwrap();
// Verify
let prompt = manager.get_prompt("test-prompt").unwrap();
assert_eq!(prompt.title, "Test Prompt");
}
Testing MCP Server
// tests/integration/mcp_server.rs
use swissarmyhammer::mcp::{MCPServer, MCPRequest, MCPResponse};
use serde_json::json;
#[tokio::test]
async fn test_mcp_initialize() {
let server = MCPServer::new();
let request = MCPRequest {
jsonrpc: "2.0".to_string(),
method: "initialize".to_string(),
params: json!({}),
id: Some(json!(1)),
};
let response = server.handle_request(request).await.unwrap();
assert_eq!(response.jsonrpc, "2.0");
assert!(response.result.is_some());
assert!(response.result.unwrap()["serverInfo"]["name"]
.as_str()
.unwrap()
.contains("swissarmyhammer"));
}
#[tokio::test]
async fn test_mcp_list_prompts() {
let server = setup_test_server().await;
let request = MCPRequest {
jsonrpc: "2.0".to_string(),
method: "prompts/list".to_string(),
params: json!({}),
id: Some(json!(2)),
};
let response = server.handle_request(request).await.unwrap();
let prompts = &response.result.unwrap()["prompts"];
assert!(prompts.is_array());
assert!(!prompts.as_array().unwrap().is_empty());
}
Testing CLI Commands
// tests/integration/cli_commands.rs
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::tempdir;
#[test]
fn test_list_command() {
let mut cmd = Command::cargo_bin("swissarmyhammer").unwrap();
cmd.arg("list")
.assert()
.success()
.stdout(predicate::str::contains("Available prompts:"));
}
#[test]
fn test_serve_command_help() {
let mut cmd = Command::cargo_bin("swissarmyhammer").unwrap();
cmd.arg("serve")
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("Start the MCP server"));
}
#[test]
fn test_export_import_workflow() {
let temp_dir = tempdir().unwrap();
let export_path = temp_dir.path().join("export.tar.gz");
// Export
Command::cargo_bin("swissarmyhammer").unwrap()
.arg("export")
.arg(&export_path)
.assert()
.success();
// Import
Command::cargo_bin("swissarmyhammer").unwrap()
.arg("import")
.arg(&export_path)
.arg("--dry-run")
.assert()
.success()
.stdout(predicate::str::contains("Would import"));
}
Property Testing
Using Proptest
// src/validation.rs
use proptest::prelude::*;
fn is_valid_prompt_name(name: &str) -> bool {
!name.is_empty()
&& name.chars().all(|c| c.is_alphanumeric() || c == '-')
&& name.chars().next().unwrap().is_alphabetic()
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn test_valid_names_accepted(name in "[a-z][a-z0-9-]{0,50}") {
assert!(is_valid_prompt_name(&name));
}
#[test]
fn test_invalid_names_rejected(name in "[^a-z].*|.*[^a-z0-9-].*") {
// Names starting with non-letter or containing invalid chars
if !name.chars().next().unwrap().is_alphabetic()
|| name.chars().any(|c| !c.is_alphanumeric() && c != '-') {
assert!(!is_valid_prompt_name(&name));
}
}
}
}
Testing Template Rendering
use proptest::prelude::*;
proptest! {
#[test]
fn test_template_escaping(
user_input in any::<String>(),
template in "Hello {{name}}!"
) {
let mut args = HashMap::new();
args.insert("name", &user_input);
let result = render_template(&template, &args).unwrap();
// Should not contain raw HTML
if user_input.contains('<') {
assert!(!result.contains('<'));
}
}
}
Testing Async Code
Basic Async Tests
#[tokio::test]
async fn test_async_prompt_loading() {
let manager = PromptManager::new();
let result = manager.load_prompts_async().await;
assert!(result.is_ok());
let prompts = manager.list_prompts().await;
assert!(!prompts.is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_concurrent_access() {
let manager = Arc::new(PromptManager::new());
let handle1 = {
let mgr = Arc::clone(&manager);
tokio::spawn(async move {
mgr.get_prompt("test1").await
})
};
let handle2 = {
let mgr = Arc::clone(&manager);
tokio::spawn(async move {
mgr.get_prompt("test2").await
})
};
let (result1, result2) = tokio::join!(handle1, handle2);
assert!(result1.is_ok());
assert!(result2.is_ok());
}
Testing Timeouts
#[tokio::test]
async fn test_operation_timeout() {
let manager = PromptManager::new();
let result = tokio::time::timeout(
Duration::from_secs(5),
manager.slow_operation()
).await;
assert!(result.is_ok(), "Operation should complete within timeout");
}
Test Fixtures
Using Test Data
Create reusable test data in tests/fixtures/
:
// tests/common/mod.rs
use std::path::PathBuf;
pub fn test_prompt_content() -> &'static str {
r#"---
name: test-prompt
title: Test Prompt
description: A prompt for testing
arguments:
- name: input
description: Test input
required: true
---
Process this input: {{input}}"#
}
pub fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
}
pub fn load_fixture(name: &str) -> String {
std::fs::read_to_string(fixtures_dir().join(name))
.expect("Failed to load fixture")
}
Test Builders
// tests/common/builders.rs
pub struct PromptBuilder {
name: String,
title: String,
content: String,
arguments: Vec<ArgumentSpec>,
}
impl PromptBuilder {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
title: format!("{} Title", name),
content: "Default content".to_string(),
arguments: vec![],
}
}
pub fn with_argument(mut self, name: &str, required: bool) -> Self {
self.arguments.push(ArgumentSpec {
name: name.to_string(),
required,
..Default::default()
});
self
}
pub fn build(self) -> String {
// Generate YAML front matter and content
format!(r#"---
name: {}
title: {}
arguments:
{}
---
{}"#, self.name, self.title,
self.arguments.iter()
.map(|a| format!(" - name: {}\n required: {}", a.name, a.required))
.collect::<Vec<_>>()
.join("\n"),
self.content)
}
}
// Usage in tests
#[test]
fn test_with_builder() {
let prompt_content = PromptBuilder::new("test")
.with_argument("input", true)
.with_argument("format", false)
.build();
let prompt = Prompt::parse(&prompt_content).unwrap();
assert_eq!(prompt.arguments.len(), 2);
}
Performance Testing
Benchmarks
Create benchmarks in benches/
:
// benches/prompt_loading.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
use swissarmyhammer::PromptManager;
fn benchmark_prompt_loading(c: &mut Criterion) {
let mut group = c.benchmark_group("prompt_loading");
for size in [10, 100, 1000].iter() {
group.bench_with_input(
BenchmarkId::from_parameter(size),
size,
|b, &size| {
let temp_dir = create_test_prompts(size);
b.iter(|| {
let manager = PromptManager::new();
manager.add_directory(temp_dir.path());
manager.load_prompts()
});
},
);
}
group.finish();
}
fn benchmark_template_rendering(c: &mut Criterion) {
c.bench_function("render_simple_template", |b| {
let template = "Hello {{name}}, welcome to {{place}}!";
let mut args = HashMap::new();
args.insert("name", "Alice");
args.insert("place", "Wonderland");
b.iter(|| {
black_box(render_template(template, &args))
});
});
}
criterion_group!(benches, benchmark_prompt_loading, benchmark_template_rendering);
criterion_main!(benches);
Profiling Tests
#[test]
#[ignore] // Run with cargo test -- --ignored
fn profile_large_prompt_set() {
let temp_dir = create_test_prompts(10000);
let start = Instant::now();
let manager = PromptManager::new();
manager.add_directory(temp_dir.path());
manager.load_prompts().unwrap();
let duration = start.elapsed();
println!("Loaded 10000 prompts in {:?}", duration);
assert!(duration < Duration::from_secs(5), "Loading too slow");
}
Test Coverage
Generating Coverage Reports
# Install tarpaulin
cargo install cargo-tarpaulin
# Generate coverage report
cargo tarpaulin --out Html --output-dir coverage
# With specific features
cargo tarpaulin --features "experimental" --out Lcov
# Exclude test code from coverage
cargo tarpaulin --exclude-files "*/tests/*" --exclude-files "*/benches/*"
Coverage Configuration
.tarpaulin.toml
:
[default]
exclude-files = ["*/tests/*", "*/benches/*", "*/examples/*"]
ignored = false
timeout = "600s"
features = "all"
[report]
out = ["Html", "Lcov"]
output-dir = "coverage"
Test Utilities
Custom Assertions
// tests/common/assertions.rs
pub trait PromptAssertions {
fn assert_valid_prompt(&self);
fn assert_has_argument(&self, name: &str);
fn assert_renders_with(&self, args: &HashMap<String, String>);
}
impl PromptAssertions for Prompt {
fn assert_valid_prompt(&self) {
assert!(!self.name.is_empty(), "Prompt name is empty");
assert!(!self.title.is_empty(), "Prompt title is empty");
assert!(is_valid_prompt_name(&self.name), "Invalid prompt name");
}
fn assert_has_argument(&self, name: &str) {
assert!(
self.arguments.iter().any(|a| a.name == name),
"Prompt missing expected argument: {}", name
);
}
fn assert_renders_with(&self, args: &HashMap<String, String>) {
let result = self.render(args);
assert!(result.is_ok(), "Failed to render: {:?}", result.err());
assert!(!result.unwrap().is_empty(), "Rendered output is empty");
}
}
Test Helpers
// tests/common/helpers.rs
use std::sync::Once;
static INIT: Once = Once::new();
pub fn init_test_logging() {
INIT.call_once(|| {
env_logger::builder()
.filter_level(log::LevelFilter::Debug)
.is_test(true)
.init();
});
}
pub fn with_test_env<F>(vars: Vec<(&str, &str)>, test: F)
where
F: FnOnce() + std::panic::UnwindSafe,
{
let _guards: Vec<_> = vars.into_iter()
.map(|(k, v)| {
env::set_var(k, v);
defer::defer(move || env::remove_var(k))
})
.collect();
test();
}
// Usage
#[test]
fn test_with_env_vars() {
with_test_env(vec![
("SWISSARMYHAMMER_DEBUG", "true"),
("SWISSARMYHAMMER_PORT", "9999"),
], || {
let config = Config::from_env();
assert!(config.debug);
assert_eq!(config.port, 9999);
});
}
Debugging Tests
Debug Output
#[test]
fn test_with_debug_output() {
init_test_logging();
log::debug!("Starting test");
let result = some_operation();
// Print debug info on failure
if result.is_err() {
eprintln!("Operation failed: {:?}", result);
eprintln!("Current state: {:?}", get_debug_state());
}
assert!(result.is_ok());
}
Test Isolation
#[test]
fn test_isolated_state() {
// Use a unique test ID to avoid conflicts
let test_id = uuid::Uuid::new_v4();
let test_dir = temp_dir().join(format!("test-{}", test_id));
// Ensure cleanup even on panic
let _guard = defer::defer(|| {
let _ = fs::remove_dir_all(&test_dir);
});
// Run test with isolated state
run_test_in_dir(&test_dir);
}
CI Testing
GitHub Actions Test Matrix
name: Test
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
rust: [stable, beta, nightly]
features: ["", "all", "experimental"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ matrix.rust }}
- name: Test
run: cargo test --features "${{ matrix.features }}"
- name: Test Examples
run: cargo test --examples
- name: Doc Tests
run: cargo test --doc
Best Practices
1. Test Organization
- Keep unit tests with the code
- Use integration tests for workflows
- Group related tests
- Share common utilities
2. Test Naming
#[test]
fn test_parse_valid_prompt() { } // Clear what's being tested
#[test]
fn test_render_with_missing_arg() { } // Clear expected outcome
#[test]
fn test_concurrent_access_safety() { } // Clear test scenario
3. Test Independence
- Each test should be independent
- Use temporary directories
- Clean up resources
- Don’t rely on test order
4. Test Coverage
- Aim for >80% coverage
- Test edge cases
- Test error paths
- Test concurrent scenarios
5. Performance
- Keep tests fast (<100ms each)
- Use
#[ignore]
for slow tests - Run slow tests in CI only
- Mock expensive operations
Next Steps
- Read Development Setup for environment setup
- See Contributing for contribution guidelines
- Check CI/CD for automated testing
- Review Benchmarking for performance testing