Writing effective unit tests for Zentinel components.
Unit Test Basics
Test Structure
Follow the Arrange-Act-Assert pattern:
#[test]
fn test_route_matches_path() {
// Arrange
let route = Route::new("/api/users");
// Act
let matches = route.matches("/api/users");
// Assert
assert!(matches);
}
Test Naming
Use descriptive names that explain what is being tested:
// Good: describes the scenario and expected outcome
#[test]
fn test_route_with_wildcard_matches_any_suffix() { }
#[test]
fn test_upstream_returns_error_when_all_targets_unhealthy() { }
#[test]
fn test_config_parser_rejects_duplicate_route_names() { }
// Bad: vague names
#[test]
fn test_route() { }
#[test]
fn test_error() { }
Testing Patterns
Testing Success Cases
#[test]
fn test_parse_valid_config() {
let input = r#"
server {
worker-threads 4
}
listeners {
listener "http" {
address "0.0.0.0:8080"
}
}
"#;
let config = parse_config(input).unwrap();
assert_eq!(config.server.worker_threads, 4);
assert_eq!(config.listeners.len(), 1);
}
Testing Error Cases
#[test]
fn test_parse_rejects_invalid_port() {
let input = r#"
listeners {
listener "http" {
address "0.0.0.0:99999"
}
}
"#;
let result = parse_config(input);
assert!(result.is_err());
let error = result.unwrap_err();
assert!(error.to_string().contains("port"));
}
Testing with Expected Errors
use std::assert_matches::assert_matches;
#[test]
fn test_specific_error_type() {
let result = parse_config("invalid");
assert_matches!(
result,
Err(ConfigError::ParseError { line: 1, .. })
);
}
Testing Edge Cases
#[test]
fn test_empty_input() {
let result = parse_config("");
assert!(result.is_err());
}
#[test]
fn test_whitespace_only() {
let result = parse_config(" \n\t ");
assert!(result.is_err());
}
#[test]
fn test_maximum_routes() {
let config = generate_config_with_routes(1000);
let result = parse_config(&config);
assert!(result.is_ok());
}
#[test]
fn test_unicode_in_path() {
let route = Route::new("/api/用户");
assert!(route.matches("/api/用户"));
}
Testing Private Functions
Using #[cfg(test)]
// src/lib.rs
fn internal_helper(x: i32) -> i32 {
x * 2
}
pub fn public_function(x: i32) -> i32 {
internal_helper(x) + 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal_helper() {
// Can access private functions in tests
assert_eq!(internal_helper(5), 10);
}
}
Testing via Public Interface
Prefer testing through public APIs when possible:
#[test]
fn test_public_function_uses_helper_correctly() {
// Tests internal_helper indirectly
assert_eq!(public_function(5), 11); // 5*2 + 1
}
Testing Complex Types
Custom Assertions
fn assert_route_matches(route: &Route, path: &str) {
assert!(
route.matches(path),
"Expected route {:?} to match path {:?}",
route.pattern(),
path
);
}
fn assert_route_rejects(route: &Route, path: &str) {
assert!(
!route.matches(path),
"Expected route {:?} NOT to match path {:?}",
route.pattern(),
path
);
}
#[test]
fn test_route_patterns() {
let route = Route::new("/api/*");
assert_route_matches(&route, "/api/users");
assert_route_matches(&route, "/api/users/123");
assert_route_rejects(&route, "/other/path");
}
Testing Collections
#[test]
fn test_route_priority_ordering() {
let routes = vec![
Route::new("/api/users").priority(100),
Route::new("/api/*").priority(50),
Route::new("/*").priority(10),
];
let sorted = sort_by_priority(routes);
assert_eq!(sorted[0].pattern(), "/api/users");
assert_eq!(sorted[1].pattern(), "/api/*");
assert_eq!(sorted[2].pattern(), "/*");
}
Testing Structs with Many Fields
#[test]
fn test_config_defaults() {
let config = Config::default();
// Test important defaults explicitly
assert_eq!(config.server.worker_threads, 0); // auto
assert_eq!(config.server.graceful_shutdown, Duration::from_secs(30));
assert!(config.listeners.is_empty());
// Use snapshot testing for full comparison
insta::assert_debug_snapshot!(config);
}
Parameterized Tests
Using Arrays
#[test]
fn test_path_matching_cases() {
let cases = [
("/api/users", "/api/users", true),
("/api/*", "/api/users", true),
("/api/*", "/other", false),
("/*", "/anything", true),
("/api/users", "/api/users/123", false),
];
for (pattern, path, expected) in cases {
let route = Route::new(pattern);
assert_eq!(
route.matches(path),
expected,
"Pattern {:?} with path {:?}",
pattern,
path
);
}
}
Using test-case Crate
use test_case::test_case;
#[test_case("/api/users", "/api/users" => true ; "exact match")]
#[test_case("/api/*", "/api/users" => true ; "wildcard match")]
#[test_case("/api/*", "/other" => false ; "wildcard no match")]
#[test_case("/*", "/anything" => true ; "root wildcard")]
fn test_route_matching(pattern: &str, path: &str) -> bool {
Route::new(pattern).matches(path)
}
Testing with rstest
use rstest::rstest;
#[rstest]
#[case("/api/users", "/api/users", true)]
#[case("/api/*", "/api/users", true)]
#[case("/api/*", "/other", false)]
fn test_route_matching(
#[case] pattern: &str,
#[case] path: &str,
#[case] expected: bool,
) {
let route = Route::new(pattern);
assert_eq!(route.matches(path), expected);
}
Fixtures with rstest
use rstest::*;
#[fixture]
fn sample_config() -> Config {
Config::builder()
.worker_threads(4)
.timeout(Duration::from_secs(30))
.build()
}
#[rstest]
fn test_config_worker_threads(sample_config: Config) {
assert_eq!(sample_config.server.worker_threads, 4);
}
#[rstest]
fn test_config_timeout(sample_config: Config) {
assert_eq!(sample_config.server.timeout, Duration::from_secs(30));
}
Snapshot Testing
Using insta
use insta::{assert_snapshot, assert_debug_snapshot};
#[test]
fn test_config_serialization() {
let config = Config::default();
let json = serde_json::to_string_pretty(&config).unwrap();
assert_snapshot!(json);
}
#[test]
fn test_error_message_format() {
let error = ConfigError::InvalidPort { port: 99999 };
assert_snapshot!(error.to_string());
}
#[test]
fn test_route_debug_output() {
let route = Route::new("/api/*").priority(100);
assert_debug_snapshot!(route);
}
Updating snapshots:
# Review and accept changes
cargo insta test
cargo insta review
# Accept all changes
cargo insta test --accept
Testing Panics
Expected Panics
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_panics_on_invalid_index() {
let routes = vec![Route::new("/api")];
let _ = routes[5]; // Panics
}
Catching Panics
use std::panic;
#[test]
fn test_recovers_from_panic() {
let result = panic::catch_unwind(|| {
panic!("test panic");
});
assert!(result.is_err());
}
Test Helpers
Builder Pattern for Test Data
struct TestRequestBuilder {
method: String,
path: String,
headers: Vec<(String, String)>,
}
impl TestRequestBuilder {
fn new() -> Self {
Self {
method: "GET".to_string(),
path: "/".to_string(),
headers: vec![],
}
}
fn method(mut self, method: &str) -> Self {
self.method = method.to_string();
self
}
fn path(mut self, path: &str) -> Self {
self.path = path.to_string();
self
}
fn header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_string(), value.to_string()));
self
}
fn build(self) -> Request {
// Build actual request
}
}
#[test]
fn test_with_builder() {
let request = TestRequestBuilder::new()
.method("POST")
.path("/api/users")
.header("Content-Type", "application/json")
.build();
// Test with request
}
Best Practices
Keep Tests Independent
// Bad: tests depend on shared state
static mut COUNTER: i32 = 0;
#[test]
fn test_increment() {
unsafe { COUNTER += 1; }
// Depends on test execution order
}
// Good: each test has its own state
#[test]
fn test_increment() {
let mut counter = Counter::new();
counter.increment();
assert_eq!(counter.value(), 1);
}
Test One Thing Per Test
// Bad: testing multiple things
#[test]
fn test_route() {
let route = Route::new("/api/*");
assert!(route.matches("/api/users"));
assert!(!route.matches("/other"));
assert_eq!(route.priority(), 0);
}
// Good: separate tests
#[test]
fn test_route_matches_valid_path() {
let route = Route::new("/api/*");
assert!(route.matches("/api/users"));
}
#[test]
fn test_route_rejects_invalid_path() {
let route = Route::new("/api/*");
assert!(!route.matches("/other"));
}
#[test]
fn test_route_default_priority() {
let route = Route::new("/api/*");
assert_eq!(route.priority(), 0);
}
Use Clear Assertions
// Bad: unclear what's being tested
#[test]
fn test_something() {
let result = process("input");
assert!(result.is_some());
}
// Good: clear assertion message
#[test]
fn test_process_returns_value_for_valid_input() {
let result = process("valid_input");
assert!(
result.is_some(),
"Expected process to return Some for valid input"
);
}
Next Steps
- Integration Tests - Testing with real connections
- Load Testing - Performance testing
- Testing Overview - General testing strategy