From 4b7d74ac79d027eae751aaf9d6a2320f2af5c081 Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 13 Sep 2026 09:11:23 +0200 Subject: [PATCH 1/5] feat: implement `is_in` assertion --- src/assertions.rs | 29 +++++++- src/derived_spec/mod.rs | 29 +++++--- src/derived_spec/tests.rs | 22 ++++++ src/equality.rs | 73 +++++++++++++++++-- src/expectations.rs | 14 ++++ src/integer/tests.rs | 69 ++++++++++++++++++ src/string/tests.rs | 132 +++++++++++++++++++++++++++++++++++ tests/assert_custom_types.rs | 49 +++++++++++++ tests/version_numbers.rs | 34 +-------- 9 files changed, 401 insertions(+), 50 deletions(-) create mode 100644 tests/assert_custom_types.rs diff --git a/src/assertions.rs b/src/assertions.rs index 5c0edad..91d05eb 100644 --- a/src/assertions.rs +++ b/src/assertions.rs @@ -257,6 +257,31 @@ pub trait AssertEquivalence { fn is_not_equivalent_to(self, expected: E) -> Self; } +/// Assert a single value is in a set of expected values. +/// +/// # Examples +/// +/// ``` +/// use asserting::prelude::*; +/// +/// assert_that!("two").is_in(["one", "two", "three"]); +/// assert_that!(42).is_in([22, 42, 50, 88]); +/// ``` +pub trait AssertIsIn { + /// Verifies that the actual value is in the set of expected values. + /// + /// # Examples + /// + /// ``` + /// use asserting::prelude::*; + /// + /// assert_that!("two").is_in(["one", "two", "three"]); + /// assert_that!(42).is_in([1, 2, 42, 5, 6]); + /// ``` + #[track_caller] + fn is_in(self, expected_values: I) -> Self; +} + /// Assert approximate equality for floating point numbers. /// /// # Examples @@ -276,7 +301,7 @@ pub trait AssertIsCloseToWithinMargin { /// Verifies that the actual value is approximately equal to the expected /// value. /// - /// For comparison, the epsilon and ULPS values of the given margin are + /// For the comparison the epsilon and ULPS values of the given margin are /// used. /// /// # Examples @@ -299,7 +324,7 @@ pub trait AssertIsCloseToWithinMargin { /// Verifies that the actual value not approximately equals to the expected /// value. /// - /// For comparison, the epsilon and ULPS values of the given margin are + /// For the comparison the epsilon and ULPS values of the given margin are /// used. /// /// # Examples diff --git a/src/derived_spec/mod.rs b/src/derived_spec/mod.rs index 54d6976..35f7e54 100644 --- a/src/derived_spec/mod.rs +++ b/src/derived_spec/mod.rs @@ -5,11 +5,12 @@ use crate::assertions::{ AssertBoolean, AssertChar, AssertDebugString, AssertDecimalNumber, AssertDisplayString, AssertElements, AssertEmptiness, AssertEquality, AssertErrorHasSource, AssertHasCharCount, AssertHasDebugString, AssertHasDisplayString, AssertHasError, AssertHasErrorMessage, - AssertHasLength, AssertHasValue, AssertInRange, AssertInfinity, AssertIteratorContains, - AssertIteratorContainsInAnyOrder, AssertIteratorContainsInOrder, AssertMapContainsKey, - AssertMapContainsValue, AssertNotANumber, AssertNumericIdentity, AssertOption, - AssertOptionValue, AssertOrder, AssertOrderedElements, AssertOrderedElementsRef, AssertResult, - AssertResultValue, AssertSameAs, AssertSignum, AssertStringContainsAnyOf, AssertStringPattern, + AssertHasLength, AssertHasValue, AssertInRange, AssertInfinity, AssertIsIn, + AssertIteratorContains, AssertIteratorContainsInAnyOrder, AssertIteratorContainsInOrder, + AssertMapContainsKey, AssertMapContainsValue, AssertNotANumber, AssertNumericIdentity, + AssertOption, AssertOptionValue, AssertOrder, AssertOrderedElements, AssertOrderedElementsRef, + AssertResult, AssertResultValue, AssertSameAs, AssertSignum, AssertStringContainsAnyOf, + AssertStringPattern, }; use crate::expectations::{ error_has_source, error_has_source_message, has_at_least_char_count, has_at_least_length, @@ -19,9 +20,9 @@ use crate::expectations::{ has_length_in_range, has_length_less_than, has_precision_of, has_scale_of, has_value, is_a_number, is_after, is_alphabetic, is_alphanumeric, is_ascii, is_at_least, is_at_most, is_before, is_between, is_control_char, is_digit, is_empty, is_equal_to, is_err, is_false, - is_finite, is_greater_than, is_in_range, is_infinite, is_integer, is_less_than, is_lower_case, - is_negative, is_none, is_ok, is_one, is_positive, is_same_as, is_some, is_true, is_upper_case, - is_whitespace, is_zero, iterator_contains, iterator_contains_all_in_order, + is_finite, is_greater_than, is_in, is_in_range, is_infinite, is_integer, is_less_than, + is_lower_case, is_negative, is_none, is_ok, is_one, is_positive, is_same_as, is_some, is_true, + is_upper_case, is_whitespace, is_zero, iterator_contains, iterator_contains_all_in_order, iterator_contains_all_of, iterator_contains_any_of, iterator_contains_exactly, iterator_contains_exactly_in_any_order, iterator_contains_only, iterator_contains_only_once, iterator_contains_sequence, iterator_ends_with, iterator_starts_with, @@ -643,6 +644,18 @@ where } } +impl AssertIsIn for DerivedSpec<'_, O, S, D> +where + I: IntoIterator, + S: PartialEq, + D: Represent + Represent, + O: DoFail, +{ + fn is_in(self, expected_values: I) -> Self { + self.expecting(is_in(expected_values)) + } +} + #[cfg(feature = "float-cmp")] mod float_cmp { use super::DerivedSpec; diff --git a/src/derived_spec/tests.rs b/src/derived_spec/tests.rs index 4d855f1..34d2547 100644 --- a/src/derived_spec/tests.rs +++ b/src/derived_spec/tests.rs @@ -267,6 +267,17 @@ fn extracting_ref_string_is_same_as() { .is_same_as("Alexander".to_string()); } +#[test] +fn extracting_ref_string_is_in_array_of_str() { + struct Name(String); + + let name = Name("Alex".to_string()); + + assert_that(name) + .extracting_ref("0", |n| &n.0) + .is_in(["Susan", "Gabi", "Alex"]); +} + #[test] fn extracting_ref_i32_is_zero() { struct Int(i32); @@ -330,6 +341,17 @@ fn extracting_ref_i32_is_in_range() { .is_in_range(1..=9); } +#[test] +fn extracting_ref_i32_is_in_array_of_i32() { + struct Int(i32); + + let number = Int(42); + + assert_that(number) + .extracting_ref("0", |n| &n.0) + .is_in([0, 1, 2, 3, 42, 5, 6, 7, 8]); +} + #[cfg(feature = "float-cmp")] #[test] fn extracting_ref_f32_is_close_to() { diff --git a/src/equality.rs b/src/equality.rs index ae5dbe6..4426e02 100644 --- a/src/equality.rs +++ b/src/equality.rs @@ -1,20 +1,25 @@ //! Implementation of the equality assertions. use crate::assertions::{ - AssertEquality, AssertHasDebugString, AssertHasDisplayString, AssertSameAs, + AssertEquality, AssertHasDebugString, AssertHasDisplayString, AssertIsIn, AssertSameAs, +}; +use crate::colored::{ + mark_all_items_in_collection, mark_diff, mark_diff_str, mark_missing, mark_unexpected, }; -use crate::colored::{mark_diff, mark_diff_str}; use crate::expectations::{ - HasDebugString, HasDisplayString, IsEqualTo, IsSameAs, has_debug_string, has_display_string, - is_equal_to, is_same_as, not, + HasDebugString, HasDisplayString, IsEqualTo, IsIn, IsSameAs, has_debug_string, + has_display_string, is_equal_to, is_in, is_same_as, not, }; use crate::spec::{ DiffFormat, Expectation, Expecting, Expression, FailingStrategy, Invertible, Represent, Represented, Spec, }; -use crate::std::fmt::{Debug, Display}; -use crate::std::format; -use crate::std::string::{String, ToString}; +use crate::std::{ + fmt::{Debug, Display}, + format, + string::{String, ToString}, + vec::Vec, +}; impl AssertEquality for Spec<'_, S, D, R> where @@ -190,3 +195,57 @@ where } impl Invertible for HasDisplayString {} + +impl AssertIsIn for Spec<'_, S, D, R> +where + I: IntoIterator, + S: PartialEq, + D: Represent + Represent, + R: FailingStrategy, +{ + fn is_in(self, expected_values: I) -> Self { + self.expecting(is_in(expected_values)) + } +} + +impl Expectation for IsIn +where + S: PartialEq, + D: Represent + Represent, +{ + fn test(&mut self, subject: &S) -> bool { + self.expected_values + .iter() + .any(|expected| subject == expected) + } + + fn message( + &self, + expression: &Expression<'_>, + actual: &S, + inverted: bool, + representation: &D, + format: &DiffFormat, + ) -> String { + let not = if inverted { "not " } else { "" }; + let expected_values = self + .expected_values + .iter() + .map(|expected| Represented::from((expected, representation))) + .collect::>(); + let marked_actual = mark_unexpected(actual, representation, format); + let marked_expected = mark_all_items_in_collection( + &self.expected_values, + representation, + format, + mark_missing, + ); + format!( + r"expected {expression} to {not}be in {expected_values:?} + but was: {marked_actual} + which is not in: {marked_expected}", + ) + } +} + +impl Invertible for IsIn {} diff --git a/src/expectations.rs b/src/expectations.rs index 424c3a9..da38d56 100644 --- a/src/expectations.rs +++ b/src/expectations.rs @@ -259,6 +259,20 @@ pub struct IsSameAs { pub expected: E, } +pub fn is_in(expected_values: I) -> IsIn +where + I: IntoIterator, +{ + IsIn { + expected_values: Vec::from_iter(expected_values), + } +} + +#[must_use] +pub struct IsIn { + pub expected_values: Vec, +} + /// Creates an [`IsCloseTo`] expectation. /// /// The margin is set to a default value. To define a custom margin, use the diff --git a/src/integer/tests.rs b/src/integer/tests.rs index e4df857..e348664 100644 --- a/src/integer/tests.rs +++ b/src/integer/tests.rs @@ -1,4 +1,6 @@ use crate::prelude::*; +use crate::std::vec; +use hashbrown::HashSet; #[test] fn usize_is_equal_to_usize() { @@ -478,6 +480,73 @@ fn verify_u64_is_one_fails() { ); } +#[test] +fn i32_is_in_array_of_i32() { + let subject: i32 = 32; + + assert_that(subject).is_in([20, 42, 32, 100]); +} + +#[test] +fn i32_is_in_vec_of_i32() { + let subject: i32 = 32; + + assert_that(subject).is_in(vec![32, 42, 20, 100]); +} + +#[test] +fn i32_is_in_hashset_of_i32() { + let subject: i32 = 42; + + let expected_values: HashSet = HashSet::from([20, 42, 32, 100]); + + assert_that(subject).is_in(expected_values); +} + +#[test] +fn borrowed_i32_is_in_borrowed_array_of_i32() { + let subject: &i32 = &32; + + assert_that(subject).is_in(&[32, 42, 20, 100]); +} + +#[test] +fn borrowed_i32_is_in_slice_of_i32() { + let subject: &i32 = &32; + + assert_that(subject).is_in(&[32, 42, 20, 100][..]); +} + +#[test] +fn verify_i32_is_in_empty_array_fails() { + let subject: i32 = 42; + + let failures = verify_that!(subject).is_in([]).display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r"expected subject to be in [] + but was: 42 + which is not in: [] +", + ); +} + +#[test] +fn verify_i32_is_in_array_with_several_i32_fails() { + let subject: i32 = 42; + + let failures = verify_that!(subject) + .is_in([43, 11, 22, 33, 88]) + .display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r"expected subject to be in [43, 11, 22, 33, 88] + but was: 42 + which is not in: [43, 11, 22, 33, 88] +", + ); +} + #[cfg(feature = "colored")] mod colored { use crate::prelude::*; diff --git a/src/string/tests.rs b/src/string/tests.rs index b86af21..8f49429 100644 --- a/src/string/tests.rs +++ b/src/string/tests.rs @@ -1,5 +1,7 @@ use crate::prelude::*; use crate::std::string::{String, ToString}; +use crate::std::vec; +use hashbrown::HashSet; #[test] fn string_is_equal_to_string() { @@ -1373,6 +1375,136 @@ fn verify_string_does_not_end_with_char_fails() { ); } +#[test] +fn string_is_in_an_array_of_strings() { + let subject: String = "tempor nihil in minim".to_string(); + + assert_that(subject).is_in([ + "aliquip nulla eros odio".to_string(), + "tempor nihil in minim".to_string(), + "id culpa et in".to_string(), + ]); +} + +#[test] +fn string_is_in_a_vec_of_strings() { + let subject: String = "tempor nihil in minim".to_string(); + + assert_that(subject).is_in(vec![ + "aliquip nulla eros odio".to_string(), + "tempor nihil in minim".to_string(), + "id culpa et in".to_string(), + ]); +} + +#[test] +fn string_is_in_a_array_of_str() { + let subject: String = "tempor nihil in minim".to_string(); + + assert_that(subject).is_in([ + "aliquip nulla eros odio", + "tempor nihil in minim", + "id culpa et in", + ]); +} + +#[test] +fn str_is_in_an_array_of_strings() { + let subject: &str = "tempor nihil in minim"; + + assert_that(subject).is_in([ + "aliquip nulla eros odio".to_string(), + "tempor nihil in minim".to_string(), + "id culpa et in".to_string(), + ]); +} + +#[test] +fn str_is_in_a_vec_of_strings() { + let subject: &str = "tempor nihil in minim"; + + assert_that(subject).is_in(vec![ + "aliquip nulla eros odio".to_string(), + "tempor nihil in minim".to_string(), + "id culpa et in".to_string(), + ]); +} + +#[test] +fn str_is_in_a_array_of_str() { + let subject: &str = "tempor nihil in minim"; + + assert_that(subject).is_in([ + "aliquip nulla eros odio", + "tempor nihil in minim", + "id culpa et in", + ]); +} + +#[test] +fn str_is_in_borrowed_array_of_string() { + let subject: &str = "tempor nihil in minim"; + + assert_that(subject).is_in(&[ + "aliquip nulla eros odio".to_string(), + "tempor nihil in minim".to_string(), + "id culpa et in".to_string(), + ]); +} + +#[test] +fn str_is_in_slice_of_string() { + let subject: &str = "tempor nihil in minim"; + + assert_that(subject).is_in( + &[ + "aliquip nulla eros odio".to_string(), + "tempor nihil in minim".to_string(), + "id culpa et in".to_string(), + ][..], + ); +} + +#[test] +fn str_is_in_hashset_of_str() { + let subject: &str = "zero"; + + let expected_values: HashSet<&str> = HashSet::from_iter(["one", "two", "three", "zero"]); + + assert_that(subject).is_in(expected_values); +} + +#[test] +fn verify_str_is_in_empty_array_fails() { + let subject: &str = "tempor nihil in minim"; + let expected: [&str; 0] = []; + + let failures = verify_that!(subject).is_in(expected).display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r#"expected subject to be in [] + but was: "tempor nihil in minim" + which is not in: [] +"#, + ); +} + +#[test] +fn verify_str_is_in_array_of_several_str_fails() { + let subject: &str = "zero"; + + let failures = verify_that!(subject) + .is_in(["one", "two", "three", "four"]) + .display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r#"expected subject to be in ["one", "two", "three", "four"] + but was: "zero" + which is not in: ["one", "two", "three", "four"] +"#, + ); +} + #[cfg(feature = "regex")] mod regex { use crate::prelude::*; diff --git a/tests/assert_custom_types.rs b/tests/assert_custom_types.rs new file mode 100644 index 0000000..5a7a548 --- /dev/null +++ b/tests/assert_custom_types.rs @@ -0,0 +1,49 @@ +//! This module tests the assertion methods on various custom types. +#![allow(unused_crate_dependencies)] + +use asserting::prelude::*; + +#[derive(Debug, PartialEq)] +struct IntVal(i32); + +#[test] +fn int_val_is_in_array_of_single_int_val() { + let subject = IntVal(42); + + assert_that!(subject).is_in([IntVal(42)]); +} + +#[test] +fn int_val_is_in_array_of_several_int_val() { + let subject = IntVal(42); + + assert_that!(subject).is_in([IntVal(33), IntVal(42), IntVal(99)]); +} + +#[test] +fn int_val_ref_is_in_borrowed_array_of_int_val() { + let subject = IntVal(42); + + assert_that!(&subject).is_in(&[IntVal(42), IntVal(11), IntVal(28)]); +} + +#[test] +fn int_val_ref_is_in_slice_of_several_int_val() { + let subject = IntVal(42); + + assert_that!(&subject).is_in(&[IntVal(90), IntVal(42)][..]); +} + +#[test] +fn int_val_is_in_empty_array_fails() { + let subject = IntVal(42); + + let failures = verify_that!(subject).is_in([]).display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r"expected subject to be in [] + but was: IntVal(42) + which is not in: [] +", + ); +} diff --git a/tests/version_numbers.rs b/tests/version_numbers.rs index 247942e..36f5a67 100644 --- a/tests/version_numbers.rs +++ b/tests/version_numbers.rs @@ -1,38 +1,6 @@ //! Check the version number of this crate specified in the crate root and the //! README. - -// workaround for false positive 'unused extern crate' warnings until -// Rust issue [#95513](https://github.com/rust-lang/rust/issues/95513) is fixed -mod dummy_extern_uses { - use anyhow as _; - use asserting as _; - #[cfg(feature = "bigdecimal")] - use bigdecimal as _; - use fakeenv as _; - #[cfg(feature = "float-cmp")] - use float_cmp as _; - use hashbrown as _; - #[cfg(feature = "recursive")] - use indexmap as _; - #[cfg(feature = "num-bigint")] - use num_bigint as _; - #[cfg(any(feature = "bigdecimal", feature = "num-bigint"))] - use once_cell as _; - use proptest as _; - #[cfg(feature = "recursive")] - use rapidhash as _; - #[cfg(feature = "regex")] - use regex as _; - #[cfg(feature = "rust-decimal")] - use rust_decimal as _; - #[cfg(feature = "colored")] - use sdiff as _; - use serde as _; - use serde_bytes as _; - #[cfg(feature = "recursive")] - use serde_core as _; - use time as _; -} +#![allow(unused_crate_dependencies)] #[test] fn test_readme_deps() { From ff6d12740812ac1545d5d95beca141ba4fe16c48 Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 20 Sep 2026 12:57:44 +0200 Subject: [PATCH 2/5] feat: implement `is_not_in` assertion and fail on empty expected iterator by default --- src/assertions.rs | 22 ++++++++++++++++++ src/derived_spec/mod.rs | 4 ++++ src/equality.rs | 25 ++++++++++++++++++++- src/expectations.rs | 9 +++++++- src/integer/tests.rs | 31 ++++++++++++++++++++++++-- src/lib.rs | 19 ++++++++++++++++ src/spec/mod.rs | 4 +++- src/string/tests.rs | 49 +++++++++++++++++++++++++++++++++++++---- 8 files changed, 154 insertions(+), 9 deletions(-) diff --git a/src/assertions.rs b/src/assertions.rs index 91d05eb..d2f754e 100644 --- a/src/assertions.rs +++ b/src/assertions.rs @@ -266,10 +266,16 @@ pub trait AssertEquivalence { /// /// assert_that!("two").is_in(["one", "two", "three"]); /// assert_that!(42).is_in([22, 42, 50, 88]); +/// +/// assert_that!("zero").is_not_in(["one", "two", "three"]); +/// assert_that!(42).is_not_in([1, 2, 3, 4, 5, 6]); /// ``` pub trait AssertIsIn { /// Verifies that the actual value is in the set of expected values. /// + /// The set of expected values can be given in any type of collection + /// that implements the `IntoIterator` trait. + /// /// # Examples /// /// ``` @@ -280,6 +286,22 @@ pub trait AssertIsIn { /// ``` #[track_caller] fn is_in(self, expected_values: I) -> Self; + + /// Verifies that the actual value is not in the set of expected values. + /// + /// The set of expected values can be given in any type of collection + /// that implements the `IntoIterator` trait. + /// + /// # Examples + /// + /// ``` + /// use asserting::prelude::*; + /// + /// assert_that!("zero").is_not_in(["one", "two", "three"]); + /// assert_that!(42).is_not_in([1, 2, 3, 4, 5, 6]); + /// ``` + #[track_caller] + fn is_not_in(self, expected_values: I) -> Self; } /// Assert approximate equality for floating point numbers. diff --git a/src/derived_spec/mod.rs b/src/derived_spec/mod.rs index 35f7e54..700cc25 100644 --- a/src/derived_spec/mod.rs +++ b/src/derived_spec/mod.rs @@ -654,6 +654,10 @@ where fn is_in(self, expected_values: I) -> Self { self.expecting(is_in(expected_values)) } + + fn is_not_in(self, expected_values: I) -> Self { + self.expecting(not(is_in(expected_values))) + } } #[cfg(feature = "float-cmp")] diff --git a/src/equality.rs b/src/equality.rs index 4426e02..a8309ed 100644 --- a/src/equality.rs +++ b/src/equality.rs @@ -10,6 +10,7 @@ use crate::expectations::{ HasDebugString, HasDisplayString, IsEqualTo, IsIn, IsSameAs, has_debug_string, has_display_string, is_equal_to, is_in, is_same_as, not, }; +use crate::failure_empty_collection; use crate::spec::{ DiffFormat, Expectation, Expecting, Expression, FailingStrategy, Invertible, Represent, Represented, Spec, @@ -206,6 +207,10 @@ where fn is_in(self, expected_values: I) -> Self { self.expecting(is_in(expected_values)) } + + fn is_not_in(self, expected_values: I) -> Self { + self.expecting(not(is_in(expected_values))) + } } impl Expectation for IsIn @@ -214,6 +219,9 @@ where D: Represent + Represent, { fn test(&mut self, subject: &S) -> bool { + if self.expected_values.is_empty() { + return self.inverted; + } self.expected_values .iter() .any(|expected| subject == expected) @@ -233,6 +241,17 @@ where .iter() .map(|expected| Represented::from((expected, representation))) .collect::>(); + if self.expected_values.is_empty() { + let (method, behavior, alternative) = if inverted { + ("is_not_in", "succeed", "is_not_in_maybe_empty") + } else { + ("is_in", "fail", "is_in_maybe_empty") + }; + return format!( + "expected {expression} to {not}be in {expected_values:?}\n{}", + failure_empty_collection(method, behavior, alternative) + ); + } let marked_actual = mark_unexpected(actual, representation, format); let marked_expected = mark_all_items_in_collection( &self.expected_values, @@ -248,4 +267,8 @@ where } } -impl Invertible for IsIn {} +impl Invertible for IsIn { + fn set_inverted(&mut self) { + self.inverted = !self.inverted; + } +} diff --git a/src/expectations.rs b/src/expectations.rs index da38d56..a55d9bd 100644 --- a/src/expectations.rs +++ b/src/expectations.rs @@ -3,6 +3,7 @@ #![allow(missing_docs)] #![warn(clippy::return_self_not_must_use)] +use crate::spec::Invertible; use crate::std::marker::PhantomData; use crate::std::{string::String, vec::Vec}; use hashbrown::HashSet; @@ -23,7 +24,11 @@ use regex::Regex; /// assert_that!([1, 2, 3]).expecting(not(HasLength { expected_length: 4 })); /// assert_that!("almost").expecting(not(StringContains { expected: "entire" })); /// ``` -pub fn not(expectation: E) -> Not { +pub fn not(mut expectation: E) -> Not +where + E: Invertible, +{ + expectation.set_inverted(); Not(expectation) } @@ -264,12 +269,14 @@ where I: IntoIterator, { IsIn { + inverted: false, expected_values: Vec::from_iter(expected_values), } } #[must_use] pub struct IsIn { + pub inverted: bool, pub expected_values: Vec, } diff --git a/src/integer/tests.rs b/src/integer/tests.rs index e348664..9f4c12a 100644 --- a/src/integer/tests.rs +++ b/src/integer/tests.rs @@ -525,8 +525,9 @@ fn verify_i32_is_in_empty_array_fails() { assert_that!(failures).single_element().is_equal_to( r"expected subject to be in [] - but was: 42 - which is not in: [] + An assertion using `is_in()` was made against an empty collection. + This would always fail, which is likely a bug in your test setup. + If an empty collection should be allowed in your test, use `is_in_maybe_empty()`. ", ); } @@ -547,6 +548,32 @@ fn verify_i32_is_in_array_with_several_i32_fails() { ); } +#[test] +fn i32_is_not_in_array_of_i32() { + let subject: i32 = 41; + + assert_that(subject).is_not_in([20, 42, 32, 100]); +} + +#[test] +fn verify_i32_is_not_in_empty_array_fails() { + let subject: i32 = 41; + + let expected_values: [i32; 0] = []; + + let failures = verify_that(subject) + .is_not_in(expected_values) + .display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r"expected subject to not be in [] + An assertion using `is_not_in()` was made against an empty collection. + This would always succeed, which is likely a bug in your test setup. + If an empty collection should be allowed in your test, use `is_not_in_maybe_empty()`. +", + ); +} + #[cfg(feature = "colored")] mod colored { use crate::prelude::*; diff --git a/src/lib.rs b/src/lib.rs index 95eef23..5b374b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1175,3 +1175,22 @@ mod dummy_extern_uses { use time as _; use version_sync as _; } + +fn failure_empty_collection(method: &str, behavior: &str, alternative: &str) -> String { + format!( + " An assertion using `{method}()` was made against an empty collection.\n \ + This would always {behavior}, which is likely a bug in your test setup.\n \ + If an empty collection should be allowed in your test, use `{alternative}()`." + ) +} + +macro_rules! panic_on_empty_collection { + (method:expr, alternative:expr, behavior:expr) => { + panic!( + "An assertion using `{}()` was made against an empty collection.\n \ + This would always {}, which is likely a bug in your test setup.\n \ + If an empty collection should be allowed in your test, use `{}()`.", + method, behavior, alternative + ); + }; +} diff --git a/src/spec/mod.rs b/src/spec/mod.rs index df44bb3..04f89fa 100644 --- a/src/spec/mod.rs +++ b/src/spec/mod.rs @@ -459,7 +459,9 @@ pub trait Expectation { /// clearly states whether the expectation has been inverted or not. /// /// [`Not`]: crate::expectations::Not -pub trait Invertible {} +pub trait Invertible { + fn set_inverted(&mut self) {} +} /// A textual representation of the expression or subject that is being /// asserted. diff --git a/src/string/tests.rs b/src/string/tests.rs index 8f49429..8fd3ca3 100644 --- a/src/string/tests.rs +++ b/src/string/tests.rs @@ -1482,10 +1482,11 @@ fn verify_str_is_in_empty_array_fails() { let failures = verify_that!(subject).is_in(expected).display_failures(); assert_that!(failures).single_element().is_equal_to( - r#"expected subject to be in [] - but was: "tempor nihil in minim" - which is not in: [] -"#, + r"expected subject to be in [] + An assertion using `is_in()` was made against an empty collection. + This would always fail, which is likely a bug in your test setup. + If an empty collection should be allowed in your test, use `is_in_maybe_empty()`. +", ); } @@ -1505,6 +1506,46 @@ fn verify_str_is_in_array_of_several_str_fails() { ); } +#[test] +fn string_is_not_in_array_of_strings() { + let subject: String = "tempor nihil in minim".to_string(); + + assert_that(subject).is_not_in([ + "aliquip nulla eros odio".to_string(), + "id culpa et in".to_string(), + ]); +} + +#[test] +fn str_is_not_in_array_of_str() { + let subject: &str = "tempor nihil in minim"; + + assert_that(subject).is_not_in([ + "aliquip nulla eros odio", + "voluptate no lobortis qui", + "id culpa et in", + ]); +} + +#[test] +fn verify_string_is_not_in_an_empty_array_fails() { + let subject: String = "tempor nihil in minim".to_string(); + + let expected_values: [String; 0] = []; + + let failures = verify_that(subject) + .is_not_in(expected_values) + .display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r"expected subject to not be in [] + An assertion using `is_not_in()` was made against an empty collection. + This would always succeed, which is likely a bug in your test setup. + If an empty collection should be allowed in your test, use `is_not_in_maybe_empty()`. +", + ); +} + #[cfg(feature = "regex")] mod regex { use crate::prelude::*; From aa8ccc181ede1e7752baa198df9c0011c3219d82 Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 20 Sep 2026 13:16:31 +0200 Subject: [PATCH 3/5] feat: implement `is_in_maybe_empty` and `is_not_in_maybe_empty` assertion which fail on empty arrays in the mathematically correct way --- src/assertions.rs | 6 ++++++ src/derived_spec/mod.rs | 8 ++++++++ src/equality.rs | 12 ++++++++++-- src/expectations.rs | 11 +++++++++++ src/integer/tests.rs | 25 +++++++++++++++++++++++++ src/lib.rs | 29 ++++++++++------------------- src/string/tests.rs | 34 ++++++++++++++++++++++++++++++++-- 7 files changed, 102 insertions(+), 23 deletions(-) diff --git a/src/assertions.rs b/src/assertions.rs index d2f754e..57a519b 100644 --- a/src/assertions.rs +++ b/src/assertions.rs @@ -287,6 +287,9 @@ pub trait AssertIsIn { #[track_caller] fn is_in(self, expected_values: I) -> Self; + #[track_caller] + fn is_in_maybe_empty(self, expected_values: I) -> Self; + /// Verifies that the actual value is not in the set of expected values. /// /// The set of expected values can be given in any type of collection @@ -302,6 +305,9 @@ pub trait AssertIsIn { /// ``` #[track_caller] fn is_not_in(self, expected_values: I) -> Self; + + #[track_caller] + fn is_not_in_maybe_empty(self, expected_values: I) -> Self; } /// Assert approximate equality for floating point numbers. diff --git a/src/derived_spec/mod.rs b/src/derived_spec/mod.rs index 700cc25..e2b8966 100644 --- a/src/derived_spec/mod.rs +++ b/src/derived_spec/mod.rs @@ -655,9 +655,17 @@ where self.expecting(is_in(expected_values)) } + fn is_in_maybe_empty(self, expected_values: I) -> Self { + self.expecting(is_in(expected_values).allow_empty()) + } + fn is_not_in(self, expected_values: I) -> Self { self.expecting(not(is_in(expected_values))) } + + fn is_not_in_maybe_empty(self, expected_values: I) -> Self { + self.expecting(not(is_in(expected_values).allow_empty())) + } } #[cfg(feature = "float-cmp")] diff --git a/src/equality.rs b/src/equality.rs index a8309ed..dc9ca53 100644 --- a/src/equality.rs +++ b/src/equality.rs @@ -208,9 +208,17 @@ where self.expecting(is_in(expected_values)) } + fn is_in_maybe_empty(self, expected_values: I) -> Self { + self.expecting(is_in(expected_values).allow_empty()) + } + fn is_not_in(self, expected_values: I) -> Self { self.expecting(not(is_in(expected_values))) } + + fn is_not_in_maybe_empty(self, expected_values: I) -> Self { + self.expecting(not(is_in(expected_values).allow_empty())) + } } impl Expectation for IsIn @@ -219,7 +227,7 @@ where D: Represent + Represent, { fn test(&mut self, subject: &S) -> bool { - if self.expected_values.is_empty() { + if !self.allow_empty && self.expected_values.is_empty() { return self.inverted; } self.expected_values @@ -241,7 +249,7 @@ where .iter() .map(|expected| Represented::from((expected, representation))) .collect::>(); - if self.expected_values.is_empty() { + if !self.allow_empty && self.expected_values.is_empty() { let (method, behavior, alternative) = if inverted { ("is_not_in", "succeed", "is_not_in_maybe_empty") } else { diff --git a/src/expectations.rs b/src/expectations.rs index a55d9bd..d46d903 100644 --- a/src/expectations.rs +++ b/src/expectations.rs @@ -269,6 +269,7 @@ where I: IntoIterator, { IsIn { + allow_empty: false, inverted: false, expected_values: Vec::from_iter(expected_values), } @@ -276,10 +277,20 @@ where #[must_use] pub struct IsIn { + pub allow_empty: bool, pub inverted: bool, pub expected_values: Vec, } +impl IsIn { + pub fn allow_empty(self) -> Self { + Self { + allow_empty: true, + ..self + } + } +} + /// Creates an [`IsCloseTo`] expectation. /// /// The margin is set to a default value. To define a custom margin, use the diff --git a/src/integer/tests.rs b/src/integer/tests.rs index 9f4c12a..0375fc6 100644 --- a/src/integer/tests.rs +++ b/src/integer/tests.rs @@ -532,6 +532,22 @@ fn verify_i32_is_in_empty_array_fails() { ); } +#[test] +fn verify_i32_is_in_maybe_empty_for_empty_array_fails() { + let subject: i32 = 42; + + let failures = verify_that!(subject) + .is_in_maybe_empty([]) + .display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r"expected subject to be in [] + but was: 42 + which is not in: [] +", + ); +} + #[test] fn verify_i32_is_in_array_with_several_i32_fails() { let subject: i32 = 42; @@ -574,6 +590,15 @@ fn verify_i32_is_not_in_empty_array_fails() { ); } +#[test] +fn is_not_in_maybe_empty_for_empty_array() { + let subject: i32 = 42; + + let expected_values: [i32; 0] = []; + + assert_that(subject).is_not_in_maybe_empty(expected_values); +} + #[cfg(feature = "colored")] mod colored { use crate::prelude::*; diff --git a/src/lib.rs b/src/lib.rs index 5b374b7..e5ce2e1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1158,6 +1158,16 @@ mod slice; mod string; mod vec; +use crate::std::{format, string::String}; + +fn failure_empty_collection(method: &str, behavior: &str, alternative: &str) -> String { + format!( + " An assertion using `{method}()` was made against an empty collection.\n \ + This would always {behavior}, which is likely a bug in your test setup.\n \ + If an empty collection should be allowed in your test, use `{alternative}()`." + ) +} + // test code snippets in the README.md #[cfg(doctest)] #[doc = include_str!("../README.md")] @@ -1175,22 +1185,3 @@ mod dummy_extern_uses { use time as _; use version_sync as _; } - -fn failure_empty_collection(method: &str, behavior: &str, alternative: &str) -> String { - format!( - " An assertion using `{method}()` was made against an empty collection.\n \ - This would always {behavior}, which is likely a bug in your test setup.\n \ - If an empty collection should be allowed in your test, use `{alternative}()`." - ) -} - -macro_rules! panic_on_empty_collection { - (method:expr, alternative:expr, behavior:expr) => { - panic!( - "An assertion using `{}()` was made against an empty collection.\n \ - This would always {}, which is likely a bug in your test setup.\n \ - If an empty collection should be allowed in your test, use `{}()`.", - method, behavior, alternative - ); - }; -} diff --git a/src/string/tests.rs b/src/string/tests.rs index 8fd3ca3..fd01711 100644 --- a/src/string/tests.rs +++ b/src/string/tests.rs @@ -1477,9 +1477,12 @@ fn str_is_in_hashset_of_str() { #[test] fn verify_str_is_in_empty_array_fails() { let subject: &str = "tempor nihil in minim"; - let expected: [&str; 0] = []; - let failures = verify_that!(subject).is_in(expected).display_failures(); + let expected_values: [&str; 0] = []; + + let failures = verify_that!(subject) + .is_in(expected_values) + .display_failures(); assert_that!(failures).single_element().is_equal_to( r"expected subject to be in [] @@ -1490,6 +1493,24 @@ fn verify_str_is_in_empty_array_fails() { ); } +#[test] +fn verify_string_is_in_maybe_empty_for_empty_array_fails() { + let subject: &str = "tempor nihil in minim"; + + let expected_values: [&str; 0] = []; + + let failures = verify_that!(subject) + .is_in_maybe_empty(expected_values) + .display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r#"expected subject to be in [] + but was: "tempor nihil in minim" + which is not in: [] +"#, + ); +} + #[test] fn verify_str_is_in_array_of_several_str_fails() { let subject: &str = "zero"; @@ -1546,6 +1567,15 @@ fn verify_string_is_not_in_an_empty_array_fails() { ); } +#[test] +fn string_is_not_in_maybe_empty_for_empty_array() { + let subject: String = "tempor nihil in minim".to_string(); + + let expected_values: [String; 0] = []; + + assert_that(subject).is_not_in_maybe_empty(expected_values); +} + #[cfg(feature = "regex")] mod regex { use crate::prelude::*; From b35a0a0bf1ea18743d7f97cd18c732bd3a913378 Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 20 Sep 2026 13:28:40 +0200 Subject: [PATCH 4/5] feat(is-in-assertion): fix compile and test errors --- tests/assert_custom_types.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/assert_custom_types.rs b/tests/assert_custom_types.rs index 5a7a548..bdadea6 100644 --- a/tests/assert_custom_types.rs +++ b/tests/assert_custom_types.rs @@ -35,15 +35,39 @@ fn int_val_ref_is_in_slice_of_several_int_val() { } #[test] -fn int_val_is_in_empty_array_fails() { +fn int_val_ref_is_in_maybe_empty_slice_of_several_int_val() { let subject = IntVal(42); - let failures = verify_that!(subject).is_in([]).display_failures(); + assert_that!(&subject).is_in_maybe_empty(&[IntVal(90), IntVal(42)][..]); +} + +#[test] +fn verify_int_val_ref_is_in_maybe_empty_for_empty_slice_fails() { + let subject = IntVal(42); + + let failures = verify_that!(&subject) + .is_in_maybe_empty(&[][..]) + .display_failures(); assert_that!(failures).single_element().is_equal_to( - r"expected subject to be in [] + r"expected &subject to be in [] but was: IntVal(42) which is not in: [] ", ); } + +#[test] +fn verify_int_val_is_in_empty_array_fails() { + let subject = IntVal(42); + + let failures = verify_that!(subject).is_in([]).display_failures(); + + assert_that!(failures).single_element().is_equal_to( + r"expected subject to be in [] + An assertion using `is_in()` was made against an empty collection. + This would always fail, which is likely a bug in your test setup. + If an empty collection should be allowed in your test, use `is_in_maybe_empty()`. +", + ); +} From f80eb1e215d33d1a0e3e8188241b0550a3773c5b Mon Sep 17 00:00:00 2001 From: haraldmaida Date: Sun, 20 Sep 2026 18:52:42 +0200 Subject: [PATCH 5/5] feat(is-in-assertion): write documation for the `is_in_maybe_empty` and `is_not_in_maybe_empty` assertions --- src/assertions.rs | 173 +++++++++++++++++++++++++++++++++++++++++++++- src/spec/mod.rs | 9 +++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/assertions.rs b/src/assertions.rs index 57a519b..8455574 100644 --- a/src/assertions.rs +++ b/src/assertions.rs @@ -257,7 +257,7 @@ pub trait AssertEquivalence { fn is_not_equivalent_to(self, expected: E) -> Self; } -/// Assert a single value is in a set of expected values. +/// Assert whether a single value is in a set of expected values. /// /// # Examples /// @@ -270,12 +270,80 @@ pub trait AssertEquivalence { /// assert_that!("zero").is_not_in(["one", "two", "three"]); /// assert_that!(42).is_not_in([1, 2, 3, 4, 5, 6]); /// ``` +/// +/// The assertions [`is_in`] and [`is_not_in`] fail if the expected set of values +/// is empty. This restrictive behavior is by design. In the vast majority of +/// tests asserting against an empty set results in a "dead test". Asserting +/// that a value is in an empty set always fails, while asserting that a value +/// is not in an empty set always succeeds. So the assertion result does not +/// depend on the actual value. This might not be what we want to assert. +/// +/// ``` +/// # #[cfg(feature = "panic")] +/// # { +/// use asserting::prelude::*; +/// +/// assert_that_code!(|| { +/// assert_that!(42).is_in([]); // always fails +/// }).panics_with_message( +/// r"expected 42 to be in [] +/// An assertion using `is_in()` was made against an empty collection. +/// This would always fail, which is likely a bug in your test setup. +/// If an empty collection should be allowed in your test, use `is_in_maybe_empty()`. +/// "); +/// +/// assert_that_code!(|| { +/// assert_that!(42).is_not_in([]); // always fails +/// }).panics_with_message( +/// r"expected 42 to not be in [] +/// An assertion using `is_not_in()` was made against an empty collection. +/// This would always succeed, which is likely a bug in your test setup. +/// If an empty collection should be allowed in your test, use `is_not_in_maybe_empty()`. +/// "); +/// # } +/// ``` +/// +/// If you need the mathematical correct behavior for some specific test, use +/// the alternative methods [`is_in_maybe_empty`] and [`is_not_in_maybe_empty`]. +/// +/// ``` +/// use asserting::prelude::*; +/// +/// # #[cfg(feature = "panic")] +/// # { +/// assert_that_code!(|| { +/// assert_that!(42) +/// .with_diff_format(DIFF_FORMAT_NO_HIGHLIGHT) +/// .is_in_maybe_empty([]); // always fails +/// }).panics_with_message( +/// r"expected 42 to be in [] +/// but was: 42 +/// which is not in: [] +/// "); +/// # } +/// +/// assert_that!(42).is_not_in_maybe_empty([]); // always succeeds +/// ``` +/// +/// [`is_in`]: Self::is_in +/// [`is_in_maybe_empty`]: Self::is_in_maybe_empty +/// [`is_not_in`]: Self::is_not_in +/// [`is_not_in_maybe_empty`]: Self::is_not_in_maybe_empty pub trait AssertIsIn { /// Verifies that the actual value is in the set of expected values. /// /// The set of expected values can be given in any type of collection /// that implements the `IntoIterator` trait. /// + /// If the expected set of values is empty, this assertion always fails + /// regardless of the actual value. This is a so-called "dead test" as it + /// does not assert anything about the actual value. This might be a bug + /// or design flaw of the test case. The failure message of the failing + /// assertion gives a hint that this test might not be what you want. + /// + /// If you explicitly want to allow the empty set for some special test, + /// use the alternative [`is_in_maybe_empty`] method. + /// /// # Examples /// /// ``` @@ -283,10 +351,62 @@ pub trait AssertIsIn { /// /// assert_that!("two").is_in(["one", "two", "three"]); /// assert_that!(42).is_in([1, 2, 42, 5, 6]); + /// + /// # #[cfg(feature = "panic")] + /// # { + /// assert_that_code!(|| { + /// assert_that!(42).is_in([]); // always fails + /// }).panics_with_message( + /// r"expected 42 to be in [] + /// An assertion using `is_in()` was made against an empty collection. + /// This would always fail, which is likely a bug in your test setup. + /// If an empty collection should be allowed in your test, use `is_in_maybe_empty()`. + /// "); + /// # } /// ``` + /// + /// [`is_in_maybe_empty`]: Self::is_in_maybe_empty #[track_caller] fn is_in(self, expected_values: I) -> Self; + /// Verifies that the actual value is in the set of expected values + /// with the empty set allowed. + /// + /// The set of expected values can be given in any type of collection + /// that implements the `IntoIterator` trait. + /// + /// This assertion is basically the same as the [`is_in`] method, but it + /// explicitly allows the empty set for the expected values. Asserting + /// against an empty set is most likely a "dead test", as the test always + /// fails independent of the actual value. So when using this method, make + /// sure your test is actually testing something. + /// + /// If there is no good reason for using this method, stick with the more + /// restrictive [`is_in`] method. + /// + /// # Examples + /// + /// ``` + /// use asserting::prelude::*; + /// + /// assert_that!("two").is_in_maybe_empty(["one", "two", "three"]); + /// assert_that!(42).is_in_maybe_empty([1, 2, 42, 5, 6]); + /// + /// # #[cfg(feature = "panic")] + /// # { + /// assert_that_code!(|| { + /// assert_that!(42) + /// .with_diff_format(DIFF_FORMAT_NO_HIGHLIGHT) + /// .is_in_maybe_empty([]); // always fails + /// }).panics_with_message( + /// r"expected 42 to be in [] + /// but was: 42 + /// which is not in: [] + /// "); + /// # } + /// ``` + /// + /// [`is_in`]: Self::is_in #[track_caller] fn is_in_maybe_empty(self, expected_values: I) -> Self; @@ -295,6 +415,16 @@ pub trait AssertIsIn { /// The set of expected values can be given in any type of collection /// that implements the `IntoIterator` trait. /// + /// This assertion fails if the expected set of values is empty. This + /// behavior is different from the mathematically correct way. This is by + /// design as in the mathematical sense any element is not in the empty set. + /// But in the vast majority of tests this would be a "dead test" as the + /// assertion always succeeds independent of the actual value. In fact, the + /// test does not assert anything about the actual value. + /// + /// If for some test you need the mathematically correct interpretation, use + /// the alternative [`is_not_in_maybe_empty`] method. + /// /// # Examples /// /// ``` @@ -302,10 +432,51 @@ pub trait AssertIsIn { /// /// assert_that!("zero").is_not_in(["one", "two", "three"]); /// assert_that!(42).is_not_in([1, 2, 3, 4, 5, 6]); + /// + /// # #[cfg(feature = "panic")] + /// # { + /// assert_that_code!(|| { + /// assert_that!(42).is_not_in([]); // always fails + /// }).panics_with_message( + /// r"expected 42 to not be in [] + /// An assertion using `is_not_in()` was made against an empty collection. + /// This would always succeed, which is likely a bug in your test setup. + /// If an empty collection should be allowed in your test, use `is_not_in_maybe_empty()`. + /// "); + /// # } /// ``` + /// + /// [`is_not_in_maybe_empty`]: Self::is_not_in_maybe_empty #[track_caller] fn is_not_in(self, expected_values: I) -> Self; + /// Verifies that the actual value is not in the set of expected values + /// with the empty set allowed. + /// + /// The set of expected values can be given in any type of collection + /// that implements the `IntoIterator` trait. + /// + /// This assertion is basically the same as the [`is_not_in`] method, but it + /// explicitly allows the empty set for the expected values. Asserting + /// against an empty set is most likely a "dead test", as the test always + /// succeeds independent of the actual value. So when using this method, + /// make sure your test is actually testing something. + /// + /// If there is no good reason for using this method, stick with the more + /// restrictive [`is_not_in`] method. + /// + /// # Examples + /// + /// ``` + /// use asserting::prelude::*; + /// + /// assert_that!("zero").is_not_in_maybe_empty(["one", "two", "three"]); + /// assert_that!(42).is_not_in_maybe_empty([1, 2, 3, 4, 5, 6]); + /// + /// assert_that!(42).is_not_in_maybe_empty([]); // always succeeds + /// ``` + /// + /// [`is_not_in`]: Self::is_not_in #[track_caller] fn is_not_in_maybe_empty(self, expected_values: I) -> Self; } diff --git a/src/spec/mod.rs b/src/spec/mod.rs index 04f89fa..7b07127 100644 --- a/src/spec/mod.rs +++ b/src/spec/mod.rs @@ -460,6 +460,15 @@ pub trait Expectation { /// /// [`Not`]: crate::expectations::Not pub trait Invertible { + /// This method is called, when an [`Expectation`] is inverted by the + /// [`not`] factory method. + /// + /// It provides a default implementation which does noting. + /// + /// Expectations may implement it to store the "inverted" flag in their + /// struct for being used in the [`Expectation::test`] method. + /// + /// [`not`]: crate::expectations::not fn set_inverted(&mut self) {} }