From d7c33e2d55815c09bc13cd5beccc9394cdb70915 Mon Sep 17 00:00:00 2001 From: Dennis Doomen Date: Sun, 9 Aug 2026 17:14:43 +0200 Subject: [PATCH] Add JSON round-trip assertions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # docs/_pages/releases.md --- .../ObjectAssertionsExtensions.cs | 195 ++++++++++++++- ...ObjectAssertionSpecs.BeJsonSerializable.cs | 223 ++++++++++++++++++ docs/_pages/basicassertions.md | 5 +- docs/_pages/releases.md | 1 + docs/_pages/serializationignoredmembers.md | 26 +- 5 files changed, 439 insertions(+), 11 deletions(-) create mode 100644 Tests/FluentAssertions.Specs/Primitives/ObjectAssertionSpecs.BeJsonSerializable.cs diff --git a/Src/FluentAssertions/ObjectAssertionsExtensions.cs b/Src/FluentAssertions/ObjectAssertionsExtensions.cs index a4bbe546a9..f87853f49e 100644 --- a/Src/FluentAssertions/ObjectAssertionsExtensions.cs +++ b/Src/FluentAssertions/ObjectAssertionsExtensions.cs @@ -1,9 +1,13 @@ -using System; +using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; - using System.IO; using System.Runtime.Serialization; +#if NET6_0_OR_GREATER +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +#endif using System.Xml.Serialization; using FluentAssertions.Common; using FluentAssertions.Equivalency; @@ -101,11 +105,193 @@ public static AndConstraint BeDataContractSerializable(this return new AndConstraint(assertions); } +#if NET6_0_OR_GREATER + /// + /// Asserts that an object can be serialized and deserialized using and that it still retains + /// the values of all serializable members. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public static AndConstraint BeJsonSerializable(this ObjectAssertions assertions, + [StringSyntax("CompositeFormat")] string because = "", params object[] becauseArgs) + => BeJsonSerializable(assertions, options => options, serializerOptions: null, because, becauseArgs); + + /// + /// Asserts that an object can be serialized and deserialized using and that it still retains + /// the values of all serializable members. + /// + /// + /// A reference to the configuration object that can be used + /// to influence the way the object graphs are compared. You can also provide an alternative instance of the + /// class. The global defaults are determined by the + /// class. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is . + public static AndConstraint BeJsonSerializable(this ObjectAssertions assertions, + Func, EquivalencyOptions> options, + [StringSyntax("CompositeFormat")] string because = "", params object[] becauseArgs) + => BeJsonSerializable(assertions, options, serializerOptions: null, because, becauseArgs); + + /// + /// Asserts that an object can be serialized and deserialized using and that it still retains + /// the values of all serializable members. + /// + /// + /// The used for both serialization and deserialization. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public static AndConstraint BeJsonSerializable(this ObjectAssertions assertions, + JsonSerializerOptions serializerOptions, + [StringSyntax("CompositeFormat")] string because = "", params object[] becauseArgs) + => BeJsonSerializable(assertions, options => options, serializerOptions, because, becauseArgs); + + /// + /// Asserts that an object can be serialized and deserialized using and that it still retains + /// the values of all serializable members. + /// + /// + /// A reference to the configuration object that can be used + /// to influence the way the object graphs are compared. You can also provide an alternative instance of the + /// class. The global defaults are determined by the + /// class. + /// + /// + /// The used for both serialization and deserialization. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is . + public static AndConstraint BeJsonSerializable(this ObjectAssertions assertions, + Func, EquivalencyOptions> options, + JsonSerializerOptions serializerOptions, + [StringSyntax("CompositeFormat")] string because = "", params object[] becauseArgs) + => BeJsonSerializable(assertions, options, serializerOptions, because, becauseArgs); + + /// + /// Asserts that an object can be serialized and deserialized using and that it still retains + /// the values of all serializable members. + /// + /// + /// A reference to the configuration object that can be used + /// to influence the way the object graphs are compared. You can also provide an alternative instance of the + /// class. The global defaults are determined by the + /// class. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is . + public static AndConstraint BeJsonSerializable(this ObjectAssertions assertions, + Func, EquivalencyOptions> options, + [StringSyntax("CompositeFormat")] string because = "", params object[] becauseArgs) + => BeJsonSerializable(assertions, options, serializerOptions: null, because, becauseArgs); + + /// + /// Asserts that an object can be serialized and deserialized using and that it still retains + /// the values of all serializable members. + /// + /// + /// A reference to the configuration object that can be used + /// to influence the way the object graphs are compared. You can also provide an alternative instance of the + /// class. The global defaults are determined by the + /// class. + /// + /// + /// The used for both serialization and deserialization. + /// + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + /// is . + public static AndConstraint BeJsonSerializable(this ObjectAssertions assertions, + Func, EquivalencyOptions> options, + JsonSerializerOptions serializerOptions, + [StringSyntax("CompositeFormat")] string because = "", params object[] becauseArgs) + { + Guard.ThrowIfArgumentIsNull(options); + + try + { + object deserializedObject = CreateCloneUsingJsonSerializer(assertions.Subject, serializerOptions); + + EquivalencyOptions defaultOptions = AssertionConfiguration.Current.Equivalency.CloneDefaults() + .PreferringRuntimeMemberTypes().IncludingProperties() + .Excluding(member => MemberIsIgnoredByJsonSerialization(member)); + + deserializedObject.Should().BeEquivalentTo((T)assertions.Subject, _ => options(defaultOptions)); + } + catch (Exception exc) + { + assertions.CurrentAssertionChain + .BecauseOf(because, becauseArgs) + .FailWith("Expected {0} to be serializable{reason}, but serialization failed with:" + + Environment.NewLine + Environment.NewLine + "{1}.", + assertions.Subject, + exc.Message); + } + + return new AndConstraint(assertions); + } + + [StackTraceHidden] + private static bool MemberIsIgnoredByJsonSerialization(IMemberInfo member) + { + Type declaringType = member.DeclaringType; + if (declaringType is null) + { + return false; + } + + var memberInfo = declaringType.FindProperty(member.Name, MemberVisibility.Public | MemberVisibility.Internal) + ?? (MemberInfo)declaringType.FindField(member.Name, MemberVisibility.Public | MemberVisibility.Internal); + + return memberInfo?.IsDecoratedWith() == true; + } + + [StackTraceHidden] + private static object CreateCloneUsingJsonSerializer(object subject, JsonSerializerOptions serializerOptions) + { + string json = JsonSerializer.Serialize(subject, subject.GetType(), serializerOptions); + return JsonSerializer.Deserialize(json, subject.GetType(), serializerOptions); + } +#endif + [StackTraceHidden] private static object CreateCloneUsingDataContractSerializer(object subject) { - using var stream = new MemoryStream(); - var serializer = new DataContractSerializer(subject.GetType()); + using MemoryStream stream = new(); + DataContractSerializer serializer = new(subject.GetType()); serializer.WriteObject(stream, subject); stream.Position = 0; return serializer.ReadObject(stream); @@ -203,4 +389,3 @@ private static object CreateCloneUsingXmlSerializer(object subject) return serializer.Deserialize(stream); } } - diff --git a/Tests/FluentAssertions.Specs/Primitives/ObjectAssertionSpecs.BeJsonSerializable.cs b/Tests/FluentAssertions.Specs/Primitives/ObjectAssertionSpecs.BeJsonSerializable.cs new file mode 100644 index 0000000000..e9f8d9ff44 --- /dev/null +++ b/Tests/FluentAssertions.Specs/Primitives/ObjectAssertionSpecs.BeJsonSerializable.cs @@ -0,0 +1,223 @@ +#if NET6_0_OR_GREATER +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions.Extensions; +using JetBrains.Annotations; +using Xunit; +using Xunit.Sdk; + +namespace FluentAssertions.Specs.Primitives; + +public partial class ObjectAssertionSpecs +{ + public class BeJsonSerializable + { + [Fact] + public void Succeeds_for_a_json_serializable_object() + { + // Arrange + JsonSerializableClass subject = new() + { + Name = "John", + Id = 1 + }; + + // Act / Assert + subject.Should().BeJsonSerializable(); + } + + [Fact] + public void Fails_for_an_object_that_cannot_be_json_serialized() + { + // Arrange + ClassWithIntPtr subject = new() + { + Pointer = new IntPtr(123) + }; + + // Act + Action act = () => subject.Should().BeJsonSerializable("we need to store it on {0}", "disk"); + + // Assert + act.Should().Throw() + .WithMessage("*to be serializable because we need to store it on disk, but serialization failed with:*"); + } + + [Fact] + public void Fails_when_deserialization_requires_an_unbindable_constructor_parameter() + { + // Arrange + JsonSerializableClassWithUnbindableConstructorParameter subject = new("John") + { + BirthDay = 20.September(1973) + }; + + // Act + Action act = () => subject.Should().BeJsonSerializable(); + + // Assert + act.Should().Throw() + .WithMessage("*to be serializable, but serialization failed with:*deserialization constructor*must bind*"); + } + + [Fact] + public void Succeeds_when_json_ignored_properties_are_present() + { + // Arrange + JsonSerializableClassWithIgnoredProperty subject = new() + { + Name = "Deborah", + CachedSum = 602_214_076_000_000_000_000_000M, + }; + + // Act / Assert + subject.Should().BeJsonSerializable(); + } + + [Fact] + public void Succeeds_when_generic_serializer_options_are_required() + { + // Arrange + JsonSerializableWithCustomConverter subject = new() + { + Value = new CustomValue("123") + }; + + JsonSerializerOptions serializerOptions = new(); + serializerOptions.Converters.Add(new CustomValueJsonConverter()); + + // Act / Assert + subject.Should().BeJsonSerializable( + options => options, + serializerOptions); + } + + [Fact] + public void Succeeds_for_the_non_generic_options_overload() + { + // Arrange + JsonSerializableClass subject = new() + { + Name = "John", + Id = 1 + }; + + // Act / Assert + subject.Should().BeJsonSerializable(options => options); + } + + [Fact] + public void Succeeds_for_the_non_generic_serializer_options_overload() + { + // Arrange + JsonSerializableWithCustomConverter subject = new() + { + Value = new CustomValue("123") + }; + + JsonSerializerOptions serializerOptions = new(); + serializerOptions.Converters.Add(new CustomValueJsonConverter()); + + // Act / Assert + subject.Should().BeJsonSerializable(serializerOptions); + } + + [Fact] + public void Succeeds_for_the_non_generic_options_and_serializer_options_overload() + { + // Arrange + JsonSerializableWithCustomConverter subject = new() + { + Value = new CustomValue("123") + }; + + JsonSerializerOptions serializerOptions = new(); + serializerOptions.Converters.Add(new CustomValueJsonConverter()); + + // Act / Assert + subject.Should().BeJsonSerializable(options => options, serializerOptions); + } + + [Fact] + public void Requires_generic_equivalency_options() + { + // Arrange + JsonSerializableClass subject = new(); + + // Act + Action act = () => subject.Should().BeJsonSerializable(options: null); + + // Assert + act.Should().ThrowExactly() + .WithParameterName("options"); + } + + [Fact] + public void Requires_non_generic_equivalency_options() + { + // Arrange + JsonSerializableClass subject = new(); + + // Act + Action act = () => subject.Should().BeJsonSerializable(options: null); + + // Assert + act.Should().ThrowExactly() + .WithParameterName("options"); + } + } + + public class JsonSerializableClass + { + [UsedImplicitly] + public string Name { get; set; } + + public int Id { get; set; } + } + + public class JsonSerializableClassWithUnbindableConstructorParameter(string unboundName) + { + [UsedImplicitly] + public string Name { get; } = unboundName; + + public DateTime BirthDay { get; set; } + } + + public class JsonSerializableClassWithIgnoredProperty + { + public string Name { get; set; } + + [JsonIgnore] + public decimal CachedSum { get; set; } + } + + public class ClassWithIntPtr + { + public IntPtr Pointer { get; set; } + } + + public class JsonSerializableWithCustomConverter + { + public CustomValue Value { get; set; } + } + + public sealed class CustomValue(string value) + { + public string Value { get; } = value; + } + + public class CustomValueJsonConverter : JsonConverter + { + public override CustomValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new CustomValue(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, CustomValue value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } + } +} +#endif diff --git a/docs/_pages/basicassertions.md b/docs/_pages/basicassertions.md index 1157d87b9b..16fe6ff994 100644 --- a/docs/_pages/basicassertions.md +++ b/docs/_pages/basicassertions.md @@ -104,11 +104,12 @@ Some users requested the ability to easily downcast an object to one of its deri customer.Animals.First().As().Height.Should().Be(178); ``` -We’ve also added the possibility to assert that an object can be serialized and deserialized using the XML or data contract formatters. +We’ve also added the possibility to assert that an object can be serialized and deserialized using the XML or data contract formatters. On .NET 6 and later, you can also assert round-tripping through `System.Text.Json`. ```csharp theObject.Should().BeXmlSerializable(); +theObject.Should().BeJsonSerializable(); theObject.Should().BeDataContractSerializable(); ``` -See [Tips & Tricks - Serialization & Ignored Members](serializationignoredmembers.md) for additional information about the `BeXmlSerializable` and `BeDataContractSerializable` assertions and ignored members. +See [Tips & Tricks - Serialization & Ignored Members](serializationignoredmembers.md) for additional information about the `BeXmlSerializable`, `BeJsonSerializable`, and `BeDataContractSerializable` assertions and ignored members. diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index 29ad0528b8..6c9389430d 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -12,6 +12,7 @@ sidebar: ### What's new * Added `ThatSatisfy` to `MethodInfoSelector` and `PropertyInfoSelector` - [#3257](https://github.com/fluentassertions/fluentassertions/pull/3257) * Introduced new collection assertion methods `BeSupersetOf`, `BeProperSubsetOf` and `BeProperSupersetOf`, where an empty expected subset is treated as a valid (trivially satisfied) case for `BeSupersetOf` and `BeProperSupersetOf` instead of throwing - [#3271](https://github.com/fluentassertions/fluentassertions/pull/3271) +* Added `BeJsonSerializable()` for .NET 6+ to assert `System.Text.Json` round-tripping, with overloads for equivalency options and `JsonSerializerOptions` - [#3293](https://github.com/fluentassertions/fluentassertions/pull/3293) ### Enhancements * `BeEmpty` for `IEnumerable` assertions now lists the first 10 items in the collection instead of only the first one - [#3198](https://github.com/fluentassertions/fluentassertions/pull/3198) diff --git a/docs/_pages/serializationignoredmembers.md b/docs/_pages/serializationignoredmembers.md index d29d1c86b8..0b2afc397b 100644 --- a/docs/_pages/serializationignoredmembers.md +++ b/docs/_pages/serializationignoredmembers.md @@ -8,17 +8,18 @@ sidebar: --- -The `BeXmlSerializable` and `BeDataContractSerializable` assertions exist to verify that a particular instance can round-trip through serialization. This is done by serializing the object, deserializing the result, and then comparing the objects for equivalency. +The `BeXmlSerializable`, `BeJsonSerializable`, and `BeDataContractSerializable` assertions exist to verify that a particular instance can round-trip through serialization. This is done by serializing the object, deserializing the result, and then comparing the objects for equivalency. The XML serialization infrastructure supports marking fields and properties with the `[XmlIgnore]` attribute. Similarly, members in a `[DataContract]` can be marked `[IgnoreDataMember]`. With legacy binary serialization, fields can be marked `[NonSerialized]`, and the DataContract serializer will respect this when serializing classes marked `[Serializable]`. -When members are marked in this way, the serialized form produced omits the specified members. When deserializing it, the members are not populated with anything. This is the case even if the serialized form is XML that contains elements matching ignored members. By default, Fluent Assertions will include these fields in comparisons, causing `BeXmlSerializable`/`BeDataContractSerializable` assertions to fail. +When members are marked in this way, the serialized form produced omits the specified members. When deserializing it, the members are not populated with anything. This is the case even if the serialized form is XML that contains elements matching ignored members. By default, Fluent Assertions will include these fields in comparisons, causing `BeXmlSerializable`/`BeJsonSerializable`/`BeDataContractSerializable` assertions to fail. -You can specify that these members should be ignored using a custom implementation of `IMemberSelectionRule`. This can then be configured via the `EquivalencyOptions` object, using an overload that `BeXmlSerializable` or `BeDataContractSerializable` that takes a configuration functor. +You can specify that these members should be ignored using a custom implementation of `IMemberSelectionRule`. This can then be configured via the `EquivalencyOptions` object, using an overload that `BeXmlSerializable`, `BeJsonSerializable`, or `BeDataContractSerializable` takes a configuration functor. -The following implementations exclude members marked with `[XmlIgnore]` (`ExcludeXmlIgnoredMembersRule`), `[IgnoreDataMember]` (`ExcludeIgnoredDataMembersRule`), and `[NonSerialized]` (`ExcludeNonSerializedFieldsRule`). If you are encountering assertion failures in `BeXmlSerializable`/`BeDataContractSerializable` assertions due to ignored members, you can include these in your project. +The following implementations exclude members marked with `[XmlIgnore]` (`ExcludeXmlIgnoredMembersRule`), `[JsonIgnore]` (`ExcludeJsonIgnoredMembersRule`), `[IgnoreDataMember]` (`ExcludeIgnoredDataMembersRule`), and `[NonSerialized]` (`ExcludeNonSerializedFieldsRule`). If you are encountering assertion failures in `BeXmlSerializable`/`BeJsonSerializable`/`BeDataContractSerializable` assertions due to ignored members, you can include these in your project. - Use `ExcludeXmlIgnoredMembers` for `BeXmlSerializable` assertions. +- Use `ExcludeJsonIgnoredMembers` for `BeJsonSerializable` assertions. - The DataContract serializer respects the `[NonSerialized]` attribute from legacy formatter-based serialization. For full compatibility, use both `ExcludeIgnoredDataMembersRule` and `ExcludeNonSerializedFieldsRule` with `BeDataContractSerializable` assertions. These rules can also be used when calling `BeEquivalentTo` on object graphs produced explicitly via serialization. @@ -125,6 +126,23 @@ subject.Should().BeXmlSerializable(options => options .Using(new ExcludeXmlIgnoredMembersRule())); ``` +### JSON Serialization + +```csharp +public class Record +{ + public string Name { get; } + + [JsonIgnore] + public int CachedValue { get; } +} + +Record subject = GetRecord(); + +subject.Should().BeJsonSerializable(options => options + .Using(new ExcludeJsonIgnoredMembersRule())); +``` + ### DataContract ```csharp