Equality assertions

This post is part of an ongoing series about tests in Rust:

Equality for other types

In the possible improvements mentioned in the last post, I mentioned splitting NumberAssertions into 2 functionalities:

  • comparing equality of 2 objects (is_equal_to, is_not_equal_to)

  • comparing order of 2 objects (is_greater_than, is_less_than)

The funny thing is: we don’t need a new trait for these, they already exist and are called PartialEq et PartialOrd.

Let’s focus on PartialEq for the rest of this post.

What about chaining?

In the previous post, the API was allowing to chain multiple assertions. For example, this should be possible.

some_vec.has_length(3).contains("foo").contains("bar");

However, we do not have the NumberAssertions trait anymore that can be pass from assertion to the next one. So let’s create a struct that will refer to the tested value and might also contains more useful information that may be printed out for test results. For now, let’s do it simple and just retain information about the tested value.

struct Assertion<'v, V> {
    value: &'v V,
}

Construct an Assertion

Let’s create a quick constructor for this new struct and name it assert_that. This should allow us to write the following.

assert_that(1 + 1).is_equal_to(2);

assert_that as a function

How about a simple function to start.

pub fn assert_that<V>(value: V) -> Assertion<'_, V> {
    Assertion { value }
}

Oh wait, this consumes the value, we probably don’t want that. And it doesn’t work anyway.

error[E0106]: missing lifetime specifier
  --> runit-assertions/src/lib.rs:46:46
   |
46 | pub fn assert_that<V>(value: V) -> Assertion<'_, V> {
   |                                              ^^ expected named lifetime parameter
   |
= help: this function's return type contains a borrowed value with an elided lifetime, but the lifetime cannot be derived from the arguments
help: consider using the `'static` lifetime
   |
46 | pub fn assert_that<V>(value: V) -> Assertion<'static, V> {
   |                                              ~~~~~~~

For more information about this error, try `rustc --explain E0106`.

Let’s fix it…​

pub fn assert_that<V>(value: &V) -> Assertion<'_, V> {
    Assertion { value }
}

Argh, it doesn’t work either.

error[E0308]: mismatched types
 --> src/lib.rs:44:13
  |
5 | assert_that(1 + 1).is_equal_to(2);
  |             ^^^^^
  |             |
  |             expected reference, found integer
  |             help: consider borrowing here: `&(1 + 1)`
  |
  = note: expected reference `&_`
                  found type `{integer}`

Ok, we do not want to take ownership but typing &(1+1) intead of 1+1 would make our API not easy to use. Can we do better? I believe we can thanks to macros.

assert_that as a macro

Let’s try it.

macro_rules! assert_that {
    ($value:expr) => {{
        runit_assertions::Assertion { value: &($value) }
    }};
}

Oh, but that wouldn’t work because value is a private field. We could make the field pub but no reason to expose it only for the constructor. There is actually another simple fix.

impl<'v, V> From<&'v V> for Assertion<'v, V>
where
    V: 'v,
{
    fn from(value: &'v V) -> Self {
        Assertion { value }
    }
}

macro_rules! assert_that {
    ($value:expr) => {{
        runit_assertions::Assertion::from(&($value))
    }};
}

Here we are, this finally works! Back to business after this little constructor’s digression.

Implementing is_equal_to

Let’s implement is_equal_to for Assertion now.

impl<'v, V> Assertion<'v, V> {
    pub fn is_equal_to(&self, expected: V) -> &Self
    where
        V: PartialEq,
    {
        let t: &V = self.value;
        assert!(t.eq(&expected));
        self
    }
}

The problem of that implementation? It doesn’t allow to do the following.

let s: String = some_function_under_test();
assert_that!(s).is_equal_to("foobar");

Indeed, "foobar" is of type &str but s is of type String. But I do remember an API of std that does exactly that: HashMap::get(). For example, the following does compile, even if the keys of the HashMap are String.

let mut map = HashMap::<String, usize>::default();
map.insert(String::from("foo"), 42);
let _ = map.get("foo");

This works thanks to the trait Borrow: with it, you can have a String and borrow it as if it was a &str.

Will Borrow help in our case? Well, yes and no. Ideally, I believe we should be able to support all of the following combinations.

assert_that!("foo").is_equal_to("foo");
assert_that!(String::from("foo")).is_equal_to("foo");
assert_that!("foo").is_equal_to(String::from("foo"));
assert_that!(String::from("foo")).is_equal_to(String::from("foo"));

And I did try multiple different combinations using Borrow, Into, or AsRef…​ but none of them worked. Until I realized something. Will you find the important part in the following fixed implementation?

pub fn is_equal_to<E>(&self, expected: E) -> &Self
where
    V: PartialEq<E>,
{
    let t: &V = self.value;
    assert!(t.eq(&expected));
    self
}

Of course you did spot that a new generic E was introduced. This is mandatory if we want to support a different type from V. However, the important piece is in the bounds of V: it went from V: PartialEq to V: PartialEq<E>. Yes, this single change will do the trick because String does implement PartialEq<String>, PartialEq<&str>, PartialEq<str> and even PartialEq<Cow<'a, str>>.

Conclusion

In the end, we were able to implement something that allows us to write the following assertions and I find it pretty nice so far.

assert_that!(1 + 1).is_equal_to(2);
let foobar = String::from("foobar");
assert_that!(foobar).is_equal_to("foobar");   // String == &str
assert_that!("foobar").is_equal_to("foobar"); // &str == &str
assert_that!("foobar").is_equal_to(&foobar);  // &str == &String
assert_that!("foobar").is_equal_to(foobar);   // &str == String

Looks good, isn’t it? You can browse the result of this implementation in woshilapin/runit.

links

social