Testing Connectors

Testing is critical for building reliable connectors. This guide covers testing strategies, patterns, and best practices for Strike48 connector development.

Testing Strategy

A well-tested connector should have:

Test Type Coverage Speed Purpose
Unit Tests Individual functions Fast Verify logic correctness
Integration Tests Full connector Medium Test Prospector Studio integration
End-to-End Tests Real tools Slow Verify actual behavior
Mock Tests External dependencies Fast Test error scenarios

Aim for 80%+ code coverage with a mix of all test types.

Project Structure

my-connector/
├── src/
│   ├── lib.rs          # Expose modules for testing
│   ├── connector.rs
│   ├── capabilities.rs
│   └── scanner.rs
├── tests/
│   ├── unit/
│   │   ├── mod.rs
│   │   ├── test_scanner.rs
│   │   └── test_capabilities.rs
│   └── integration/
│       ├── mod.rs
│       └── test_connector.rs
└── Cargo.toml

Setup

1. Configure Cargo.toml

[package]
name = "my-connector"
version = "0.1.0"
edition = "2021"

[lib]
name = "my_connector"
path = "src/lib.rs"

[[bin]]
name = "my-connector"
path = "src/main.rs"

[dependencies]
strike48-connector-sdk = { git = "https://github.com/Strike48/sdk-rs" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"

[dev-dependencies]
tokio-test = "0.4"
mockall = "0.12"
wiremock = "0.6"
tempfile = "3"

2. Create lib.rs

Expose modules for testing in src/lib.rs:

pub mod capabilities;
pub mod connector;
pub mod error;
pub mod scanner;
pub mod types;

// Re-export commonly used types
pub use connector::MyConnector;
pub use error::{Error, Result};
pub use types::*;

Update src/main.rs to use the library:

use my_connector::MyConnector;
use strike48_connector_sdk::*;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<()> {
    let config = ConnectorConfig::from_env();
    let connector = Arc::new(MyConnector::new()?);
    let runner = ConnectorRunner::new(config, connector);
    runner.run().await?;
    Ok(())
}

Unit Testing

Testing Pure Functions

// src/scanner.rs
pub struct Scanner;

impl Scanner {
    /// Parse port list "80,443,8000-9000" into Vec<u16>
    pub fn parse_ports(input: &str) -> Result<Vec<u16>> {
        let mut ports = Vec::new();

        for part in input.split(',') {
            let part = part.trim();

            if part.contains('-') {
                // Range: "8000-9000"
                let range: Vec<&str> = part.split('-').collect();
                if range.len() != 2 {
                    return Err(Error::InvalidPortRange(part.to_string()));
                }

                let start: u16 = range[0].parse()
                    .map_err(|_| Error::InvalidPortRange(part.to_string()))?;
                let end: u16 = range[1].parse()
                    .map_err(|_| Error::InvalidPortRange(part.to_string()))?;

                for port in start..=end {
                    ports.push(port);
                }
            } else {
                // Single port: "80"
                let port: u16 = part.parse()
                    .map_err(|_| Error::InvalidPortRange(part.to_string()))?;
                ports.push(port);
            }
        }

        Ok(ports)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_single_port() {
        let result = Scanner::parse_ports("80").unwrap();
        assert_eq!(result, vec![80]);
    }

    #[test]
    fn test_parse_multiple_ports() {
        let result = Scanner::parse_ports("80,443,8080").unwrap();
        assert_eq!(result, vec![80, 443, 8080]);
    }

    #[test]
    fn test_parse_port_range() {
        let result = Scanner::parse_ports("8000-8003").unwrap();
        assert_eq!(result, vec![8000, 8001, 8002, 8003]);
    }

    #[test]
    fn test_parse_mixed() {
        let result = Scanner::parse_ports("80, 443, 8000-8002").unwrap();
        assert_eq!(result, vec![80, 443, 8000, 8001, 8002]);
    }

    #[test]
    fn test_parse_invalid_format() {
        let result = Scanner::parse_ports("not-a-port");
        assert!(result.is_err());
    }

    #[test]
    fn test_parse_invalid_range() {
        let result = Scanner::parse_ports("8000-8001-8002");
        assert!(result.is_err());
    }
}

Testing Async Functions

// src/capabilities.rs
pub struct Capabilities;

impl Capabilities {
    pub async fn validate_target(target: &str) -> Result<String> {
        if target.is_empty() {
            return Err(Error::InvalidTarget("Target cannot be empty".into()));
        }

        // Resolve hostname to IP
        let addr = tokio::net::lookup_host(format!("{}:80", target))
            .await
            .map_err(|e| Error::InvalidTarget(format!("DNS lookup failed: {}", e)))?
            .next()
            .ok_or_else(|| Error::InvalidTarget("No IP address found".into()))?;

        Ok(addr.ip().to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_validate_localhost() {
        let result = Capabilities::validate_target("localhost").await;
        assert!(result.is_ok());
        let ip = result.unwrap();
        assert!(ip == "127.0.0.1" || ip == "::1");
    }

    #[tokio::test]
    async fn test_validate_empty_target() {
        let result = Capabilities::validate_target("").await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("empty"));
    }

    #[tokio::test]
    async fn test_validate_invalid_hostname() {
        let result = Capabilities::validate_target("invalid.hostname.doesnotexist").await;
        assert!(result.is_err());
    }
}

Mocking External Dependencies

Using mockall

use mockall::predicate::*;
use mockall::*;

#[automock]
pub trait ToolExecutor {
    fn execute(&self, cmd: &str, args: &[&str]) -> Result<String>;
}

pub struct RealToolExecutor;

impl ToolExecutor for RealToolExecutor {
    fn execute(&self, cmd: &str, args: &[&str]) -> Result<String> {
        let output = std::process::Command::new(cmd)
            .args(args)
            .output()?;

        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }
}

pub struct Scanner<T: ToolExecutor> {
    executor: T,
}

impl<T: ToolExecutor> Scanner<T> {
    pub fn new(executor: T) -> Self {
        Self { executor }
    }

    pub fn scan(&self, target: &str) -> Result<Vec<u16>> {
        let output = self.executor.execute("nmap", &["-p-", target])?;
        self.parse_output(&output)
    }

    fn parse_output(&self, output: &str) -> Result<Vec<u16>> {
        // Parse nmap output...
        Ok(vec![80, 443])
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_scan_with_mock() {
        let mut mock_executor = MockToolExecutor::new();
        mock_executor
            .expect_execute()
            .with(eq("nmap"), eq(&["-p-", "example.com"][..]))
            .times(1)
            .returning(|_, _| Ok("Discovered open port 80/tcp\nDiscovered open port 443/tcp".to_string()));

        let scanner = Scanner::new(mock_executor);
        let result = scanner.scan("example.com").unwrap();

        assert_eq!(result, vec![80, 443]);
    }

    #[test]
    fn test_scan_tool_failure() {
        let mut mock_executor = MockToolExecutor::new();
        mock_executor
            .expect_execute()
            .returning(|_, _| Err(Error::ToolNotFound("nmap".into())));

        let scanner = Scanner::new(mock_executor);
        let result = scanner.scan("example.com");

        assert!(result.is_err());
    }
}

Mocking HTTP Services with wiremock

use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};

pub struct ApiClient {
    base_url: String,
    client: reqwest::Client,
}

impl ApiClient {
    pub fn new(base_url: String) -> Self {
        Self {
            base_url,
            client: reqwest::Client::new(),
        }
    }

    pub async fn get_vulnerabilities(&self, cve: &str) -> Result<Vec<String>> {
        let url = format!("{}/api/cve/{}", self.base_url, cve);
        let response = self.client
            .get(&url)
            .send()
            .await?;

        let vulns: Vec<String> = response.json().await?;
        Ok(vulns)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_get_vulnerabilities() {
        // Start mock server
        let mock_server = MockServer::start().await;

        // Configure mock response
        Mock::given(method("GET"))
            .and(path("/api/cve/CVE-2024-1234"))
            .respond_with(ResponseTemplate::new(200)
                .set_body_json(vec!["SQL Injection", "XSS"]))
            .mount(&mock_server)
            .await;

        // Test with mock
        let client = ApiClient::new(mock_server.uri());
        let result = client.get_vulnerabilities("CVE-2024-1234").await.unwrap();

        assert_eq!(result, vec!["SQL Injection", "XSS"]);
    }
}

Integration Testing

Testing the Full Connector

Create tests/integration/test_connector.rs:

use my_connector::*;
use strike48_connector_sdk::*;
use std::sync::Arc;
use tokio::time::{sleep, Duration};

#[tokio::test]
async fn test_connector_initialization() {
    let connector = MyConnector::new();
    assert!(connector.is_ok());
}

#[tokio::test]
async fn test_connector_type() {
    let connector = MyConnector::new().unwrap();
    assert_eq!(connector.connector_type(), "my-connector");
}

#[tokio::test]
async fn test_connector_version() {
    let connector = MyConnector::new().unwrap();
    assert!(!connector.version().is_empty());
}

#[tokio::test]
async fn test_capability_execution() {
    let connector = MyConnector::new().unwrap();

    let request = serde_json::json!({
        "target": "127.0.0.1",
        "timeout": 60
    });

    let result = connector.execute(request, Some("quick-scan")).await;
    assert!(result.is_ok());

    let response = result.unwrap();
    assert!(response.get("target").is_some());
}

#[tokio::test]
async fn test_unknown_capability() {
    let connector = MyConnector::new().unwrap();

    let request = serde_json::json!({});
    let result = connector.execute(request, Some("unknown-capability")).await;

    assert!(result.is_err());
}

Testing with Temporary Files

use tempfile::TempDir;
use std::fs;

#[tokio::test]
async fn test_file_operations() {
    // Create temporary directory
    let temp_dir = TempDir::new().unwrap();
    let file_path = temp_dir.path().join("config.json");

    // Write test data
    let config = serde_json::json!({
        "target": "example.com",
        "ports": "80,443"
    });
    fs::write(&file_path, config.to_string()).unwrap();

    // Test connector with file
    let connector = MyConnector::from_file(&file_path).unwrap();
    assert_eq!(connector.target(), "example.com");

    // Temporary directory is automatically cleaned up
}

Test Organization

Shared Test Utilities

Create tests/common/mod.rs:

use my_connector::*;
use serde_json::Value;

/// Create test scan request
pub fn test_scan_request(target: &str) -> Value {
    serde_json::json!({
        "target": target,
        "ports": "80,443",
        "timeout": 60
    })
}

/// Create test connector
pub fn test_connector() -> MyConnector {
    MyConnector::new().expect("Failed to create test connector")
}

/// Assert scan result is valid
pub fn assert_valid_scan_result(result: &Value) {
    assert!(result.get("target").is_some());
    assert!(result.get("start_time").is_some());
    assert!(result.get("end_time").is_some());
    assert!(result.get("open_ports").is_some());
}

Use in tests:

mod common;

use common::*;

#[tokio::test]
async fn test_with_shared_utilities() {
    let connector = test_connector();
    let request = test_scan_request("localhost");
    let result = connector.execute(request, Some("quick-scan")).await.unwrap();
    assert_valid_scan_result(&result);
}

Testing Error Scenarios

#[tokio::test]
async fn test_timeout_handling() {
    let connector = MyConnector::new().unwrap();

    let request = serde_json::json!({
        "target": "scanme.nmap.org",
        "timeout": 1  // Very short timeout
    });

    let result = connector.execute(request, Some("full-scan")).await;

    // Should timeout gracefully
    assert!(result.is_err() || result.unwrap().get("status").unwrap() == "partial");
}

#[tokio::test]
async fn test_invalid_input() {
    let connector = MyConnector::new().unwrap();

    let request = serde_json::json!({
        "target": "invalid; target"  // Command injection attempt
    });

    let result = connector.execute(request, Some("quick-scan")).await;
    assert!(result.is_err());
}

#[tokio::test]
async fn test_missing_required_field() {
    let connector = MyConnector::new().unwrap();

    let request = serde_json::json!({
        // Missing "target" field
        "timeout": 60
    });

    let result = connector.execute(request, Some("quick-scan")).await;
    assert!(result.is_err());
}

Running Tests

Run All Tests

cargo test

Run Specific Test

cargo test test_parse_single_port

Run Tests with Output

cargo test -- --nocapture

Run Tests in Parallel

cargo test -- --test-threads=4

Run Integration Tests Only

cargo test --test integration

Run with Coverage

Install tarpaulin:

cargo install cargo-tarpaulin

Generate coverage report:

cargo tarpaulin --out Html

Continuous Integration

GitHub Actions Example

Create .github/workflows/test.yml:

name: Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Install Rust
      uses: actions-rs/toolchain@v1
      with:
        toolchain: stable
        override: true

    - name: Install nmap
      run: sudo apt-get update && sudo apt-get install -y nmap

    - name: Run tests
      run: cargo test --verbose

    - name: Run clippy
      run: cargo clippy -- -D warnings

    - name: Check formatting
      run: cargo fmt -- --check

  coverage:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Install tarpaulin
      run: cargo install cargo-tarpaulin

    - name: Generate coverage
      run: cargo tarpaulin --out Xml

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        files: ./cobertura.xml

Best Practices

✅ Do

  • Write tests first - TDD helps design better APIs
  • Test error paths - Don't just test the happy path
  • Use descriptive names - test_parse_invalid_port_range > test_parse_error
  • Keep tests independent - Each test should run in isolation
  • Mock external dependencies - Tests should be fast and reliable
  • Test edge cases - Empty strings, max values, special characters
  • Use code coverage - Aim for 80%+ coverage

❌ Don't

  • Test implementation details - Test behavior, not internals
  • Share state between tests - Avoid global state
  • Make tests slow - Mock external services
  • Ignore flaky tests - Fix or remove them
  • Write fragile tests - Tests shouldn't break on refactoring

Example: Complete Test Suite

See the complete test suite for the port scanner connector:

Next Steps

Resources