How to assert a specific custom error in tests?

I have a custom error type defined with thiserror:

#[derive(Debug, thiserror::Error)]
pub enum ProxyError {
    #[error("invalid host: {0}")]
    InvalidHost(Box<str>),

    #[error("failed to resolve IP address from host: {0}")]
    DnsResolution(#[from] io::Error),
    ...
}

A FromStr implementation returns this error type:

impl std::str::FromStr for Host {
    type Err = ProxyError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let s = s.trim();
        if !hostname_validator::is_valid(s) {
            return Err(ProxyError::InvalidHost(s.into()));
        }

        Ok(Host(s.into()))
    }
}

In tests, I want to assert that parsing an invalid string returns a specific variant of my custom error:

#[test]
fn test_invalid_host_from_str() {
    let res = Host::from_str("108.162.198.200:80");
    assert!(res.is_err());
    assert_eq!(
        res.unwrap_err(),
        ProxyError::InvalidHost("108.162.198.200:80".into())
    );
}

Yes, the compiler complains:

binary operation `==` cannot be applied to type `ProxyError`

This is expected, since some variants wrap types like std::io::Error which do not implement PartialEq, so I cannot simply #[derive(PartialEq)] for the whole enum.

Manually implementing PartialEq for each variant feels verbose and error-prone, especially as the enum grows.

idiomatic-way-of-testing-result-t-error
Stackoverflow - How do you test for a specific Rust error?
One workaround I found is using the matches! macro:

#[test]
fn test_invalid_host_from_str() {
    let res = Host::from_str("108.162.198.200:80");
    assert!(res.is_err());
    assert!(matches!(
        res.unwrap_err(),
        ProxyError::InvalidHost(_)
    ));
}

This works, but it feels slightly imprecise:

  • it does not check the inner value
  • assert_matches! would read better, but it is still nightly-only

Question:
Is there a more idiomatic or expressive way to assert specific error variants in tests in stable Rust?
Are there any well-accepted patterns or crates for this use case?

You can implement helper methods on the enum, that check if the enum is in particular state. For example:

impl ProxyError {
    fn as_invalid_host(&self) -> Option<&str> {
        match *self {
            Self::InvalidHost(ref payload) => Some(payload),
            _ => None,
        }
    }
}

#[test]
fn test_invalid_host_from_str() {
    let res = Host::from_str("108.162.198.200:80");
    assert!(res.is_err());
    assert_eq!(
        res.unwrap_err().as_invalid_host(),
        Some("108.162.198.200:80")
    );
}

You can automate this with strum::EnumTryAs derive macro.

#[derive(Debug, thiserror::Error, strum::EnumTryAs)]
pub enum ProxyError {
    #[error("invalid host: {0}")]
    InvalidHost(Box<str>),

    #[error("failed to resolve IP address from host: {0}")]
    DnsResolution(#[from] io::Error),
}

#[test]
fn test_invalid_host_from_str() {
    let res = Host::from_str("108.162.198.200:80");
    assert!(res.is_err());
    assert_eq!(
        res.unwrap_err().try_as_invalid_host().as_deref(),
        Some("108.162.198.200:80")
    );
}

Although it might be less flexible, because generated try_as_variant methods take enum by value and return owned payload.

1 Like

I've used assert!(matches!(error, pattern)) (available as assert_matches! in nightly) for this:

fn test_invalid_host_from_str() {
    let res = Host::from_str("108.162.198.200:80");
    let err = res.expect_err("invalid host string should not be parsed");
    assert!(matches!(err, ProxyError::InvalidHost(val) if val == "108.162.198.200:80"));
}

This may need some intos or ref/deref changes to compile, but that's the shape of it.

1 Like