Fluent assertions for numbers in Rust

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

Introduction

Following up on my previous blog post about the testing ecosystem in Rust, I decided to try something on my own. I need to make a disclaimer right away: even if I already evaluated spectral as a nice candidate to start from, I decided to start from scratch because I want to discover all the tiny details by myself.

In this blog post, I’ll talk about my very first attempt to write some assertions for numbers.

API driven design

Let’s start by talking about the API we want. How about we start with this trait.

trait NumberAssertions {
	fn is_equal_to(&self, number: &Self);
	fn is_greater_than(&self, number: &Self);
	fn is_less_than(&self, number: &Self);
}

I know there is a bunch more we could write like is_not_equal_to, is_greater_or_equal_than, etc. but they can all be derived from the three above functions.

Note

The functions don’t return a value and you might be wondering why? We’re writing a framework for tests. If the self is not greater than” some other value, we can panic!(), no need to return a bool.

With the above trait, and if we implement it on standard numbers (i32, u8, etc.), we should be able to write tests like this.

#[test]
fn it_works() {
	2u8.is_greater_than(1);
}

In the future code samples, I will simply ignore the redundant #[test] and function wrapping.

Chaining assertions

I see one first problem with the previous example, this API never mentions the concept of an assertion. For now, let’s do something really dumb.[1]

fn assert_that<T>(provided: T) -> T {
	provided
}

assert_that(2u8).is_greater_than(1);

Great. But how would I test that a number is between 2 values?

assert_that(2u8).is_greater_than(1);
assert_that(2u8).is_less_than(3);

How about we provide the ability to chain calls.

assert_that(2u8).is_greater_than(1).is_less_than(3);

To do this, we can change the trait to something like.

trait NumberAssertions {
	fn is_equal_to(&self, number: &Self) -> &Self;
	fn is_greater_than(&self, number: &Self) -> &Self;
	fn is_less_than(&self, number: &Self) -> &Self;
}

Owned versus referenced value

There is another problem. Let’s imagine we do 2 different computations that should result in the same expected value.

let expected = 78648u64;
assert_that(compute_1()).is_equal_to(expected);
assert_that(compute_2()).is_equal_to(expected);

In this case, we’re lucky because u64 is Copy, otherwise, we would have a compile error.[2] But if expected was not Copy, we probably want to be able to take the parameter as a reference or as a value.

let expected = 78648;
assert_that(compute_1()).is_equal_to(&expected);
assert_that(compute_2()).is_equal_to(expected);

The std::borrow::Borrow trait can help here.

trait NumberAssertions {
	fn is_equal_to<B>(&self, number: B) -> &Self where B: Borrow<Self>;
	fn is_greater_than<B>(&self, number: B) -> &Self where B: Borrow<Self>;
	fn is_less_than<B>(&self, number: B) -> &Self where B: Borrow<Self>;
}

Constraints

Finally, we forgot some constraints on Self to compare numbers. Let’s add PartialEq and PartialOrd as needed.

trait NumberAssertions {
	fn is_equal_to<B>(&self, number: B) -> &Self
	where
		Self: PartialEq,
		B: Borrow<Self>;
	fn is_greater_than<B>(&self, number: B) -> &Self
	where
		Self: PartialOrd,
		B: Borrow<Self>;
	fn is_less_than<B>(&self, number: B) -> &Self
	where
		Self: PartialOrd,
		B: Borrow<Self>;
}

Implementation

We can now implement a simple version for every number types.

impl<T> NumberAssertions for T {
	fn is_equal_to<B>(&self, number: B) -> &Self
	where
		B: Borrow<Self>,
		Self: PartialEq,
	{
		assert!(self.eq(number.borrow()));
		self
	}
	fn is_greater_than<B>(&self, number: B) -> &Self
	where
		B: Borrow<Self>,
		Self: PartialOrd,
	{
		assert!(self.partial_cmp(number.borrow()) == Some(std::cmp::Ordering::Greater));
		self
	}
	fn is_less_than<B>(&self, number: B) -> &Self
	where
		B: Borrow<Self>,
		Self: PartialOrd,
	{
		assert!(self.partial_cmp(number.borrow()) == Some(std::cmp::Ordering::Less));
		self
	}
}

We can also add some provided methods in the trait like is_between(n1, n2).

trait NumberAssertions {
	// other functions not displayed here
	fn is_between<B1, B2>(&self, min: B1, max: B2) -> &Self
	where
		Self: PartialEq,
		B1: Borrow<Self>,
		B2: Borrow<Self>,
	{
		self.is_greater_than(min).is_less_than(max)
	}
}

Testing the implementation

Let’s write a bunch of tests to see if everything is working as expected.

#[test]
fn greater_than() {
	assert_that(2u8).is_equal_to(2);
	assert_that(2i32).is_greater_than(1);
	assert_that(2.0f64).is_greater_than(1.0);
	assert_that(1u64).is_less_than(2);
	assert_that(1.0f32).is_less_than(2.0);
	assert_that(2i32).is_between(1, 3);
	assert_that(2u16).is_greater_than(0).is_less_than(100);
}

#[test]
#[should_panic]
fn greater_than_panics() {
	assert_that(2i32).is_greater_than(4);
}

Conclusion

That’s a great start…​ but I already see room for improvement.

Is not equal” and friends

First of all, I did not provide an implementation for is_not_equal_to()? Did you think I forgot? Well, in fact, I cannot implement is_not_equal_to() because is_equal_to() panics in case of failure. So basically, we would need to not panic when is_equal_to() panics which is not possible.[3]

Do I need to go back to functions that return bool? But if I do, what about chaining? Another solution would be to let the implementors implement the functions but that means more work for each implementor. Ideally, someone should be able to implement NumberAssertions easily for a new type. Not yet sure how to overcome this problem.

More functions

In this blog post, I focused on a very simple API about equality and comparison. I’m sure we could imagine more useful assertions functions like is_multiple_of(), is_zero(), is_not_a_number(), is_prime(), etc. I’ll probably add more functions later, once I’m satisfied the code architecture.

Equality for other types

is_equal_to() is part of the trait NumberAssertions. But equality is not exclusive to numbers, we could also compare String, and any kind of object that implements PartialEq. I might need to split into two parts the NumberAssertions trait.

Naming test

When using assert!(), we can name the test by doing assert!(1 == 1, "Test dumb equality"). Our current solution forbids that. When looking into spectral, we can see that assert_that(provided) doesn’t return provided but a wrapper type named Spec<T>. This Spec can contain more information about the current assertion (the name of the test or the location).

Feedbacks

Implementation can be found at https://gitlab.com/woshilapin/runit. I’d be happy to hear feedbacks, suggestions, or even better, contributors if you’re interested in this project.


1. We could also rename all our functions with assert_is_equal_to() but that would make the functions much longer, repetitive and less fluent.
2. First call to is_equal_to() takes ownership of expected, second call will fail the compilation
3. I might be able to use std::panic::catch_unwind but that would mean running assertions in another thread which is not an option for tests.

links

social