1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381
//! Isotest enables a very specific Rust unit testing pattern.
//!
//! Say you have some complex data type which is used in a lot of places.
//! You'd like to write functions that operate on that complex data, but these
//! functions only need to know about some small subset of that data type.
//! It could be convenient to write those functions generically over a trait which
//! contains methods that work with that subset of data, rather than taking the
//! full data type.
//!
//! There a few key benefits to doing this. Using a trait hides irrelevant details,
//! keeping the concern of the function limited to only what it needs to know.
//! This is a good application of the
//! [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege).
//!
//! Using a trait also makes writing tests easier. If we used the full data type,
//! we would need to create test fixtures with a lot of extraneous arbitrary data which
//! won't even affect the function under test. By using a trait, we can write a much
//! simpler test-only data structure which implements the same trait, and use that instead
//! of the complex "real" data type in our tests. This keeps tests simpler and avoids the
//! need to generate lots of arbitrary throw-away data.
//!
//! Additionally, when debugging a failing test, it's a lot easier to get debug output on
//! just the data we care about, and not have all of the noise of the real data structure
//! included in the debug output.
//!
//! The only concern with this approach is that we're not testing the "real" data, and if there
//! is any problem with our test data type or its implementation of the common trait, then
//! we may not be testing what we think we are testing. This is where `isotest` comes in.
//!
//! `isotest` helps ensure that our implementation of the common trait is correct by
//! letting the user define functions to convert to and from the test data type, and
//! then providing a macro with a simple API to run tests for both types.
//! The user can write tests in terms of the simpler test data, but then that test gets run
//! for both test and real data, to ensure that assumptions implicit in the trait implementation
//! are not faulty. Thus, you get the benefit of simplifying your test writing while
//! still testing your logic with real data.
#![warn(missing_docs)]
use std::marker::PhantomData;
pub use paste;
/// Which of the two contexts each isotest body will run under.
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum IsotestContext {
/// The "test" context, using a minimal test struct
Test,
/// The "real" context, using the actual struct used in the rest of the code
Real,
}
/// Trait that declares the relationship between a "test" and "real struct",
/// namely how to go back and forth between the two.
pub trait Iso: Sized {
/// The real data which corresponds to the type this trait is defined for
type Real: Clone + From<Self>;
/// Return the test version of this data, mapping if necessary.
///
/// The test data must be a subset of the real data, so that this
/// transformation is never lossy.
fn test(x: &Self::Real) -> Self;
/// Return the real version of this data, mapping if necessary.
///
/// In general, the real data is a superset of the test data,
/// so some arbitrary data will need to be supplied to fill in
/// the rest. In fact, it is best if truly random arbitrary
/// data is used, as this will act as a fuzz test of your
/// trait implementation.
fn real(&self) -> Self::Real;
}
/// Helper to define a two-way [`Iso`] relationship between test data
/// and real data.
///
/// The macro mainly just helps you implement the trait succinctly,
/// but also throws in a free `From<Real> for Test` impl for you.
/// It also allows you to specify example cases on both the Test and
/// Real sides for which invariant tests will be generated.
///
/// It is important that the Iso implementation satisfies two invariants:
/// 1. for any Test data `t`, `t == Iso::test(Iso::real(t))`
/// 2. for any Real data `r`, `Iso::test(r) == Iso::test(Iso::real(Iso::test(r)))`
///
/// The `test_cases` and `real_cases` can be used to automatically generate tests
/// which check that these invariants are upheld for whatever cases are specified.
/// The tests are generated with the prefix `iso_impl_invariants_test__`.
///
/// You typically do not need to work with this trait directly. However,
/// It must be implemented for the two types that you use in
/// an [`isotest!`] invocation.
///
/// ```rust
/// use isotest::Iso;
///
/// #[derive(Clone, Debug, PartialEq)]
/// struct A(u8);
/// #[derive(Clone, Debug, PartialEq)]
/// struct B(u8, u8);
///
/// isotest::iso! {
/// A => |a| B(a.0, 42),
/// B => |b| A(b.0),
/// test_cases: [A(0), A(42)],
/// real_cases: [B(0, 0), B(42, 42)],
/// }
///
/// assert_eq!(A(1).real(), B(1, 42));
/// assert_eq!(A::test(&B(1, 2)), A(1));
/// ```
#[macro_export]
macro_rules! iso {
(
$a:ty => $forward:expr,
$b:ty => $backward:expr
$(, test_cases: [$($tc:expr),* $(,)?])?
$(, real_cases: [$($rc:expr),* $(,)?])?
$(,)?
) => {
impl $crate::Iso for $a {
type Real = $b;
fn test(x: &$b) -> $a {
let f: Box<dyn Fn($b) -> $a> = Box::new($backward);
f(x.clone())
}
fn real(&self) -> $b {
let f: Box<dyn Fn($a) -> $b> = Box::new($forward);
f(self.clone())
}
}
impl From<$a> for $b {
fn from(a: $a) -> $b {
use $crate::Iso;
a.real()
}
}
$($crate::paste::paste! {
#[test]
fn [< iso_impl_invariants_test__ $a:snake:lower __ $b:snake:lower >]() {
$(
let test1: $a = $tc;
let test2 = $crate::roundtrip_test(&test1);
assert_eq!(test1, test2, "Iso test_case invariant test failed: {:?} != {:?}", test1, test2);
)*
}
})?
$($crate::paste::paste! {
#[test]
fn [< iso_impl_invariants_real__ $a:snake:lower __ $b:snake:lower >]() {
$(
let real: $b = $rc;
let (test1, test2) = $crate::roundtrip_real::<$a, $b>(&real);
assert_eq!(test1, test2, "Iso real_case invariant test failed: {:?} != {:?}, real data = {:?}", test1, test2, real);
)*
}
})?
};
}
/// Roundtrip from test -> real -> test
pub fn roundtrip_test<A>(test: &A) -> A
where
A: Iso + PartialEq + std::fmt::Debug,
{
A::test(&test.real())
}
/// Roundtrip from real -> test -> real -> test, returning the two test items
pub fn roundtrip_real<A, B>(real: &B) -> (A, A)
where
A: Iso<Real = B> + PartialEq + std::fmt::Debug,
B: Clone + PartialEq + std::fmt::Debug,
{
let test = A::test(&real);
let test2 = A::test(&test.real());
(test, test2)
}
/// Test the invariants of your Iso implementation.
/// This test must pass for any Test value you use.
pub fn assert_iso_invariants_test<A>(test: A)
where
A: Iso + PartialEq + std::fmt::Debug,
{
let test2 = A::test(&test.real());
assert_eq!(
test, test2,
"test -> real -> test roundtrip should leave original value unchanged"
);
}
/// Test the invariants of your Iso implementation.
/// This test must pass for any Real value you use.
pub fn assert_iso_invariants_real<A, B>(real: B)
where
A: Iso<Real = B> + PartialEq + std::fmt::Debug,
B: Clone + PartialEq + std::fmt::Debug,
{
{
let test = A::test(&real);
let test2 = A::test(&test.real());
assert_eq!(
test, test2,
"real -> test -> real -> test roundtrip should be idempotent"
);
}
}
/// Run the same closure for both the Test and Real versions of some data.
///
/// The macro takes a list of [`Iso`] implementors, followed by closure which
/// receives a small API which can handle each `Iso` type. See
/// [`IsoTestApi`] and [`IsoRealApi`] for descriptions of the methods.
/// ```rust
/// #[derive(Clone, Debug, PartialEq)]
/// struct A(u8);
/// #[derive(Clone, Debug, PartialEq)]
/// struct B(u8, u8);
///
/// trait X {
/// fn num(&self) -> u8;
/// }
///
/// impl X for A {
/// fn num(&self) -> u8 {
/// self.0
/// }
/// }
///
/// impl X for B {
/// fn num(&self) -> u8 {
/// self.0
/// }
/// }
///
/// isotest::iso! {
/// A => |a| B(a.0, 42),
/// B => |b| A(b.0),
/// };
///
/// isotest::isotest!(A => |iso| {
/// let mut a = iso.create(A(1));
/// assert_eq!(a.num(), 1);
/// iso.mutate(&mut a, |a| {
/// a.0 = 2;
/// });
/// assert_eq!(a.num(), 2);
/// });
/// ```
///
#[macro_export]
macro_rules! isotest {
( $($iso:ty),+ => $runner:expr) => {
use $crate::Iso;
{
// This is the test using the "test" struct
let run: Box<dyn Fn($( $crate::IsoTestApi<$iso>, )+ )> = Box::new($runner);
run($( $crate::IsoTestApi::<$iso>::new(), )+);
}
{
// This is the test using the "real" struct
let run: Box<dyn Fn($( $crate::IsoRealApi<$iso>, )+ )> = Box::new($runner);
run($( $crate::IsoRealApi::<$iso>::new(), )+);
}
};
}
/// A version of [`isotest!`] which returns `Future<Output = ()>` instead of `()`
/// and supports `async` syntax.
#[cfg(feature = "async")]
#[macro_export]
macro_rules! isotest_async {
($($iso:ty),+ => $runner:expr) => {
use $crate::Iso;
{
// This is the test using the "test" struct
let run: Box<dyn Fn($( $crate::IsoTestApi<$iso>, )+ )
-> std::pin::Pin<Box<dyn futures::Future<Output = ()>>>,
> = Box::new($runner);
run($( $crate::IsoTestApi::<$iso>::new(), )+).await;
}
{
// This is the test using the "real" struct
let run: Box<dyn Fn($( $crate::IsoRealApi<$iso>, )+ )
-> std::pin::Pin<Box<dyn futures::Future<Output = ()>>>,
> = Box::new($runner);
run($( $crate::IsoRealApi::<$iso>::new(), )+).await;
}
};
}
/// The API passed into an isotest in the Test context.
///
/// The `isotest!` macro is a bit sneaky, passing in APIs with different
/// function signatures for each context, so that the test can be written
/// the same lexically, but actually expand to two different tests
/// working with two different types.
pub struct IsoTestApi<A: Iso>(PhantomData<A>);
/// The API passed into an isotest in the Real context.
///
/// The `isotest!` macro is a bit sneaky, passing in APIs with different
/// function signatures for each context, so that the test can be written
/// the same lexically, but actually expand to two different tests
/// working with two different types.
pub struct IsoRealApi<A: Iso>(PhantomData<A>);
impl<A: Iso> IsoTestApi<A> {
/// Constructor
pub fn new() -> Self {
Self(PhantomData)
}
/// Create test data from test data (identity function)
pub fn create(&self, a: A) -> A {
a
}
/// Update test data with a function over test data (simple map)
pub fn update(&self, a: A, f: impl Fn(A) -> A) -> A {
f(a)
}
/// Mutate test data with a function over test data (simple mutable map)
pub fn mutate(&self, a: &mut A, f: impl Fn(&mut A)) {
f(a)
}
/// Return the context we're in
pub fn context(&self) -> IsotestContext {
IsotestContext::Test
}
}
impl<A: Iso> IsoRealApi<A> {
/// Constructor
pub fn new() -> Self {
Self(PhantomData)
}
/// Create real data from test data
pub fn create(&self, a: A) -> A::Real {
a.real()
}
/// Update real data with a function over test data
pub fn update(&self, x: A::Real, f: impl Fn(A) -> A) -> A::Real {
f(A::test(&x)).real()
}
/// Mutate real data with a function over test data (simple mutable map)
pub fn mutate(&self, x: &mut A::Real, f: impl Fn(&mut A)) {
let mut t = A::test(x);
f(&mut t);
let _ = std::mem::replace(x, t.real());
}
/// Return the context we're in
pub fn context(&self) -> IsotestContext {
IsotestContext::Real
}
}
/// The argument to an isotest `update` function
pub type Modify<A> = Box<dyn Fn(A) -> A>;
/// Creation function for the test context
pub type CreateTest<A> = Box<dyn Fn(A) -> A>;
/// Update function for the test context
pub type UpdateTest<A> = Box<dyn Fn(A, Modify<A>) -> A>;
/// Create function for the real context
pub type CreateReal<A, B> = Box<dyn Fn(A) -> B>;
/// Update function for the real context
pub type UpdateReal<A, B> = Box<dyn Fn(B, Modify<A>) -> B>;