Testing Guide
Comprehensive testing is essential for maintaining code quality and preventing regressions. This guide explains how to write and run tests in Void.
Test Organization
Tests are organized into three categories:
Unit Tests
Located within the source code files, right next to the code they test.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
Integration Tests
Located in the tests/ directory, testing the public API.
crate/
├── src/
│ ├── lib.rs
│ └── main.rs
└── tests/
├── common.rs
├── integration_test_1.rs
└── integration_test_2.rs
Documentation Tests
Tests embedded in documentation comments.
/// Adds two numbers.
///
/// # Examples
///
/// ```
/// use voidmc::add;
/// assert_eq!(add(2, 3), 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Running Tests
Basic Commands
# Run all tests
cargo test
# Run tests for specific crate
cargo test -p void-codec
# Run specific test by name
cargo test test_varint
# Run with output visible
cargo test -- --nocapture
# Run single-threaded (easier debugging)
cargo test -- --test-threads=1
# Run ignored tests only
cargo test -- --ignored
# Run all including ignored
cargo test -- --include-ignored
Advanced Options
# Show test names without running
cargo test --list
# Run with environment variables
RUST_BACKTRACE=full cargo test
# Generate coverage
cargo tarpaulin --out Html
# Benchmark
cargo bench
Writing Good Tests
Principles
- Arrange-Act-Assert Pattern
#[test]
fn test_decode_packet() {
// Arrange
let mut buffer = vec![0x03, 0x00, 0x01];
// Act
let result = Packet::decode(&mut buffer);
// Assert
assert!(result.is_ok());
}
- One Assertion Per Test (when possible)
// Good: focused test
#[test]
fn test_player_name_is_stored() {
let player = Player::new("Alice".to_string());
assert_eq!(player.name, "Alice");
}
// Also good: multiple related assertions
#[test]
fn test_player_creation() {
let player = Player::new("Alice".to_string());
assert_eq!(player.name, "Alice");
assert_eq!(player.position, Position::default());
}
// Less good: testing multiple independent things
#[test]
fn test_everything() {
// player tests
// entity tests
// world tests
}
- Descriptive Names
// Good
#[test]
fn decode_varint_with_value_zero_returns_zero() { }
#[test]
fn encode_string_with_empty_input_produces_zero_length() { }
// Less clear
#[test]
fn test_varint() { }
#[test]
fn test_string() { }
Example Test Suite
#[cfg(test)]
mod tests {
use super::*;
mod encode {
use super::*;
#[test]
fn varint_zero() {
let mut buf = Vec::new();
0i32.encode(&mut buf);
assert_eq!(buf, vec![0x00]);
}
#[test]
fn varint_127() {
let mut buf = Vec::new();
127i32.encode(&mut buf);
assert_eq!(buf, vec![0x7f]);
}
#[test]
fn varint_128() {
let mut buf = Vec::new();
128i32.encode(&mut buf);
assert_eq!(buf, vec![0x80, 0x01]);
}
}
mod decode {
use super::*;
#[test]
fn varint_single_byte() {
let mut buf = &[0x05][..];
let value = i32::decode(&mut buf).unwrap();
assert_eq!(value, 5);
}
#[test]
fn varint_overflow_returns_error() {
let mut buf = &[0xFF, 0xFF, 0xFF, 0xFF, 0xFF][..];
let result = i32::decode(&mut buf);
assert!(result.is_err());
}
}
}
Testing Different Components
Codec Testing (void-codec)
Focus on serialization/deserialization:
#[test]
fn roundtrip_packet_data() {
let original = SomePacket { /* ... */ };
// Encode
let mut buffer = Vec::new();
original.encode(&mut buffer);
// Decode
let mut reader = &buffer[..];
let decoded = SomePacket::decode(&mut reader).unwrap();
// Verify
assert_eq!(original, decoded);
}
Protocol Testing (void-protocol)
Test packet definitions and state transitions:
#[test]
fn play_state_accepts_move_packet() {
let packet = PlayPacket::PlayerMovement { /* ... */ };
let state = State::Play;
assert!(is_valid_packet_for_state(&packet, state));
}
#[test]
fn status_state_rejects_play_packets() {
let packet = PlayPacket::PlayerMovement { /* ... */ };
let state = State::Status;
assert!(!is_valid_packet_for_state(&packet, state));
}
Async Testing
Use #[tokio::test] for async code:
#[tokio::test]
async fn client_receives_welcome_message() {
let server = start_test_server().await;
let mut client = connect_to_server(&server).await;
let message = client.read_next_packet().await.unwrap();
assert_eq!(message.text, "Welcome to Void!");
}
#[tokio::test]
async fn concurrent_clients_dont_interfere() {
let server = start_test_server().await;
let clients: Vec<_> = (0..10)
.map(|_| connect_to_server(&server))
.collect();
// Verify all are connected
assert_eq!(clients.len(), 10);
}
Testing Best Practices
What to Test
✅ Must test:
- Edge cases (0, negative, overflow, max value)
- Error conditions
- State transitions
- Critical paths
✅ Should test:
- Typical use cases
- Integration points
- Performance-critical code
What NOT to Overly Test
❌ Don't test:
- Third-party library behavior (they have their own tests)
- Trivial getter functions
- The compiler (trust Rust's type system)
Coverage Goals
Aim for coverage on:
- Critical code: 95%+ (codec, protocol parsing)
- Core logic: 80%+ (game state, handlers)
- Utilities: 70%+ (helpers, formatting)
# Generate coverage report
cargo tarpaulin --out Html
Debugging Tests
Getting More Information
// Enable backtrace
RUST_BACKTRACE=1 cargo test
// Show println! output
cargo test -- --nocapture
// Run single-threaded for deterministic output
cargo test -- --test-threads=1
Using println! in Tests
#[test]
fn test_with_debug_output() {
let result = some_function();
println!("Result: {:?}", result); // Shows when test fails
}
Interactive Debugging
# Run test in debugger
rust-gdb --args cargo test test_name -- --test-threads=1
Continuous Integration
All tests run automatically on:
- Every commit push
- Every pull request
- Before merge to main
Tests must pass before code can be merged.
Common Test Patterns
Testing Result Types
#[test]
fn valid_input_returns_ok() {
let result = parse("valid");
assert!(result.is_ok());
}
#[test]
fn invalid_input_returns_err() {
let result = parse("invalid");
assert!(result.is_err());
}
#[test]
fn error_message_is_descriptive() {
let result = parse("invalid").unwrap_err();
assert!(result.contains("expected"));
}
Testing Panic Behavior
#[test]
#[should_panic(expected = "divide by zero")]
fn divide_by_zero_panics() {
divide(10, 0);
}
Testing with Fixtures
fn setup() -> TestEnvironment {
TestEnvironment::new()
}
#[test]
fn test_with_setup() {
let env = setup();
// Use env...
}
Test Maintenance
- Keep tests synchronized with code changes
- Remove obsolete tests
- Refactor duplicate test code
- Update test names if behavior changes