From 37a241bf21395a8356226b68ca17b28b810207ad Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 20 Apr 2022 20:10:21 +0200 Subject: [PATCH] Add `For`/`Exclude` to allow exclusion of members inside a collection (#1782) --- Src/FluentAssertions/Common/MemberPath.cs | 24 +- .../MemberPathSegmentEqualityComparer.cs | 49 +++ .../EquivalencyAssertionOptions.cs | 12 +- .../NestedExclusionOptionBuilder.cs | 46 +++ .../ExcludeMemberByPathSelectionRule.cs | 8 +- .../SelectMemberByPathSelectionRule.cs | 7 +- .../FluentAssertions/net47.verified.txt | 6 + .../FluentAssertions/net6.0.verified.txt | 6 + .../netcoreapp2.1.verified.txt | 6 + .../netcoreapp3.0.verified.txt | 6 + .../netstandard2.0.verified.txt | 6 + .../netstandard2.1.verified.txt | 6 + .../CollectionSpecs.cs | 367 ++++++++++++++++++ docs/_pages/objectgraphs.md | 17 + docs/_pages/releases.md | 1 + 15 files changed, 558 insertions(+), 9 deletions(-) create mode 100644 Src/FluentAssertions/Common/MemberPathSegmentEqualityComparer.cs create mode 100644 Src/FluentAssertions/Equivalency/NestedExclusionOptionBuilder.cs diff --git a/Src/FluentAssertions/Common/MemberPath.cs b/Src/FluentAssertions/Common/MemberPath.cs index 505f7f0ab6..5d62ddf12f 100644 --- a/Src/FluentAssertions/Common/MemberPath.cs +++ b/Src/FluentAssertions/Common/MemberPath.cs @@ -17,6 +17,8 @@ internal class MemberPath private string[] segments; + private static readonly MemberPathSegmentEqualityComparer MemberPathSegmentEqualityComparer = new(); + public MemberPath(IMember member, string parentPath) : this(member.ReflectedType, member.DeclaringType, parentPath.Combine(member.Name)) { @@ -53,7 +55,7 @@ public bool IsSameAs(MemberPath candidate) { string[] candidateSegments = candidate.Segments; - return candidateSegments.SequenceEqual(Segments); + return candidateSegments.SequenceEqual(Segments, MemberPathSegmentEqualityComparer); } return false; @@ -64,7 +66,7 @@ private bool IsParentOf(MemberPath candidate) string[] candidateSegments = candidate.Segments; return candidateSegments.Length > Segments.Length && - candidateSegments.Take(Segments.Length).SequenceEqual(Segments); + candidateSegments.Take(Segments.Length).SequenceEqual(Segments, MemberPathSegmentEqualityComparer); } private bool IsChildOf(MemberPath candidate) @@ -72,7 +74,14 @@ private bool IsChildOf(MemberPath candidate) string[] candidateSegments = candidate.Segments; return candidateSegments.Length < Segments.Length - && candidateSegments.SequenceEqual(Segments.Take(candidateSegments.Length)); + && candidateSegments.SequenceEqual(Segments.Take(candidateSegments.Length), + MemberPathSegmentEqualityComparer); + } + + public MemberPath AsParentCollectionOf(MemberPath nextPath) + { + var extendedDottedPath = dottedPath.Combine(nextPath.dottedPath, "[]"); + return new MemberPath(declaringType, nextPath.reflectedType, extendedDottedPath); } /// @@ -86,7 +95,7 @@ public bool IsEquivalentTo(string path) public bool HasSameParentAs(MemberPath path) { return Segments.Length == path.Segments.Length - && GetParentSegments().SequenceEqual(path.GetParentSegments()); + && GetParentSegments().SequenceEqual(path.GetParentSegments(), MemberPathSegmentEqualityComparer); } private IEnumerable GetParentSegments() => Segments.Take(Segments.Length - 1); @@ -96,6 +105,11 @@ public bool HasSameParentAs(MemberPath path) /// public bool GetContainsSpecificCollectionIndex() => dottedPath.ContainsSpecificCollectionIndex(); + private string[] Segments => + segments ??= dottedPath + .Replace("[]", "[*]", StringComparison.Ordinal) + .Split(new[] { '.', '[', ']' }, StringSplitOptions.RemoveEmptyEntries); + /// /// Returns a copy of the current object as if it represented an un-indexed item in a collection. /// @@ -104,8 +118,6 @@ public MemberPath WithCollectionAsRoot() return new MemberPath(reflectedType, declaringType, "[]." + dottedPath); } - private string[] Segments => segments ??= dottedPath.Split(new[] { '.', '[', ']' }, StringSplitOptions.RemoveEmptyEntries); - /// /// Returns the name of the member the current path points to without its parent path. /// diff --git a/Src/FluentAssertions/Common/MemberPathSegmentEqualityComparer.cs b/Src/FluentAssertions/Common/MemberPathSegmentEqualityComparer.cs new file mode 100644 index 0000000000..10c1a43215 --- /dev/null +++ b/Src/FluentAssertions/Common/MemberPathSegmentEqualityComparer.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace FluentAssertions.Common +{ + /// + /// Compares two segments of a . + /// Sets the equal with any numeric index qualifier. + /// All other comparisons are default string equality. + /// + internal class MemberPathSegmentEqualityComparer : IEqualityComparer + { + private const string AnyIndexQualifier = "*"; + private static readonly Regex IndexQualifierRegex = new(@"^\d+$"); + + /// + /// Compares two segments of a . + /// + /// Left part of the comparison. + /// Right part of the comparison. + /// True if segments are equal, false if not. + public bool Equals(string x, string y) + { + if (x == AnyIndexQualifier) + { + return IsIndexQualifier(y); + } + + if (y == AnyIndexQualifier) + { + return IsIndexQualifier(x); + } + + return x == y; + } + + private static bool IsIndexQualifier(string segment) + => segment == AnyIndexQualifier || IndexQualifierRegex.IsMatch(segment); + + public int GetHashCode(string obj) + { +#if NETCOREAPP2_1_OR_GREATER + return obj.GetHashCode(System.StringComparison.Ordinal); +#else + return obj.GetHashCode(); +#endif + } + } +} diff --git a/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs b/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs index 0b16cdf241..14fbc86abc 100644 --- a/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs +++ b/Src/FluentAssertions/Equivalency/EquivalencyAssertionOptions.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using FluentAssertions.Common; using FluentAssertions.Equivalency.Execution; @@ -40,6 +39,17 @@ public EquivalencyAssertionOptions Excluding(Expression + /// Selects a collection to define exclusions at. + /// Allows to navigate deeper by using . + /// + public NestedExclusionOptionBuilder For(Expression>> expression) + { + var selectionRule = new ExcludeMemberByPathSelectionRule(expression.GetMemberPath()); + AddSelectionRule(selectionRule); + return new NestedExclusionOptionBuilder(this, selectionRule); + } + /// /// Includes the specified member in the equality check. /// diff --git a/Src/FluentAssertions/Equivalency/NestedExclusionOptionBuilder.cs b/Src/FluentAssertions/Equivalency/NestedExclusionOptionBuilder.cs new file mode 100644 index 0000000000..02821424f1 --- /dev/null +++ b/Src/FluentAssertions/Equivalency/NestedExclusionOptionBuilder.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using FluentAssertions.Common; +using FluentAssertions.Equivalency.Selection; + +namespace FluentAssertions.Equivalency +{ + public class NestedExclusionOptionBuilder + { + /// + /// The selected path starting at the first . + /// + private readonly ExcludeMemberByPathSelectionRule currentPathSelectionRule; + + private readonly EquivalencyAssertionOptions capturedAssertionOptions; + + internal NestedExclusionOptionBuilder(EquivalencyAssertionOptions capturedAssertionOptions, + ExcludeMemberByPathSelectionRule currentPathSelectionRule) + { + this.capturedAssertionOptions = capturedAssertionOptions; + this.currentPathSelectionRule = currentPathSelectionRule; + } + + /// + /// Selects a nested property to exclude. This ends the chain. + /// + public EquivalencyAssertionOptions Exclude(Expression> expression) + { + var nextPath = expression.GetMemberPath(); + currentPathSelectionRule.AppendPath(nextPath); + return capturedAssertionOptions; + } + + /// + /// Adds the selected collection to the chain. + /// + public NestedExclusionOptionBuilder For( + Expression>> expression) + { + var nextPath = expression.GetMemberPath(); + currentPathSelectionRule.AppendPath(nextPath); + return new NestedExclusionOptionBuilder(capturedAssertionOptions, currentPathSelectionRule); + } + } +} diff --git a/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs index 3b4c263837..0d68882444 100644 --- a/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/ExcludeMemberByPathSelectionRule.cs @@ -8,7 +8,7 @@ namespace FluentAssertions.Equivalency.Selection /// internal class ExcludeMemberByPathSelectionRule : SelectMemberByPathSelectionRule { - private readonly MemberPath memberToExclude; + private MemberPath memberToExclude; public ExcludeMemberByPathSelectionRule(MemberPath pathToExclude) : base(pathToExclude.ToString()) @@ -23,6 +23,12 @@ public ExcludeMemberByPathSelectionRule(MemberPath pathToExclude) memberToExclude.IsSameAs(new MemberPath(member, parentPath))); } + public void AppendPath(MemberPath nextPath) + { + memberToExclude = memberToExclude.AsParentCollectionOf(nextPath); + SetSelectedPath(memberToExclude.ToString()); + } + public override string ToString() { return "Exclude member " + memberToExclude; diff --git a/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs b/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs index 8a32b13ea3..753b59818a 100644 --- a/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs +++ b/Src/FluentAssertions/Equivalency/Selection/SelectMemberByPathSelectionRule.cs @@ -7,7 +7,7 @@ namespace FluentAssertions.Equivalency.Selection { internal abstract class SelectMemberByPathSelectionRule : IMemberSelectionRule { - private readonly string selectedPath; + private string selectedPath; protected SelectMemberByPathSelectionRule(string selectedPath) { @@ -16,6 +16,11 @@ protected SelectMemberByPathSelectionRule(string selectedPath) public virtual bool IncludesMembers => false; + protected void SetSelectedPath(string path) + { + this.selectedPath = path; + } + public IEnumerable SelectMembers(INode currentNode, IEnumerable selectedMembers, MemberSelectionContext context) { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt index 8e6e5e5a1d..a86a4adf5e 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net47.verified.txt @@ -791,6 +791,7 @@ namespace FluentAssertions.Equivalency public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions> AsCollection() { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Excluding(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Including(System.Linq.Expressions.Expression> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(string expectationMemberPath, string subjectMemberPath) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(System.Linq.Expressions.Expression> expectationMemberPath, System.Linq.Expressions.Expression> subjectMemberPath) { } @@ -959,6 +960,11 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, } + public class NestedExclusionOptionBuilder + { + public FluentAssertions.Equivalency.EquivalencyAssertionOptions Exclude(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } + } public class Node : FluentAssertions.Equivalency.INode { public Node() { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt index 48dce352e8..45386470fb 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/net6.0.verified.txt @@ -803,6 +803,7 @@ namespace FluentAssertions.Equivalency public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions> AsCollection() { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Excluding(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Including(System.Linq.Expressions.Expression> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(string expectationMemberPath, string subjectMemberPath) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(System.Linq.Expressions.Expression> expectationMemberPath, System.Linq.Expressions.Expression> subjectMemberPath) { } @@ -971,6 +972,11 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, } + public class NestedExclusionOptionBuilder + { + public FluentAssertions.Equivalency.EquivalencyAssertionOptions Exclude(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } + } public class Node : FluentAssertions.Equivalency.INode { public Node() { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt index e0c1d1a59c..32f7082f3f 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp2.1.verified.txt @@ -791,6 +791,7 @@ namespace FluentAssertions.Equivalency public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions> AsCollection() { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Excluding(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Including(System.Linq.Expressions.Expression> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(string expectationMemberPath, string subjectMemberPath) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(System.Linq.Expressions.Expression> expectationMemberPath, System.Linq.Expressions.Expression> subjectMemberPath) { } @@ -959,6 +960,11 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, } + public class NestedExclusionOptionBuilder + { + public FluentAssertions.Equivalency.EquivalencyAssertionOptions Exclude(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } + } public class Node : FluentAssertions.Equivalency.INode { public Node() { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt index 48cf7bfda4..cd16c72a15 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netcoreapp3.0.verified.txt @@ -791,6 +791,7 @@ namespace FluentAssertions.Equivalency public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions> AsCollection() { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Excluding(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Including(System.Linq.Expressions.Expression> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(string expectationMemberPath, string subjectMemberPath) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(System.Linq.Expressions.Expression> expectationMemberPath, System.Linq.Expressions.Expression> subjectMemberPath) { } @@ -959,6 +960,11 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, } + public class NestedExclusionOptionBuilder + { + public FluentAssertions.Equivalency.EquivalencyAssertionOptions Exclude(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } + } public class Node : FluentAssertions.Equivalency.INode { public Node() { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt index 80b123a3d3..e7ef120517 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.0.verified.txt @@ -784,6 +784,7 @@ namespace FluentAssertions.Equivalency public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions> AsCollection() { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Excluding(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Including(System.Linq.Expressions.Expression> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(string expectationMemberPath, string subjectMemberPath) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(System.Linq.Expressions.Expression> expectationMemberPath, System.Linq.Expressions.Expression> subjectMemberPath) { } @@ -952,6 +953,11 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, } + public class NestedExclusionOptionBuilder + { + public FluentAssertions.Equivalency.EquivalencyAssertionOptions Exclude(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } + } public class Node : FluentAssertions.Equivalency.INode { public Node() { } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt index 618a9a052d..f17275a93c 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions/netstandard2.1.verified.txt @@ -791,6 +791,7 @@ namespace FluentAssertions.Equivalency public EquivalencyAssertionOptions(FluentAssertions.Equivalency.IEquivalencyAssertionOptions defaults) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions> AsCollection() { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Excluding(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions Including(System.Linq.Expressions.Expression> expression) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(string expectationMemberPath, string subjectMemberPath) { } public FluentAssertions.Equivalency.EquivalencyAssertionOptions WithMapping(System.Linq.Expressions.Expression> expectationMemberPath, System.Linq.Expressions.Expression> subjectMemberPath) { } @@ -959,6 +960,11 @@ namespace FluentAssertions.Equivalency Internal = 1, Public = 2, } + public class NestedExclusionOptionBuilder + { + public FluentAssertions.Equivalency.EquivalencyAssertionOptions Exclude(System.Linq.Expressions.Expression> expression) { } + public FluentAssertions.Equivalency.NestedExclusionOptionBuilder For(System.Linq.Expressions.Expression>> expression) { } + } public class Node : FluentAssertions.Equivalency.INode { public Node() { } diff --git a/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs b/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs index 6e228e27d1..0d5856bec0 100644 --- a/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs +++ b/Tests/FluentAssertions.Equivalency.Specs/CollectionSpecs.cs @@ -558,6 +558,373 @@ public void When_a_deeply_nested_property_of_a_collection_with_an_invalid_value_ act.Should().NotThrow(); } + public class For + { + [Fact] + public void When_property_in_collection_is_excluded_it_should_not_throw() + { + // Arrange + var subject = new + { + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 2, + Text = "Actual" + } + } + } + }; + + var expected = new + { + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 3, + Text = "Actual" + } + } + } + }; + + // Act / Assert + subject.Should().BeEquivalentTo(expected, + options => options + .For(x => x.Level.Collection) + .Exclude(x => x.Number)); + } + + [Fact] + public void When_collection_in_collection_is_excluded_it_should_not_throw() + { + // Arrange + var subject = new + { + Level = new + { + Collection = new[] + { + new + { + Number = 1, + NextCollection = new[] + { + new + { + Text = "Text" + } + } + }, + new + { + Number = 2, + NextCollection = new[] + { + new + { + Text = "Actual" + } + } + } + } + } + }; + + var expected = new + { + Level = new + { + Collection = new[] + { + new + { + Number = 1, + NextCollection = new[] + { + new + { + Text = "Text" + } + } + }, + new + { + Number = 2, + NextCollection = new[] + { + new + { + Text = "Expected" + } + } + } + } + } + }; + + // Act / Assert + subject.Should().BeEquivalentTo(expected, + options => options + .For(x => x.Level.Collection) + .Exclude(x => x.NextCollection)); + } + + [Fact] + public void When_property_in_collection_in_collection_is_excluded_it_should_not_throw() + { + // Arrange + var subject = new + { + Text = "Text", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + NextCollection = new[] + { + new + { + Text = "Text" + } + } + }, + new + { + Number = 2, + NextCollection = new[] + { + new + { + Text = "Actual" + } + } + } + } + } + }; + + var expected = new + { + Text = "Text", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + NextCollection = new[] + { + new + { + Text = "Text" + } + } + }, + new + { + Number = 2, + NextCollection = new[] + { + new + { + Text = "Expected" + } + } + } + } + } + }; + + // Act / Assert + subject.Should().BeEquivalentTo(expected, + options => options + .For(x => x.Level.Collection) + .For(x => x.NextCollection) + .Exclude(x => x.Text) + ); + } + + [Fact] + public void A_nested_exclusion_can_be_followed_by_a_root_level_exclusion() + { + // Arrange + var subject = new + { + Text = "Actual", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 2, + Text = "Actual" + } + } + } + }; + + var expected = new + { + Text = "Expected", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 2, + Text = "Expected" + } + } + } + }; + + // Act / Assert + subject.Should().BeEquivalentTo(expected, + options => options + .For(x => x.Level.Collection).Exclude(x => x.Text) + .Excluding(x => x.Text)); + } + + [Fact] + public void A_nested_exclusion_can_be_preceded_by_a_root_level_exclusion() + { + // Arrange + var subject = new + { + Text = "Actual", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 2, + Text = "Actual" + } + } + } + }; + + var expected = new + { + Text = "Expected", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 2, + Text = "Expected" + } + } + } + }; + + // Act / Assert + subject.Should().BeEquivalentTo(expected, + options => options + .Excluding(x => x.Text) + .For(x => x.Level.Collection).Exclude(x => x.Text)); + } + + [Fact] + public void A_nested_exclusion_can_be_followed_by_a_nested_exclusion() + { + // Arrange + var subject = new + { + Text = "Actual", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 2, + Text = "Actual" + } + } + } + }; + + var expected = new + { + Text = "Actual", + Level = new + { + Collection = new[] + { + new + { + Number = 1, + Text = "Text" + }, + new + { + Number = 3, + Text = "Expected" + } + } + } + }; + + // Act / Assert + subject.Should().BeEquivalentTo(expected, + options => options + .For(x => x.Level.Collection).Exclude(x => x.Text) + .For(x => x.Level.Collection).Exclude(x => x.Number)); + } + } + [Fact] public void When_a_dictionary_property_is_detected_it_should_ignore_the_order_of_the_pairs() { diff --git a/docs/_pages/objectgraphs.md b/docs/_pages/objectgraphs.md index a21abb76ed..fc37bf3496 100644 --- a/docs/_pages/objectgraphs.md +++ b/docs/_pages/objectgraphs.md @@ -130,6 +130,23 @@ orderDto.Should().BeEquivalentTo(order, options => options.Excluding(o => o.Products[1].Status)); ``` +You can use `For` and `Exclude` if you want to exclude a member on each nested object regardless of its index. + +```csharp +orderDto.Should().BeEquivalentTo(order, options => + options.For(o => o.Products) + .Exclude(o => o.Status)); +``` + +Using `For` you can navigate arbitrarily deep. Consider a `Product` has a collection of `Part`s and a `Part` has a name. Using `For` your can also exclude the `Name` of all `Part`s of all `Product`s. + +```csharp +orderDto.Should().BeEquivalentTo(order, options => + options.For(o => o.Products) + .For(o => o.Parts) + .Exclude(o => o.Name)); +``` + Of course, `Excluding()` and `ExcludingMissingMembers()` can be combined. You can also take a different approach and explicitly tell Fluent Assertions which members to include. You can directly specify a property expression or use a predicate that acts on the provided `ISubjectInfo`. diff --git a/docs/_pages/releases.md b/docs/_pages/releases.md index ff728d4bb2..4953f2f24f 100644 --- a/docs/_pages/releases.md +++ b/docs/_pages/releases.md @@ -13,6 +13,7 @@ sidebar: * Add `BeDefined` and `NotBeDefined` to assert on existence of an enum value - [#1888](https://github.com/fluentassertions/fluentassertions/pull/1888) * Added the ability to exclude fields & properties marked as non-browsable in the code editor from structural equality comparisons - [#1807](https://github.com/fluentassertions/fluentassertions/pull/1807) & [#1812](https://github.com/fluentassertions/fluentassertions/pull/1812) * Assertions on the collection types in System.Data (`DataSet.Tables`, `DataTable.Columns`, `DataTable.Rows`) have been restored - [#1812](https://github.com/fluentassertions/fluentassertions/pull/1812) +* Add `For`/`Exclude` to allow exclusion of members inside a collection - [#1782](https://github.com/fluentassertions/fluentassertions/pull/1782) ## 6.6.0