Skip to content

Commit

Permalink
Hash, std::iter support and new Error type (#4)
Browse files Browse the repository at this point in the history

Signed-off-by: Filippo Costa <filippo@neysofu.me>
  • Loading branch information
neysofu committed Aug 27, 2023
1 parent 7b6629f commit 96204db
Show file tree
Hide file tree
Showing 14 changed files with 938 additions and 333 deletions.
25 changes: 16 additions & 9 deletions .github/workflows/ci.yml
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [ main ]
branches: [main]
pull_request:
branches: [ main ]
branches: [main]

env:
CARGO_TERM_COLOR: always
Expand All @@ -13,10 +13,17 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run cargo clippy
run: cargo clippy
- name: Run tests
run: cargo test --verbose
- uses: actions/checkout@v2
- run: cargo check --verbose
- run: cargo clippy
- run: cargo test --verbose
- run: cargo test --examples
- run: cargo test --doc
msrv:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: install cargo-binstall
run: curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
- run: cargo binstall --version 0.15.1 --no-confirm cargo-msrv
- run: cargo msrv verify
12 changes: 8 additions & 4 deletions Cargo.toml
@@ -1,15 +1,19 @@
[package]
name = "reltester"
version = "1.0.1"
edition = "2018"
edition = "2021"
repository = "https://github.com/neysofu/reltester"
license = "MIT"
rust-version = "1.56"
description = "Automatically verify the correctness of [Partial]Eq/Ord implementations"
authors = ["Filippo Neysofu Costa <filippo@neysofu.me>"]

[dependencies]
thiserror = "1.0.26"
rand = "0.8"
thiserror = "1"

[dev-dependencies]
quickcheck = "1.0"
quickcheck_macros = "1.0"
quickcheck = "1"
quickcheck_macros = "1"
proptest = "1"
proptest-derive = "0.3"
65 changes: 55 additions & 10 deletions README.md
@@ -1,8 +1,8 @@
# Reltester

[![Crates.io](https://img.shields.io/crates/l/reltester)](https://github.com/neysofu/reltester/blob/main/LICENSE.txt) [![docs.rs](https://img.shields.io/docsrs/reltester)](https://docs.rs/reltester/latest/reltester/) [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/neysofu/reltester/ci.yml)](https://github.com/neysofu/reltester/actions) [![Crates.io](https://img.shields.io/crates/v/reltester)](https://crates.io/crates/reltester) [![min-rustc](https://img.shields.io/badge/min--rustc-1.53-blue)](https://github.com/neysofu/reltester/blob/main/rust-toolchain.toml)
[![Crates.io](https://img.shields.io/crates/l/reltester)](https://github.com/neysofu/reltester/blob/main/LICENSE.txt) [![docs.rs](https://img.shields.io/docsrs/reltester)](https://docs.rs/reltester/latest/reltester/) [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/neysofu/reltester/ci.yml)](https://github.com/neysofu/reltester/actions) [![Crates.io](https://img.shields.io/crates/v/reltester)](https://crates.io/crates/reltester) [![min-rustc](https://img.shields.io/badge/min--rustc-1.56-blue)](https://github.com/neysofu/reltester/blob/main/rust-toolchain.toml)

**Rel**ation **tester** is a small testing utility for automatically checking the correctness of `PartialEq`, `PartialOrd`, `Eq`, and `Ord` implementations. It's most useful when used in conjuction with [`quickcheck`](https://github.com/BurntSushi/quickcheck) or some other property-based testing framework.
**Rel**ation **tester** is a small testing utility for automatically checking the correctness of `[Partial]Eq`, `[Partial]Ord`, `Hash`, and `[DoubleEnded|Fused]Iterator` trait implementations. It's most useful when used in conjuction with [`quickcheck`](https://github.com/BurntSushi/quickcheck) or some other property-based testing framework.


*Go to the [docs](https://docs.rs/reltester/latest/reltester/)!*
Expand All @@ -11,23 +11,32 @@

Imagine a scenario where you have a type `Foo` with a custom implementation of either `PartialEq`, `Eq`, `PartialOrd`, or `Ord`. By "custom" we mean hand-written as opposed to derived. The Rust compiler alone cannot verify the correctness of these implementations and thus it is up to you, the programmer, to uphold certain invariants about the specific [binary relation](https://en.wikipedia.org/wiki/Binary_relation) that you're implementing. For example, if you implement `PartialEq` for `Foo`, you must guarantee that `foo1 == foo2` implies `foo2 == foo1` (*symmetry*).

This is what `reltester` is for. Rather than learning all subtle details of `PartialEq`, `Eq`, `PartialOrd`, and `Ord`, you can write some tests that will automatically check these invariants for you.
Other traits such as `Hash` and `Iterator` mandate several invariants as well – some of which are very intuitive, and [others](https://doc.rust-lang.org/std/hash/trait.Hash.html#prefix-collisions) which are not. It's especially common for less-than-perfect implementations of the `std::iter` family of traits to introduce off-by-one bugs[^1][^2][^3][^4] among others.

The idea is, instead of keeping these invariants in your head whenever you go about manually implementing one of these traits in your codebase, you can add a Reltester check to your test suite and have a higher degree of confidence that your implementation is correct.


## How to use

1. Write some tests that generate random values of the type you wish to test. You can do this by hand or using crates such as [`quickcheck`](https://github.com/BurntSushi/quickcheck) and [`proptest`](https://github.com/proptest-rs/proptest).
2. Based on the traits that your type implements, call the appropriate checker:
1. Write some tests that generate random values of the type you wish to test. You can do this by hand or using crates such as [`quickcheck`](https://github.com/BurntSushi/quickcheck) and [`proptest`](https://github.com/proptest-rs/proptest). Calling the checkers on static, non-randomized values is possible but is less effective in catching bugs.
2. Based on the traits that your type implements, call the appropriate checker(s):

- `reltester::eq` for `Eq`;
- `reltester::ord` for `Ord`;
- `reltester::partial_eq` for `PartialEq`;
- `reltester::partial_ord` for `PartialOrd`.
- `reltester::partial_ord` for `PartialOrd`;
- `reltester::hash` for `Hash`;
- `reltester::iterator` for `Iterator`;
- `reltester::fused_iterator` for `FusedIterator`;
- `reltester::double_ended_iterator` for `DoubleEndedIterator`;

Some of these functions take multiple (two or three) values of the same type. This is because it takes up to three values to test some invariants.

All of these functions take three arguments of the same type: `a`, `b`, and `c`. This is because it takes up to three values to test some invariants.
Please refer to the documentation for more information. The `reltester::invariants` module is available for more granular checks if you can't satisfy the type bounds of the main functions.

Please refer to the documentation for more advanced use cases.
## Examples

# A small example
### `f32` (`PartialEq`, `PartialOrd`)

```rust
use reltester;
Expand All @@ -36,12 +45,48 @@ use quickcheck_macros::quickcheck;
#[quickcheck]
fn test_f32(a: f32, b: f32, c: f32) -> bool {
// Let's check if `f32` implements `PartialEq` and `PartialOrd` correctly
// (spoiler: it does)
// (spoiler: it does).
reltester::partial_eq(&a, &b, &c).is_ok()
&& reltester::partial_ord(&a, &b, &c).is_ok()
}
```

### `u32` (`Hash`)

```rust
use reltester;
use quickcheck_macros::quickcheck;

#[quickcheck]
fn test_u32(a: u32, b: u32) -> bool {
// Unlike `f32`, `u32` implements both `Eq` and `Hash`, which allows us to
// test `Hash` invariants.
reltester::hash(&a, &b).is_ok()
}
```

### `Vec<u32>` (`DoubleEndedIterator`, `FusedIterator`, `Iterator`)

```rust
use reltester;
use quickcheck_macros::quickcheck;

#[quickcheck]
fn test_vec_u32(nums: Vec<u32>) -> bool {
// `Iterator` is implied and checked by both `DoubleEndedIterator` and
// `FusedIterator`.
reltester::double_ended_iterator(nums.iter()).is_ok()
&& reltester::fused_iterator(nums.iter()).is_ok()
}
```

## Legal

Reltester is available under the terms of the MIT license.

## External references and footnotes

[^1]: https://github.com/rust-lang/rust/issues/41964
[^2]: https://github.com/bevyengine/bevy/pull/7469
[^3]: https://github.com/bluejekyll/trust-dns/issues/1638
[^4]: https://github.com/sparsemat/sprs/issues/261
8 changes: 8 additions & 0 deletions examples/f32_partial_eq_is_not_reflexive.rs
@@ -0,0 +1,8 @@
//! Why can't `f32` be `Eq`? Here's a counterexample to show why:

fn main() {}

#[test]
fn f64_partial_eq_is_not_reflexive() {
assert!(reltester::invariants::eq_reflexivity(&f64::NAN).is_err());
}
32 changes: 32 additions & 0 deletions examples/proptest.rs
@@ -0,0 +1,32 @@
fn main() {}

#[cfg(test)]
mod tests {
use proptest::prelude::*;
use std::net::IpAddr;

proptest! {
#[test]
fn correctness_u32(a: u32, b: u32, c: u32) {
reltester::eq(&a, &b, &c).unwrap();
reltester::ord(&a, &b, &c).unwrap();
}

#[test]
fn correctness_f32(a: f32, b: f32, c: f32) {
reltester::partial_eq(&a, &b, &c).unwrap();
reltester::partial_ord(&a, &b, &c).unwrap();
}

#[test]
fn correctness_ip_address(a: IpAddr, b: IpAddr, c: IpAddr) {
reltester::eq(&a, &b, &c).unwrap();
reltester::ord(&a, &b, &c).unwrap();
}

#[test]
fn vec_u32_is_truly_double_ended(x: Vec<u32>) {
reltester::double_ended_iterator(x.iter()).unwrap();
}
}
}
27 changes: 27 additions & 0 deletions examples/quickcheck.rs
@@ -0,0 +1,27 @@
fn main() {}

#[cfg(test)]
mod tests {
use quickcheck_macros::quickcheck;
use std::net::IpAddr;

#[quickcheck]
fn correctness_u32(a: u32, b: u32, c: u32) -> bool {
reltester::eq(&a, &b, &c).is_ok() && reltester::ord(&a, &b, &c).is_ok()
}

#[quickcheck]
fn correctness_f32(a: f32, b: f32, c: f32) -> bool {
reltester::partial_eq(&a, &b, &c).is_ok() && reltester::partial_ord(&a, &b, &c).is_ok()
}

#[quickcheck]
fn correctness_ip_address(a: IpAddr, b: IpAddr, c: IpAddr) -> bool {
reltester::eq(&a, &b, &c).is_ok() && reltester::ord(&a, &b, &c).is_ok()
}

#[quickcheck]
fn vec_u32_is_truly_double_ended(x: Vec<u32>) -> bool {
reltester::double_ended_iterator(x.iter()).is_ok()
}
}
2 changes: 1 addition & 1 deletion rust-toolchain.toml
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.53"
channel = "1.70" # MSRV is not 1.70 but our dev-dependencies require a more recent rustc.
profile = "default"
148 changes: 148 additions & 0 deletions src/error.rs
@@ -0,0 +1,148 @@
//! Crate error types.

use thiserror::Error;

/// Represents a broken invariant of [`PartialEq`].
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum PartialEqError {
/// [`PartialEq::ne`] *MUST* always return the negation of [`PartialEq::eq`].
#[error("PartialEq::ne MUST always return the negation of PartialEq::eq")]
BadNe,
/// If `A: PartialEq<B>` and `B: PartialEq<A>`, then `a == b` *MUST* imply `b == a`.
#[error("a == b MUST imply b == a")]
BrokeSymmetry,
/// If `A: PartialEq<B>` and `B: PartialEq<C>` and `A: PartialEq<C>`, then
/// `a == b && b == c` *MUST* imply `a == c`.
#[error("a == b && b == c MUST imply a == c")]
BrokeTransitivity,
}

/// Represents a broken invariant of [`Eq`].
///
/// Note that [`Eq`] also mandates all invariants of [`PartialEq`].
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum EqError {
/// All values must be equal to themselves.
#[error("a == a MUST be true")]
BrokeReflexivity,
}

/// Represents a broken invariant of [`PartialOrd`].
///
/// Note that [`PartialOrd`] also mandates all invariants of [`PartialEq`].
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum PartialOrdError {
/// [`PartialOrd::partial_cmp`] *MUST* return `Some(Ordering::Equal)` if
/// and only if [`PartialEq::eq`] returns [`true`].
#[error("PartialOrd::partial_cmp MUST return Some(Ordering::Equal) if and only if PartialEq::eq returns true")]
BadPartialCmp,
/// [`PartialOrd::lt`] *MUST* return [`true`]
/// if and only if [`PartialOrd::partial_cmp`] returns `Some(Ordering::Less)`.
#[error("PartialOrd::lt MUST return true if and only if PartialOrd::partial_cmp returns Some(Ordering::Less)")]
BadLt,
/// [`PartialOrd::le`] *MUST* return [`true`] if and only if
/// [`PartialOrd::partial_cmp`] returns `Some(Ordering::Less)` or
/// [`Some(Ordering::Equal)`].
#[error("PartialOrd::le MUST return true if and only if PartialOrd::partial_cmp returns Some(Ordering::Less) or Some(Ordering::Equal)")]
BadLe,
/// [`PartialOrd::gt`] *MUST* return [`true`] if and only if
/// [`PartialOrd::partial_cmp`] returns `Some(Ordering::Greater)`.
#[error("PartialOrd::gt MUST return true if and only if PartialOrd::partial_cmp returns Some(Ordering::Greater)")]
BadGt,
/// [`PartialOrd::ge`] *MUST* return [`true`] if and only if
/// [`PartialOrd::partial_cmp`] returns `Some(Ordering::Greater)` or
/// `Some(Ordering::Equal)`.
#[error("PartialOrd::ge MUST return true if and only if PartialOrd::partial_cmp returns Some(Ordering::Greater) or Some(Ordering::Equal)")]
BadGe,
/// If `a > b`, then `b < a` *MUST* be true.
#[error("If a > b, then b < a MUST be true")]
BrokeDuality,
/// If `a > b` and `b > c`, then `a > c` *MUST* be true. The same must hold true for `<`.
#[error("If a > b and b > c, then a > c MUST be true. The same must hold true for <")]
BrokeTransitivity,
}

/// Represents a broken invariant of [`Ord`].
///
/// Note that [`Ord`] also mandates all invariants of [`PartialOrd`] and [`Eq`].
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum OrdError {
/// [`Ord::cmp`] *MUST* always return `Some(PartialOrd::partial_cmp())`.
#[error("`cmp` and `partial_cmp` are not consistent")]
BadCmp,
/// [`Ord::cmp`] and [`Ord::max`] are not consistent.
#[error("`cmp` and `max` are not consistent")]
BadMax,
/// [`Ord::cmp`] and [`Ord::min`] are not consistent.
#[error("`cmp` and `min` are not consistent")]
BadMin,
/// [`Ord::cmp`] and [`Ord::clamp`] are not consistent.
#[error("`cmp` and `clamp` are not consistent")]
BadClamp,
}

/// Represents a broken invariant of [`Hash`].
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum HashError {
/// Equal values *MUST* have equal hash values.
#[error("Equal values MUST have equal hash values")]
EqualButDifferentHashes,
/// When two values are different (as defined by [`PartialEq::ne`]), neither
/// of the two hash outputs can be a prefix of the other. See
/// <https://doc.rust-lang.org/std/hash/trait.Hash.html#prefix-collisions>
/// for more information.
#[error("When two values are different, one of the two hash outputs CAN NOT be a prefix of the other")]
PrefixCollision,
}

/// Represents a broken invariant of [`Iterator`].
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum IteratorError {
/// [`Iterator::size_hint`] *MUST* always provide correct lower and upper
/// bounds.
#[error("Iterator::size_hint MUST always provide correct lower and upper bounds")]
BadSizeHint,
/// [`Iterator::count`] *MUST* be consistent with the actual number of
/// elements returned by [`Iterator::next`].
#[error(
"Iterator::count MUST be consistent with the actual number of elements returned by .next()"
)]
BadCount,
/// [`Iterator::last`] *MUST* be equal to the last element of the
/// [`Vec`] resulting from [`Iterator::collect`].
#[error(".last() MUST be equal to the last element of the Vec<_> resulting from .collect()")]
BadLast,
/// [`DoubleEndedIterator::next_back`] *MUST* return the same values as
/// [`Iterator::next`], just in reverse order, and it MUST NOT return
/// different values.
#[error("DoubleEndedIterator::next_back() MUST return the same values as .next(), but in reverse order")]
BadNextBack,
/// [`FusedIterator`](core::iter::FusedIterator) *MUST* return [`None`]
/// indefinitely after exhaustion.
#[error("FusedIterator MUST return None indefinitely after exhaustion")]
FusedIteratorReturnedSomeAfterExhaustion,
}

/// The crate error type.
#[derive(Error, Debug, Clone)]
#[non_exhaustive]
pub enum Error {
#[error(transparent)]
PartialEq(#[from] PartialEqError),
#[error(transparent)]
Eq(#[from] EqError),
#[error(transparent)]
PartiaOrd(#[from] PartialOrdError),
#[error(transparent)]
Ord(#[from] OrdError),
#[error(transparent)]
Hash(#[from] HashError),
#[error(transparent)]
Iterator(#[from] IteratorError),
}

0 comments on commit 96204db

Please sign in to comment.