This post is part of an ongoing series about tests in Rust:
-
Fluent assertions for numbers 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 |
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.
assert_is_equal_to()
but that would make the functions much longer, repetitive and less fluent.
std::panic::catch_unwind
but that would mean running assertions in another thread which is not an option for tests.