Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions Src/FluentAssertions.Json/ObjectAssertionsExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using FluentAssertions.Equivalency;
using FluentAssertions.Execution;
using FluentAssertions.Primitives;
using Newtonsoft.Json;

namespace FluentAssertions.Json
{
/// <summary>
/// Contains extension methods for JSON serialization assertion methods
/// </summary>
public static class ObjectAssertionsExtensions
{
/// <summary>
/// Asserts that an object can be serialized and deserialized using the JSON serializer and that it stills retains
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"stills retains" -> "still retains"

/// the values of all members.
/// </summary>
/// <param name="assertions"></param>
/// <param name="because">
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </param>
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <see cref="because" />.
/// </param>
[CustomAssertion]
public static AndConstraint<ObjectAssertions> BeJsonSerializable(this ObjectAssertions assertions, string because = "", params object[] becauseArgs)
{
return BeJsonSerializable<object>(assertions, options => options, because, becauseArgs);
}

/// <summary>
/// Asserts that an object can be serialized and deserialized using the JSON serializer and that it stills retains
/// the values of all members.
/// </summary>
/// <param name="assertions"></param>
/// <param name="because">
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </param>
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <see cref="because" />.
/// </param>
[CustomAssertion]
public static AndConstraint<ObjectAssertions> BeJsonSerializable<T>(this ObjectAssertions assertions, string because = "", params object[] becauseArgs)
{
return BeJsonSerializable<T>(assertions, options => options, because, becauseArgs);
}

/// <summary>
/// Asserts that an object can be serialized and deserialized using the JSON serializer and that it stills retains
/// the values of all members.
/// </summary>
/// <param name="options"></param>
/// <param name="because">
/// A formatted phrase as is supported by <see cref="string.Format(string,object[])" /> explaining why the assertion
/// is needed. If the phrase does not start with the word <i>because</i>, it is prepended automatically.
/// </param>
/// <param name="becauseArgs">
/// Zero or more objects to format using the placeholders in <see cref="because" />.
/// </param>
/// <param name="assertions"></param>
[CustomAssertion]
public static AndConstraint<ObjectAssertions> BeJsonSerializable<T>(this ObjectAssertions assertions, Func<EquivalencyAssertionOptions<T>, EquivalencyAssertionOptions<T>> options, string because = "", params object[] becauseArgs)
{
Execute.Assertion.ForCondition(assertions.Subject != null)
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:object} to be JSON serializable{reason}, but the value is null. Please provide a value for the assertion.");

Execute.Assertion.ForCondition(assertions.Subject is T)
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:object} to be JSON serializable{reason}, but {context:object} is not assignable to {0}", typeof(T));

try
{
var deserializedObject = CreateCloneUsingJsonSerializer(assertions.Subject);

var defaultOptions = AssertionOptions.CloneDefaults<T>()
.RespectingRuntimeTypes()
.IncludingFields()
.IncludingProperties();

var typedSubject = (T)assertions.Subject;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 What if the subject is not of type T?
🤔 What if the subject is null?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With respect to what to do if the subject is null: Both BeBinarySerializable() and BeDataContractSerializable() fail the assertion with what looks like a NullReferenceException. I could do the same, but I was thinking of something more like this:

    Execute.Assertion.ForCondition(assertions.Subject != null)
        .BecauseOf(because, becauseArgs)
        .FailWith("Expected {object:context} to be JSON serializable{reason}, but the value is null.  Please provide a value for the assertion.");

Not quite sure on the fail message, but it gets the point across I think

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's sloppy old code. The main library will do exactly what you suggested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done :-)

((T)deserializedObject).Should().BeEquivalentTo(typedSubject, _ => options(defaultOptions));
}
catch (Exception exc)
{
Execute.Assertion
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:object} to be JSON serializable{reason}, but serializing {0} failed with {1}", assertions.Subject, exc);
}

return new AndConstraint<ObjectAssertions>(assertions);
}

private static object CreateCloneUsingJsonSerializer(object subject)
{
var serializedObject = JsonConvert.SerializeObject(subject);
var cloneUsingJsonSerializer = JsonConvert.DeserializeObject(serializedObject, subject.GetType());
return cloneUsingJsonSerializer;
}
Comment on lines +96 to +101
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make any difference if we use subject.GetType() or typeof(T)?
E.g. for inheritance and interfaces.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like using typeof(T) causes issues with the non-generic overload of BeJsonSerialisable as T is object which causes problems with the equivalency of the deserialised object. Essentially a dynamic object is deserialised that doesn't play well with the IsEquivalentTo test. In addition the deserialised dynamic object has only string properties rather than, for example, Guids or DateTimes that the original instance had.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AutoFixture" Version="4.17.0" />
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="10.0.3" />
Expand Down
13 changes: 13 additions & 0 deletions Tests/FluentAssertions.Json.Specs/Models/AddressDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace FluentAssertions.Json.Specs.Models
{
// ReSharper disable UnusedMember.Global

public class AddressDto
{
public string AddressLine1{ get; set; }
public string AddressLine2{ get; set; }
public string AddressLine3{ get; set; }
}
// ReSharper restore UnusedMember.Global

}
12 changes: 12 additions & 0 deletions Tests/FluentAssertions.Json.Specs/Models/EmploymentDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace FluentAssertions.Json.Specs.Models
{
// ReSharper disable UnusedMember.Global

public class EmploymentDto
{
public string JobTitle{ get; set; }
public string PhoneNumber { get; set; }
}
// ReSharper restore UnusedMember.Global

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Newtonsoft.Json;

namespace FluentAssertions.Json.Specs.Models
{
public class PocoWithIgnoredProperty
{
public int Id { get; set; }

[JsonIgnore]
public string Name{ get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace FluentAssertions.Json.Specs.Models
{
public class PocoWithNoDefaultConstructor
{
public int Id { get; }

/// <summary>
/// Newtonsoft.Json will deserialise this successfully if the parameter name id the same as the property
/// </summary>
/// <param name="value">DO NOT CHANGE THE NAME OF THIS PARAMETER</param>
public PocoWithNoDefaultConstructor(int value)
{
Id = value;
}
}
}
12 changes: 12 additions & 0 deletions Tests/FluentAssertions.Json.Specs/Models/PocoWithStructure.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace FluentAssertions.Json.Specs.Models
{
// ReSharper disable UnusedMember.Global
public class PocoWithStructure
{
public int Id{ get; set; }
public AddressDto Address { get; set; }
public EmploymentDto Employment { get; set; }
}
// ReSharper restore UnusedMember.Global

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;

namespace FluentAssertions.Json.Specs.Models
{
// ReSharper disable UnusedMember.Global
public class SimplePocoWithPrimitiveTypes
{
public int Id { get; set; }
public Guid GlobalId { get; set; }
public string Name { get; set; }
public DateTime DateOfBirth { get; set; }
public decimal Height { get; set; }
public double Weight { get; set; }
public float ShoeSize { get; set; }
public bool IsActive { get; set; }

public byte[] Image { get; set; }
public char Category { get; set; }
}
// ReSharper restore UnusedMember.Global
}
136 changes: 136 additions & 0 deletions Tests/FluentAssertions.Json.Specs/ShouldBeJsonSerializableTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System;
using AutoFixture;
using FluentAssertions;
using FluentAssertions.Json;
using FluentAssertions.Json.Specs.Models;
using Xunit;

// NOTE that we are using both namespaces 'FluentAssertions' & 'FluentAssertions.Json' from an external namespace to force compiler disambiguation warnings
// ReSharper disable CheckNamespace
namespace SomeOtherNamespace
// ReSharper restore CheckNamespace
{
public class ShouldBeJsonSerializableTests
{
private readonly Fixture _fixture;

public ShouldBeJsonSerializableTests()
{
_fixture = new Fixture();
}

[Fact]
public void Simple_poco_should_be_serializable()
{
// arrange
var target = _fixture.Create<SimplePocoWithPrimitiveTypes>();

// act
Action act = () => target.Should().BeJsonSerializable();

// assert
act.Should().NotThrow();
}

[Fact]
public void Complex_poco_should_be_serializable()
{
// arrange
var target = _fixture.Create<PocoWithStructure>();

// act
Action act = () => target.Should().BeJsonSerializable();

// assert
act.Should().NotThrow();
}

[Fact]
public void Class_that_does_not_have_default_constructor_should_not_be_serializable()
{
// arrange
const string reasonText = "this is the reason";
var target = _fixture.Create<PocoWithNoDefaultConstructor>();

// act
Action act = () => target.Should().BeJsonSerializable(reasonText);

// assert
act.Should().Throw<Xunit.Sdk.XunitException>()
.Which.Message.Should()
.Contain("to be JSON serializable")
.And.Contain(reasonText)
.And.Contain("but serializing")
.And.Contain("failed with");
}

[Fact]
public void Class_that_has_ignored_property_should_not_be_serializable_if_equivalency_options_are_not_configured()
{
// arrange
const string reasonText = "this is the reason";
var target = _fixture.Create<PocoWithIgnoredProperty>();

// act
Action act = () => target.Should().BeJsonSerializable(reasonText);

// assert
act.Should().Throw<Xunit.Sdk.XunitException>()
.Which.Message.Should()
.Contain("to be JSON serializable")
.And.Contain(reasonText)
.And.Contain("but serializing")
.And.Contain("failed with");
}

[Fact]
public void Class_that_has_ignored_property_should_be_serializable_when_equivalency_options_are_configured()
{
// arrange
var target = _fixture.Create<PocoWithIgnoredProperty>();

// act
Action act = () => target.Should().BeJsonSerializable<PocoWithIgnoredProperty>(opts => opts.Excluding(p => p.Name));

// assert
act.Should().NotThrow();
}

[Fact]
public void Should_fail_when_instance_is_null()
{
// arrange
const SimplePocoWithPrimitiveTypes target = null;

// act
Action act = () => target.Should().BeJsonSerializable();

// assert
act.Should()
.Throw<Xunit.Sdk.XunitException>(because:"This is consistent with BeBinarySerializable() and BeDataContractSerializable()")
.Which.Message
.Should().Contain("value is null")
.And.Contain("Please provide a value for the assertion");
Comment on lines +111 to +113
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip, it should be possible to shorten this to:

.WithMessage("*value is null*Please provide a value for the assertion*");

}

[Fact]
public void Should_fail_when_subject_is_not_same_type_as_the_specified_generic_type()
{
// arrange
var target = new AddressDto();

// act
Action act = () => target.Should().BeJsonSerializable<SimplePocoWithPrimitiveTypes>();

// assert
act.Should().Throw<Xunit.Sdk.XunitException>(because: "This is consistent with BeBinarySerializable() and BeDataContractSerializable()")
.Which.Message
.Should().Contain("is not assignable to")
.And.Contain(nameof(SimplePocoWithPrimitiveTypes));
;
}

}

}