Skip to content

Latest commit

 

History

History
250 lines (198 loc) · 7.27 KB

rust-tests.instructions.md

File metadata and controls

250 lines (198 loc) · 7.27 KB
applyTo **/*.rs
description Rust test code authoring conventions

Rust Test Instructions

Conventions for Rust test code. All conventions from rust.instructions.md apply, including naming, error handling, and module structure.

Test Module Placement

Place unit tests in #[cfg(test)] mod tests within the source file they exercise:

#[cfg(test)]
mod tests {
    use super::*;
    use wiremock::matchers::method;
    use wiremock::{Mock, MockServer, ResponseTemplate};

    #[test]
    fn given_valid_input_parse_returns_config() {
        let json = r#"{"endpoint": "http://127.0.0.1"}"#;
        let config: AppConfig = serde_json::from_str(json).unwrap();
        assert_eq!(config.polling_interval_secs, 10);
    }

    #[tokio::test]
    async fn when_endpoint_available_fetch_returns_data() {
        let mock_server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"data"))
            .mount(&mock_server)
            .await;

        let config = AppConfig {
            endpoint: mock_server.uri(),
            polling_interval_secs: 10,
        };
        let service = PollingService::new(config);
        let result = service.fetch().await;
        assert!(result.is_ok(), "fetch should succeed when the mock endpoint is available");
    }
}

Test Naming

Test method format: given_context_when_action_then_expected or descriptive snake_case that reads as a behavior statement.

given_valid_input_parse_returns_config
when_endpoint_unavailable_send_returns_error
parses_empty_payload_as_default

Prefer one assertion per test. Related assertions validating the same behavior are acceptable.

Mocking Libraries

Library Usage
mockall Preferred for trait-based mocking
wiremock HTTP server mocking in async tests
mockito Lightweight HTTP mocking for synchronous or async tests

Keep unit tests network-isolated. Unit tests must not perform DNS resolution or connect to non-loopback addresses. For HTTP behavior, inject a loopback endpoint supplied by wiremock, mockito, or an equivalent local test double. Tests that require a deployed service belong in a separately controlled integration-test environment.

Use mockall to generate mock implementations from traits via #[automock]:

use mockall::automock;

// Application types — defined in your crate (see rust.instructions.md)
pub struct Item {
    pub id: String,
}

// Uses the module-scoped Result alias from rust.instructions.md
#[automock]
pub trait Repository: Send + Sync {
    fn find_by_id(&self, id: &str) -> Result<Option<Item>>;
}

pub struct ItemService {
    repo: Box<dyn Repository>,
}

impl ItemService {
    pub fn new(repo: Box<dyn Repository>) -> Self {
        Self { repo }
    }

    pub fn get(&self, id: &str) -> Result<Option<Item>> {
        self.repo.find_by_id(id)
    }
}

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

    #[test]
    fn given_existing_item_service_returns_it() {
        let mut mock = MockRepository::new();
        mock.expect_find_by_id()
            .with(eq("42"))
            .returning(|_| Ok(Some(Item { id: "42".into() })));

        let service = ItemService::new(Box::new(mock));
        let result = service.get("42").unwrap();
        assert_eq!(result.unwrap().id, "42");
    }
}

Use wiremock to mock HTTP servers in async tests:

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

#[tokio::test]
async fn when_api_returns_ok_fetch_succeeds() {
    let mock_server = MockServer::start().await;

    Mock::given(method("GET"))
        .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": "1"}"#))
        .mount(&mock_server)
        .await;

    let client = reqwest::Client::new();
    let response = client.get(mock_server.uri()).send().await.unwrap();
    assert_eq!(response.status(), 200);
}

Add test dependencies to [dev-dependencies] in Cargo.toml:

[dev-dependencies]
mockall = "0.13"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["macros", "rt"] }
wiremock = "0.6"

Test Data Patterns

Use builder functions or fixture helpers for test data rather than repeating inline construction:

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

    fn sample_config() -> AppConfig {
        AppConfig {
            endpoint: "http://127.0.0.1".into(),
            polling_interval_secs: 10,
        }
    }

    #[test]
    fn given_custom_interval_config_uses_override() {
        let config = AppConfig {
            polling_interval_secs: 30,
            ..sample_config()
        };
        assert_eq!(config.polling_interval_secs, 30);
    }
}

Inline construction is acceptable for simple one-field tests where a builder adds no clarity.

Integration Tests

Place integration tests in the tests/ directory at the crate root. Each file in tests/ compiles as a separate crate with access to the library's public API only:

// tests/polling_integration.rs
use my_service::AppConfig;

#[tokio::test]
async fn given_valid_config_service_starts() {
    let config = AppConfig {
        endpoint: "https://example.com".into(),
        polling_interval_secs: 1,
    };
    assert!(!config.endpoint.is_empty());
}

Test Conventions

  • Use #[tokio::test] for async tests.
  • Prefer assertion messages that explain intent: assert!(result.is_ok(), "should parse valid JSON").
  • Use builder functions or fixture helpers for test data rather than repeating inline construction.
  • Place integration tests in the tests/ directory at the crate root.

Complete Example

Types referenced below (AppConfig, ServiceError, Result alias) are defined in rust.instructions.md.

#[cfg(test)]
mod tests {
    use super::*;
    use wiremock::matchers::method;
    use wiremock::{Mock, MockServer, ResponseTemplate};

    // Fixture helper — see Test Data Patterns
    fn sample_config(endpoint: String) -> AppConfig {
        AppConfig {
            endpoint,
            polling_interval_secs: 10,
        }
    }

    #[test]
    fn given_defaults_config_has_ten_second_interval() {
        let config = sample_config("http://127.0.0.1".into());
        assert_eq!(config.polling_interval_secs, 10);
    }

    #[test]
    fn service_error_not_found_formats_message() {
        let err = ServiceError::not_found("item 42");
        assert_eq!(err.to_string(), "Not found: item 42");
    }

    #[tokio::test]
    async fn when_api_returns_service_unavailable_fetch_error_contains_status() {
        let mock_server = MockServer::start().await;
        Mock::given(method("GET"))
            .respond_with(ResponseTemplate::new(503))
            .mount(&mock_server)
            .await;

        let config = sample_config(mock_server.uri());
        let service = PollingService::new(config);
        let error = service.fetch().await.expect_err("503 response should fail");
        assert!(error.to_string().contains("HTTP 503"), "error should include status");
    }
}