Testing
Testing Ruby extensions written in Rust requires consideration of both the Ruby and Rust sides of your code. This chapter covers best practices and tools for comprehensive testing.
Rust
Test Types
A well-tested Ruby extension should include:
- Unit Tests: Testing individual Rust functions
- Integration Tests: Testing the Ruby API
- Memory Tests: Ensuring proper memory management
- Cross-Platform Tests: Verifying behavior across platforms
Ruby Tests
Ruby tests should verify the public API of your extension:
require "test_helper"
class MyExtensionTest < Test::Unit::TestCase
def setup
# Any setup code
end
def test_basic_functionality
result = MyExtension.process_data("input")
assert_equal "expected output", result
end
def test_error_handling
assert_raises(MyExtension::Error) do
MyExtension.process_data(nil)
end
end
def test_memory_management
GC.stress = true
100.times do
obj = MyExtension::Object.new
obj.perform_operation
end
GC.stress = false
end
end
Rust Tests
Rust tests in your extension should focus on the internal logic:
// Mock function for testing
fn process_input(input: &str) -> Result<String, String> {
if input.is_empty() {
Err("Input cannot be empty".to_string())
} else {
Ok(format!("processed {}", input))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_internal_processing() {
let input = "test data";
let result = process_input(input);
assert_eq!(result, Ok("processed test data".to_string()));
}
#[test]
fn test_error_conditions() {
let result = process_input("");
assert!(result.is_err());
}
}
Memory Testing
Test memory management using rb-sys's test helpers:
use rb_sys_test_helpers::ruby_test;
use magnus::{Value, value::ReprValue};
#[ruby_test]
fn test_no_memory_leaks() {
let ruby = unsafe { magnus::Ruby::get_unchecked() };
// Create and drop Ruby objects
let obj: Value = ruby.eval("Object.new")
.unwrap_or_else(|_| ruby.qnil().as_value());
let _ = obj; // Ensure the object is used
// Run GC and check memory usage
magnus::gc::start();
}
Cross-Platform Testing
Ensure your tests run on all target platforms:
# test/platform_specific_test.rb
class PlatformSpecificTest < Test::Unit::TestCase
def test_platform_specific_behavior
if RUBY_PLATFORM =~ /darwin/
# macOS specific tests
test_macos_behavior
elsif RUBY_PLATFORM =~ /linux/
# Linux specific tests
test_linux_behavior
elsif RUBY_PLATFORM =~ /mingw|mswin/
# Windows specific tests
test_windows_behavior
end
end
private
def test_macos_behavior
# macOS specific assertions
end
def test_linux_behavior
# Linux specific assertions
end
def test_windows_behavior
# Windows specific assertions
end
end
Integration Testing
Test the complete flow from Ruby to Rust and back:
class IntegrationTest < Test::Unit::TestCase
def test_complete_workflow
# Setup test data
input = prepare_test_data
# Process through Rust
result = MyExtension.process(input)
# Verify results
assert_valid_result(result)
# Cleanup
cleanup_test_data
end
private
def prepare_test_data
# Create necessary test fixtures
end
def assert_valid_result(result)
# Comprehensive result validation
end
def cleanup_test_data
# Clean up any resources
end
end
Performance Testing
Implement benchmarks to track performance:
#[cfg(test)]
mod benchmarks {
use super::*;
use criterion::{criterion_group, criterion_main, Criterion};
fn benchmark_process(c: &mut Criterion) {
c.bench_function("process 1000 items", |b| {
b.iter(|| {
// Your benchmarked code here
})
});
}
criterion_group!(benches, benchmark_process);
criterion_main!(benches);
}
Best Practices
- Test Both Languages: Write tests in both Ruby and Rust
- Memory Verification: Use
GC.stress
in Ruby tests - Error Cases: Test error handling thoroughly
- Platform Coverage: Test on all target platforms
- Integration Tests: Test complete workflows
- Performance Metrics: Include benchmarks
Common Test Patterns
1. Exception Testing
def test_exception_handling
assert_raises(MyExtension::Error, "Should raise on invalid input") do
MyExtension.process(nil)
end
assert_raises(ArgumentError, "Should raise on wrong type") do
MyExtension.process(123) # Expecting string
end
end
2. Memory Stress Testing
def test_under_memory_pressure
GC.stress = true
begin
100.times do
obj = MyExtension::Object.new
obj.process_data("test")
obj = nil
end
ensure
GC.stress = false
end
end
3. Thread Safety Testing
def test_thread_safety
threads = 10.times.map do
Thread.new do
100.times do
result = MyExtension.process("test")
assert_equal "expected", result
end
end
end
threads.each(&:join)
end
CI/CD Integration
Configure CI to test across platforms:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
ruby: [2.7, 3.0, 3.1, 3.2]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
- name: Install dependencies
run: bundle install
- name: Run tests
run: bundle exec rake test
Debugging Tests
Useful techniques for debugging test failures:
- Enable Verbose Output:
ENV["RUST_BACKTRACE"] = "1"
ENV["RUST_LOG"] = "debug"
- Use Debug Assertions:
fn validate_input(value: i32) {
debug_assert!(value > 0, "Value must be positive, got {}", value);
debug_assert!(value < 100, "Value must be less than 100, got {}", value);
}
- Add Logging:
use log::{debug, info};
use magnus::Error;
fn process_data(input: &str) -> Result<String, Error> {
debug!("Processing input: {}", input);
// Processing logic
let result = input.to_uppercase();
info!("Processing completed");
Ok(result)
}