Skip to content

Commit

Permalink
Merge #5: feat: new article: Testing error variants in Rust using `ma…
Browse files Browse the repository at this point in the history
…tches!` macro

0afcabd feat: new article: Testing error variants in Rust using assert_matches! and alternatives (Jose Celano)

Pull request description:

  New article: Testing error variants in Rust using `matches!` macro

ACKs for top commit:
  josecelano:
    ACK 0afcabd

Tree-SHA512: f592b2eb0f32a92ea42a481f9e1fbc3aebbf9469c5fca704fb8e8fa1f153d984d58983d3f57c3d1ee50d7dea83a0ffd05f52b779577673e19798d071a809acd5
  • Loading branch information
josecelano committed Oct 31, 2024
2 parents 4377d8b + 0afcabd commit 7fe28be
Show file tree
Hide file tree
Showing 10 changed files with 367 additions and 4 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"mpsc",
"Prysor",
"reqwest",
"rustversion",
"Shivanandhan",
"struct",
"structs",
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ jobs:
name: Check Unused Dependencies
run: cargo machete


unit:
name: Units
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[package]
edition = "2021"
name = "testing-in-rust"
version = "0.1.0"
edition = "2021"

[dependencies]
colored = "2"
reqwest = { version = "0.11" }
tokio = { version = "1", features = ["full"] }
warp = "0.3"
warp = "0.3"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A collection of articles about testing in Rust.
- [Custom mocks in Rust](./docs/custom-mocks-in-rust.md).
- [Testing APIs in Rust](./docs/testing-apis-in-rust.md).
- [Static vs Dynamic mocks in Rust](./docs/static-vs-dynamic-mocks-in-rust.md).
- [Testing error variants in Rust using `assert_matches!` and alternatives](./docs/testing-error-variants-in-rust.md)

## Contributions

Expand Down
185 changes: 185 additions & 0 deletions docs/testing-error-variants-in-rust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Testing error variants in Rust using `assert_matches!` and alternatives

In Rust, handling errors in enums allows for detailed error information, but testing these errors can become tricky when you don’t care about every detail. This article shows various ways to test for error types, including exact matches, variant checks without specific values, and using the `matches!` and `assert_matches!` macros for simpler assertions.

## Introduction

Suppose you have an error enum, `CustomError`, with various error types, some of which carry additional information:

```rust
#[derive(Debug, PartialEq)]
enum CustomError {
NotFound,
Unauthorized,
NetworkError,
ParseError(String), // This includes additional data we might want to ignore
Unknown,
}
```

When testing functions that return errors of this type, you may want to check for a specific error variant. However, you may not always know or care about every value inside these variants. Let’s walk through three examples of how to handle these cases.

## Example 05: Asserting the exact error value

When you know the precise error, you can assert the entire error value directly.

```rust
#[cfg(test)]
fn function_returning_an_error(error: CustomError) -> Result<(), CustomError> {
Err(error)
}

#[cfg(test)]
mod tests {
use crate::example05::{function_returning_an_error, CustomError};

#[test]
fn test_function_returning_not_found() {
let result = function_returning_an_error(CustomError::NotFound);

assert!(result.is_err());

assert_eq!(result.unwrap_err(), CustomError::NotFound);
}

#[test]
fn test_function_returning_parse_error() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));

assert!(result.is_err());

assert_eq!(
result.unwrap_err(),
CustomError::ParseError("error".to_string())
);
}
}
```

Here, we check both that the result is an error and that it matches exactly `CustomError::NotFound` or `CustomError::ParseError("error")`. This approach is straightforward, but it requires knowing the exact value of the error, which isn’t always practical.

## Example 06: Asserting only the error variant without the value

When you only care about the error variant (e.g., `ParseError`) but not the exact value, you can use a custom assertion to check for the variant without worrying about its contents.

```rust
#[cfg(test)]
fn function_returning_an_error(error: CustomError) -> Result<(), CustomError> {
Err(error)
}

#[cfg(test)]
mod tests {
use crate::example06::{function_returning_an_error, CustomError};

#[test]
fn test_function_returning_not_found() {
let result = function_returning_an_error(CustomError::NotFound);

assert!(result.is_err());

assert_eq!(result.unwrap_err(), CustomError::NotFound);
}

#[test]
fn test_function_returning_parse_error() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));

assert!(result.is_err());

if let CustomError::ParseError(_error) = result.unwrap_err() {
} else {
panic!("Unexpected error variant");
}
}

#[allow(clippy::match_like_matches_macro)]
fn is_parse_error(error: CustomError) -> bool {
match error {
CustomError::ParseError(_error) => true,
_ => false,
}
}

#[test]
fn test_function_returning_parse_error_with_custom_assert() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));

assert!(result.is_err());

assert!(is_parse_error(result.unwrap_err()));
}
}
```

In this example, the `is_parse_error` helper function allows us to check only for the variant without worrying about the content. This is helpful when the inner data isn’t essential for the test.

## Example 07: Using `matches!` or `assert_matches!` for cleaner variant assertions

Using the `matches!` or `assert_matches!` macro simplifies the process even further, allowing us to check just the variant directly without writing additional code.

```rust
#[cfg(test)]
fn function_returning_an_error(error: CustomError) -> Result<(), CustomError> {
Err(error)
}

#[cfg(test)]
mod tests {
use std::assert_matches::assert_matches;

use crate::example07::{function_returning_an_error, CustomError};

#[test]
fn test_function_returning_not_found() {
let result = function_returning_an_error(CustomError::NotFound);

assert!(result.is_err());

assert_eq!(result.unwrap_err(), CustomError::NotFound);
}

#[test]
fn test_function_returning_parse_error_with_matches_macro() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));

assert!(matches!(
result.unwrap_err(),
CustomError::ParseError(_error)
));
}

/*
#[test]
fn test_function_returning_parse_error_with_assert_matches_macro() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));
assert_matches!(result.unwrap_err(), CustomError::ParseError(_error));
}
#[test]
fn test_function_returning_parse_error_with_assert_matches_macro_v2() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));
// You can also use the `..` syntax
assert_matches!(result.unwrap_err(), CustomError::ParseError(..));
}
*/
}
```

Here, the `assert_matches!` macro lets you assert that the result contains a specific variant, while `_` or `..` can be used to ignore the inner data. This approach provides cleaner, more readable assertions without additional helper functions.

The `..` syntax is commonly referred to as "pattern wildcards" or "pattern ignoring". When used in the context of structs, tuples, and enum variants, it’s often called "elision" or "rest pattern". The exact term varies depending on the structure being matched, but "rest pattern" is frequently used in Rust documentation and discussions when referring to `..` as a way to ignore data.

## Choosing the right approach

1. **Exact error matching (Example 05)**: Use this approach if you need to assert the exact value of an error, including its inner data.
2. **Variant-only matching with helper function (Example 06)**: Useful if you care only about the variant but want to avoid external macros.
3. **`assert_matches!` Macro (Example 07)**: Ideal for concise, readable tests focusing only on the variant type.

## Conclusion

Testing errors in Rust can be done in multiple ways, each with its trade-offs. For more complex enums with nested data, `matches!` and `assert_matches!` makes tests cleaner and easier to read by allowing you to focus on the variant without needing to specify every detail. These techniques ensure robust tests that maintain clarity, even as your error types evolve.
2 changes: 1 addition & 1 deletion src/example03/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//! user_repository: Box<dyn UserRepository>,
//! }
//! ```
//!
//!
//! The repository type used by `App` is defined at runtime.
pub mod app;
pub mod user;
Expand Down
46 changes: 46 additions & 0 deletions src/example05/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Example 05:
//!
//! This example shows how to assert that a function returns an error when it
//! can be done with a simple assert because we know the exact error value.
#[derive(Debug, PartialEq)]
#[allow(dead_code)]
#[cfg(test)]
enum CustomError {
NotFound,
Unauthorized,
NetworkError,
ParseError(String),
Unknown,
}

#[cfg(test)]
fn function_returning_an_error(error: CustomError) -> Result<(), CustomError> {
Err(error)
}

#[cfg(test)]
mod tests {
use crate::example05::{function_returning_an_error, CustomError};

#[test]
fn test_function_returning_not_found() {
let result = function_returning_an_error(CustomError::NotFound);

assert!(result.is_err());

assert_eq!(result.unwrap_err(), CustomError::NotFound);
}

#[test]
fn test_function_returning_parse_error() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));

assert!(result.is_err());

assert_eq!(
result.unwrap_err(),
CustomError::ParseError("error".to_string())
);
}
}
63 changes: 63 additions & 0 deletions src/example06/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//! Example 06:
//!
//! This example shows how to assert that a function returns an error variant in
//! an enum when you don't know or don't care about the exact error value.
#[derive(Debug, PartialEq)]
#[allow(dead_code)]
#[cfg(test)]
enum CustomError {
NotFound,
Unauthorized,
NetworkError,
ParseError(String),
Unknown,
}

#[cfg(test)]
fn function_returning_an_error(error: CustomError) -> Result<(), CustomError> {
Err(error)
}

#[cfg(test)]
mod tests {
use crate::example06::{function_returning_an_error, CustomError};

#[test]
fn test_function_returning_not_found() {
let result = function_returning_an_error(CustomError::NotFound);

assert!(result.is_err());

assert_eq!(result.unwrap_err(), CustomError::NotFound);
}

#[test]
fn test_function_returning_parse_error() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));

assert!(result.is_err());

if let CustomError::ParseError(_error) = result.unwrap_err() {
} else {
panic!("Unexpected error variant");
}
}

#[allow(clippy::match_like_matches_macro)]
fn is_parse_error(error: CustomError) -> bool {
match error {
CustomError::ParseError(_error) => true,
_ => false,
}
}

#[test]
fn test_function_returning_parse_error_with_custom_assert() {
let result = function_returning_an_error(CustomError::ParseError("error".to_string()));

assert!(result.is_err());

assert!(is_parse_error(result.unwrap_err()));
}
}
Loading

0 comments on commit 7fe28be

Please sign in to comment.