755-testing-error-paths — Testing Error Paths
Tutorial Video
Text description (accessibility)
This video demonstrates the "755-testing-error-paths — Testing Error Paths" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Happy-path tests are necessary but insufficient. Key difference from OCaml: 1. **Pattern matching**: Rust's `assert!(matches!(r, Err(ParseError::TooLong { len: 11, .. })))` is concise; OCaml uses `match r with Error (TooLong 11)
Tutorial
The Problem
Happy-path tests are necessary but insufficient. Error paths — malformed input, out-of-range values, empty fields, resource exhaustion — are where bugs hide and security vulnerabilities lurk. Testing error paths requires asserting on specific error variants, not just that an error occurred. Rust's Result and rich error enums make error path testing natural and exhaustive, unlike exception-based languages where error type testing requires awkward catch-and-inspect patterns.
🎯 Learning Outcomes
Err variants using assert!(matches!(result, Err(ParseError::Empty))) or exhaustive pattern matchingParseError enumDisplay messages contain user-friendly textassert_eq! on Result values directly when PartialEq is derivedCode Example
#[derive(Debug, PartialEq, Clone)]
pub enum ParseError {
Empty,
TooLong { len: usize, max: usize },
InvalidChar { ch: char, pos: usize },
}Key Differences
assert!(matches!(r, Err(ParseError::TooLong { len: 11, .. }))) is concise; OCaml uses match r with Error (TooLong 11) -> () | _ -> assert_failure "expected TooLong".#[derive(PartialEq)] enables assert_eq!(parse(""), Err(ParseError::Empty)); OCaml requires custom equality functions.anyhow/thiserror support error wrapping chains; OCaml's Error_monad (Tezos) does the same.OCaml Approach
OCaml error testing uses match on result values or Alcotest.check (Alcotest.result ...). QCheck generates random inputs for error paths, not just happy paths. OCaml's variant pattern matching is exhaustive: if you add a new error variant, the compiler forces you to handle it in all match expressions including tests. Result.get_error and Result.is_error provide imperative-style checks when pattern matching is verbose.
Full Source
#![allow(clippy::all)]
//! # Testing Error Paths
//!
//! Testing error cases and unwrap discipline.
/// Parse errors with detailed information
#[derive(Debug, PartialEq, Clone)]
pub enum ParseError {
Empty,
TooLong { len: usize, max: usize },
InvalidChar { ch: char, pos: usize },
OutOfRange { value: i64, min: i64, max: i64 },
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::Empty => write!(f, "input is empty"),
ParseError::TooLong { len, max } => write!(f, "too long: {} > {}", len, max),
ParseError::InvalidChar { ch, pos } => write!(f, "invalid char {:?} at {}", ch, pos),
ParseError::OutOfRange { value, min, max } => {
write!(f, "{} out of range [{}, {}]", value, min, max)
}
}
}
}
/// Parse a string to a positive u32
pub fn parse_positive(s: &str) -> Result<u32, ParseError> {
if s.is_empty() {
return Err(ParseError::Empty);
}
if s.len() > 10 {
return Err(ParseError::TooLong {
len: s.len(),
max: 10,
});
}
for (pos, ch) in s.char_indices() {
if !ch.is_ascii_digit() {
return Err(ParseError::InvalidChar { ch, pos });
}
}
let n: u64 = s.parse().unwrap();
if n == 0 || n > u32::MAX as u64 {
return Err(ParseError::OutOfRange {
value: n as i64,
min: 1,
max: u32::MAX as i64,
});
}
Ok(n as u32)
}
/// Safe division
pub fn divide(a: i64, b: i64) -> Result<i64, &'static str> {
if b == 0 {
Err("cannot divide by zero")
} else {
Ok(a / b)
}
}
/// Get the first element of a slice
pub fn head<T: Clone>(v: &[T]) -> Result<T, &'static str> {
v.first().cloned().ok_or("empty slice")
}
/// Get the last element of a slice
pub fn tail<T: Clone>(v: &[T]) -> Result<T, &'static str> {
v.last().cloned().ok_or("empty slice")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_positive_valid() {
assert_eq!(parse_positive("123"), Ok(123));
assert_eq!(parse_positive("1"), Ok(1));
assert_eq!(parse_positive("4294967295"), Ok(u32::MAX));
}
#[test]
fn test_parse_positive_empty() {
assert_eq!(parse_positive(""), Err(ParseError::Empty));
}
#[test]
fn test_parse_positive_too_long() {
let result = parse_positive("12345678901");
assert_eq!(result, Err(ParseError::TooLong { len: 11, max: 10 }));
}
#[test]
fn test_parse_positive_invalid_char() {
assert_eq!(
parse_positive("12x4"),
Err(ParseError::InvalidChar { ch: 'x', pos: 2 })
);
}
#[test]
fn test_parse_positive_zero() {
let result = parse_positive("0");
assert!(matches!(result, Err(ParseError::OutOfRange { .. })));
}
#[test]
fn test_divide_success() {
assert_eq!(divide(10, 2), Ok(5));
assert_eq!(divide(-10, 2), Ok(-5));
}
#[test]
fn test_divide_by_zero() {
assert_eq!(divide(10, 0), Err("cannot divide by zero"));
}
#[test]
fn test_head_success() {
assert_eq!(head(&[1, 2, 3]), Ok(1));
}
#[test]
fn test_head_empty() {
assert_eq!(head::<i32>(&[]), Err("empty slice"));
}
#[test]
fn test_tail_success() {
assert_eq!(tail(&[1, 2, 3]), Ok(3));
}
#[test]
fn test_error_display() {
let err = ParseError::InvalidChar { ch: 'x', pos: 5 };
assert_eq!(format!("{}", err), "invalid char 'x' at 5");
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_positive_valid() {
assert_eq!(parse_positive("123"), Ok(123));
assert_eq!(parse_positive("1"), Ok(1));
assert_eq!(parse_positive("4294967295"), Ok(u32::MAX));
}
#[test]
fn test_parse_positive_empty() {
assert_eq!(parse_positive(""), Err(ParseError::Empty));
}
#[test]
fn test_parse_positive_too_long() {
let result = parse_positive("12345678901");
assert_eq!(result, Err(ParseError::TooLong { len: 11, max: 10 }));
}
#[test]
fn test_parse_positive_invalid_char() {
assert_eq!(
parse_positive("12x4"),
Err(ParseError::InvalidChar { ch: 'x', pos: 2 })
);
}
#[test]
fn test_parse_positive_zero() {
let result = parse_positive("0");
assert!(matches!(result, Err(ParseError::OutOfRange { .. })));
}
#[test]
fn test_divide_success() {
assert_eq!(divide(10, 2), Ok(5));
assert_eq!(divide(-10, 2), Ok(-5));
}
#[test]
fn test_divide_by_zero() {
assert_eq!(divide(10, 0), Err("cannot divide by zero"));
}
#[test]
fn test_head_success() {
assert_eq!(head(&[1, 2, 3]), Ok(1));
}
#[test]
fn test_head_empty() {
assert_eq!(head::<i32>(&[]), Err("empty slice"));
}
#[test]
fn test_tail_success() {
assert_eq!(tail(&[1, 2, 3]), Ok(3));
}
#[test]
fn test_error_display() {
let err = ParseError::InvalidChar { ch: 'x', pos: 5 };
assert_eq!(format!("{}", err), "invalid char 'x' at 5");
}
}
Deep Comparison
OCaml vs Rust: Testing Error Paths
Error Type Definition
Rust
#[derive(Debug, PartialEq, Clone)]
pub enum ParseError {
Empty,
TooLong { len: usize, max: usize },
InvalidChar { ch: char, pos: usize },
}
OCaml
type parse_error =
| Empty
| Too_long of { len: int; max: int }
| Invalid_char of { ch: char; pos: int }
Testing Specific Error Variants
Rust
#[test]
fn test_empty_error() {
assert_eq!(parse_positive(""), Err(ParseError::Empty));
}
#[test]
fn test_invalid_char_error() {
assert_eq!(
parse_positive("12x4"),
Err(ParseError::InvalidChar { ch: 'x', pos: 2 })
);
}
OCaml
let%test "empty error" =
parse_positive "" = Error Empty
let%test "invalid char error" =
parse_positive "12x4" = Error (Invalid_char { ch = 'x'; pos = 2 })
Pattern Matching on Errors
Rust
assert!(matches!(result, Err(ParseError::OutOfRange { .. })));
OCaml
match result with
| Error (Out_of_range _) -> true
| _ -> false
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Error comparison | Structural equality | PartialEq derive |
| Wildcard match | _ | .. for struct fields |
| Error display | Manual to_string | Display trait |
| Test assertion | = | assert_eq! |
Exercises
TooShort { len: usize, min: usize } error variant to ParseError and write tests for the minimum length boundary. Update all Display and test code.parse_positive using a Vec<(&str, Result<u32, ParseError>)> — verify each pair in a loop.DeprecatedFormat error that is non-fatal (produces a warning but returns Ok), and write tests that verify the result contains both the value and the warning.