use jsonschema::Validator; use pretty_assertions::assert_eq; use schemars::{ generate::{Contract, SchemaSettings}, JsonSchema, Schema, }; use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; use snapbox::{data::DataFormat, IntoJson}; use std::{ any::type_name, borrow::Borrow, cell::OnceCell, f64, marker::PhantomData, path::Path, sync::OnceLock, }; pub struct TestHelper { settings: SchemaSettings, name: String, phantom: PhantomData, de_schema: Schema, ser_schema: Schema, de_schema_validator: OnceCell, ser_schema_validator: OnceCell, validator: fn(&T) -> bool, } impl TestHelper { /// Should be used via the `test!(SomeType)` macro pub fn new(name: String, settings: SchemaSettings) -> Self where T: JsonSchema, { let de_schema = schema_for::(&settings, Contract::Deserialize); let ser_schema = schema_for::(&settings, Contract::Serialize); Self { settings, name, phantom: PhantomData, de_schema, ser_schema, de_schema_validator: OnceCell::new(), ser_schema_validator: OnceCell::new(), validator: |_| true, } } /// Should be used via the `test!(value: SomeType)` macro pub fn new_for_value(name: String, settings: SchemaSettings, value: T) -> Self where T: Serialize, { let de_schema = schema_for_value(&settings, Contract::Deserialize, &value); let ser_schema = schema_for_value(&settings, Contract::Serialize, &value); Self { settings, name, phantom: PhantomData, de_schema, ser_schema, de_schema_validator: OnceCell::new(), ser_schema_validator: OnceCell::new(), validator: |_| true, } } pub fn with_validator(&mut self, validator: fn(&T) -> bool) -> &mut Self { self.validator = validator; self } /// Checks the generated schema against the saved schema in the snapshots directory, for manual verification of changes. /// /// Run tests with the SNAPSHOTS env var set to "overwrite" to overwrite any changed snapshots. pub fn assert_snapshot(&self) -> &Self { let common_path = format!("tests/integration/snapshots/{}.json", self.name); let de_path = format!("tests/integration/snapshots/{}.de.json", self.name); let ser_path = format!("tests/integration/snapshots/{}.ser.json", self.name); if self.de_schema == self.ser_schema { snapbox::assert_data_eq!( (&self.de_schema).into_json(), snapbox::Data::read_from(Path::new(&common_path), Some(DataFormat::Json)).raw() ); _ = std::fs::remove_file(de_path); _ = std::fs::remove_file(ser_path); } else { snapbox::assert_data_eq!( (&self.de_schema).into_json(), snapbox::Data::read_from(Path::new(&de_path), Some(DataFormat::Json)).raw() ); snapbox::assert_data_eq!( (&self.ser_schema).into_json(), snapbox::Data::read_from(Path::new(&ser_path), Some(DataFormat::Json)).raw() ); _ = std::fs::remove_file(common_path); } self } /// Checks that the schema generated for this type is identical to that of another type. pub fn assert_identical(&self) -> &Self where T: JsonSchema, { snapbox::assert_data_eq!( (&self.de_schema).into_json(), schema_for::(&self.settings, Contract::Deserialize) .into_json() .raw() ); snapbox::assert_data_eq!( (&self.ser_schema).into_json(), schema_for::(&self.settings, Contract::Serialize) .into_json() .raw() ); let t = type_name::(); let t2 = type_name::(); assert_eq!( T::schema_name(), T2::schema_name(), "`{t}` and `{t2}` have identical schemas, so should have the same schema_name" ); assert_eq!( T::schema_id(), T2::schema_id(), "`{t}` and `{t2}` have identical schemas, so should have the same schema_id" ); assert_eq!( T::inline_schema(), T2::inline_schema(), "`{t}` and `{t2}` have identical schemas, so should have the same inline_schema" ); self } pub fn custom(&self, assertion: impl Fn(&Schema, Contract)) -> &Self { assertion(&self.de_schema, Contract::Deserialize); assertion(&self.ser_schema, Contract::Serialize); self } fn de_schema_validate(&self, instance: &Value) -> bool { self.de_schema_validator .get_or_init(|| build_validator(&self.de_schema)) .is_valid(instance) } fn ser_schema_validate(&self, instance: &Value) -> bool { self.ser_schema_validator .get_or_init(|| build_validator(&self.ser_schema)) .is_valid(instance) } } fn build_validator(schema: &Schema) -> Validator { jsonschema::options() .should_validate_formats(true) .build(schema.as_value()) .expect("valid schema") } impl TestHelper { /// Checks that the "serialize" schema allows the given sample values when serialized to JSON. /// If `T implements `DeserializeOwned`, prefer using `assert_allows_ser_roundtrip()` pub fn assert_allows_ser_only(&self, samples: impl IntoIterator) -> &Self { for sample in samples { let json = serde_json::to_value(&sample).unwrap(); assert!( (self.validator)(&sample), "invalid test case - attempt to serialize value failing validation: {json}" ); assert!( self.ser_schema_validate(&json), "serialize schema should allow serialized value: {json}" ); } self } } impl TestHelper { /// Checks that the "serialize" schema allows the given sample values when serialized to JSON /// and, if the value can then be deserialized, that the "deserialize" schema also allows it. pub fn assert_allows_ser_roundtrip(&self, samples: impl IntoIterator) -> &Self { for sample in samples { let json = serde_json::to_value(&sample).unwrap(); assert!( (self.validator)(&sample), "invalid test case - attempt to serialize value failing validation: {json}" ); assert!( self.ser_schema_validate(&json), "serialize schema should allow serialized value: {json}" ); if T::deserialize(&json).is_ok() { assert!( (self.validator)(&sample), "invalid test case - roundtripped value fails validation: {json}" ); assert!( self.de_schema_validate(&json), "deserialize schema should allow value accepted by deserialization: {json}" ); } else { assert!( !self.de_schema_validate(&json), "deserialize schema should reject undeserializable value: {json}" ); } } self } /// Checks that the "deserialize" schema allow the given sample values, and the "serialize" /// schema allows the value obtained from deserializing then re-serializing the sample values /// (only for values that can successfully be serialized). /// /// This is intended for types that have different serialize/deserialize schemas, or when you /// want to test specific values that are valid for deserialization but not for serialization. pub fn assert_allows_de_roundtrip( &self, samples: impl IntoIterator>, ) -> &Self { for sample in samples { let sample = sample.borrow(); let Ok(deserialized) = T::deserialize(sample) else { panic!( "expected deserialize to succeed for {}: {sample}", type_name::() ) }; assert!( (self.validator)(&deserialized), "invalid test case - deserialized value fails validation: {sample}" ); assert!( self.de_schema_validate(sample), "deserialize schema should allow value accepted by deserialization: {sample}" ); if let Ok(serialized) = serde_json::to_value(&deserialized) { assert!( self.ser_schema_validate(&serialized), "serialize schema should allow serialized value: {serialized}" ); } } self } /// Checks that the "deserialize" schema allows only the given sample values that successfully /// deserialize and pass validation. /// /// This is intended to be given a range of values (see `arbitrary_values`), allowing limited /// fuzzing. pub fn assert_matches_de_roundtrip( &self, samples: impl IntoIterator>, ) -> &Self { for value in samples { let value = value.borrow(); match T::deserialize(value) { Ok(deserialized) if (self.validator)(&deserialized) => { assert!( self.de_schema_validate(value), "deserialize schema should allow value accepted by deserialization: {value}" ); if let Ok(serialized) = serde_json::to_value(&deserialized) { assert!( self.ser_schema_validate(&serialized), "serialize schema should allow serialized value: {serialized}" ); } } _ => { assert!( !self.de_schema_validate(value), "deserialize schema should reject invalid value: {value}" ); // This assertion isn't necessarily valid in the general case but it would be // odd (though not necessarily wrong) for it to fail. If this does ever fail // a case that should be legitimate, then this assert can be removed/weakened. assert!( !self.ser_schema_validate(value), "serialize schema should reject invalid value: {value}" ); } } } self } /// Checks that the "deserialize" schema does not allow any of the given sample values. /// /// While `assert_matches_de_roundtrip()` would also work in this case, `assert_rejects_de()` /// has the advantage that it also verifies that the test case itself is actually covering the /// failure case as intended. pub fn assert_rejects_de(&self, values: impl IntoIterator>) -> &Self { for value in values { let value = value.borrow(); assert!( T::deserialize(value).is_err(), "invalid test case - expected deserialize to fail for {}: {value}", type_name::() ); assert!( !self.de_schema_validate(value), "deserialize schema should reject invalid value: {value}" ); } self } /// Checks that neither "serialize" nor "deserialize" schemas allow any of the given sample /// values when serialized to JSON due to the values failing validation. pub fn assert_rejects_invalid(&self, samples: impl IntoIterator) -> &Self { for sample in samples { let json = serde_json::to_value(&sample).unwrap(); assert!( !(self.validator)(&sample), "invalid test case - serialized value passes validation: {json}" ); assert!( !self.de_schema_validate(&json), "deserialize schema should reject invalid value: {json}" ); assert!( !self.ser_schema_validate(&json), "serialize schema should reject invalid value: {json}" ); } self } /// Checks that both the "serialize" and "deserialize" schema allow the type's default value /// when serialized to JSON. pub fn assert_allows_ser_roundtrip_default(&self) -> &Self where T: Default, { self.assert_allows_ser_roundtrip([T::default()]) } } fn schema_for(base_settings: &SchemaSettings, contract: Contract) -> Schema { base_settings .clone() .with(|s| s.contract = contract) .into_generator() .into_root_schema_for::() } fn schema_for_value( base_settings: &SchemaSettings, contract: Contract, value: impl Serialize, ) -> Schema { base_settings .clone() .with(|s| s.contract = contract) .into_generator() .into_root_schema_for_value(&value) .unwrap() } /// Returns an iterator over an selection of arbitrary JSON values. /// /// This is intended to be used as `test!(...).assert_matches_de_roundtrip(arbitrary_values())` pub fn arbitrary_values() -> impl Iterator { fn primitives() -> impl Iterator { [ Value::Null, false.into(), true.into(), 0.into(), 255.into(), (-1).into(), u64::MAX.into(), f64::consts::PI.into(), "".into(), "0".into(), "3E8".into(), "\tPâté costs “£1”\0".into(), Value::Array(Default::default()), Value::Object(Default::default()), ] .into_iter() } // TODO once MSRV has reached 1.80, replace this with LazyLock static VALUES: OnceLock> = OnceLock::new(); VALUES .get_or_init(|| { Vec::from_iter( primitives() .chain(primitives().map(|p| json!([p]))) .chain(primitives().map(|p| json!({"key": p}))), ) }) .iter() } /// Returns an iterator over an selection of arbitrary JSON values, except for value that match /// the given filter predicate. /// /// This is to handle known cases of schemas not matching the actual deserialize behaviour. pub fn arbitrary_values_except( filter: impl Fn(&Value) -> bool, _reason: &str, ) -> impl Iterator { arbitrary_values().filter(move |v| !filter(v)) }