Search code examples
c#fluent-assertions

How do I express these tests correctly with fluent assertions?


I have the record type below.

public record CustomFormatGroupItem(string GroupName, string CfName, string CfAnchor);

I have a method that returns a collection of these, followed by two test assertions:

ICollection<CustomFormatGroupItem> result = sut.Parse();

result.Select(x => x.GroupName).Distinct()
    .Should().BeEquivalentTo(
        "Audio Advanced #1",
        "Audio Advanced #2",
        "Movie Versions",
        "Unwanted"
    );

result.Where(x => x.GroupName == "Audio Advanced #1").Select(x => (x.CfName, x.CfAnchor))
    .Should().BeEquivalentTo(new[]
    {
        ("TrueHD ATMOS", "truehd-atmos"),
        ("DTS X", "dts-x"),
        ("ATMOS (undefined)", "atmos-undefined"),
        ("DD+ ATMOS", "dd-atmos"),
        ("TrueHD", "truehd"),
        ("DTS-HD MA", "dts-hd-ma"),
        ("DD+", "ddplus"),
        ("DTS-ES", "dts-es"),
        ("DTS", "dts")
    });

I'm concerned with my usage of LINQ to massage the data into a format I can run a fluent assertions condition against. My understanding of the philosophy of Fluent Assertions is that you should basically not manipulate the data before comparing it. I know that FA provides a suite of methods that can be used to compare data in different ways, but I'm not sure if it can manipulate / test the data in the way I am above.

Basically I'm testing two different things:

  1. In the whole collection, that exactly 4 distinct values appear in the property GroupName at least once (there may be duplicates, but that's OK)
  2. For any elements whos GroupName property equals a specific value, a list of values is matched collectively against the CfName and CfAnchor properties.

This is sort-of like a dictionary of lists, but not exactly. So I can't use things like ContainsKey(), AFAIK. Is it inappropriate to be using LINQ the way I am here, and if so, how should I represent this better using Fluent Assertions to match its philosophy?


Solution

  • To avoid having to remove duplicates there's BeSubsetOf

    result.Select(x => x.GroupName).Should().BeSubsetOf(new[]
    {
        "Audio Advanced #1",
        "Audio Advanced #2",
        "Movie Versions",
        "Unwanted"
    });
    

    To avoid having to only select the GroupName property we can utilize that BeEquivalentTo uses the expectation to select the members to compare by.

    result.DistinctBy(x => x.GroupName).Should().BeEquivalentTo(new[]
    {
        new { GroupName = "Audio Advanced #1" },
        new { GroupName = "Audio Advanced #2" },
        new { GroupName = "Movie Versions" },
        new { GroupName = "Unwanted" }
    });
    

    There's nothing built-in in FluentAssertions to do both of the above, but we can create an extension method for that.

    result.Should().OnlyContainEquivalentsOf(new[]
    {
        new { GroupName = "Audio Advanced #1" },
        new { GroupName = "Audio Advanced #2" },
        new { GroupName = "Movie Versions" },
        new { GroupName = "Unwanted" }
    });
    
    internal static class GenericCollectionAssertionExtensions
    {
        [CustomAssertion]
        public static AndConstraint<TAssertions> OnlyContainEquivalentsOf<TCollection, T, TAssertions, TExpectation>(
            this GenericCollectionAssertions<TCollection, T, TAssertions> parent,
            IEnumerable<TExpectation> expectations,
            Func<EquivalencyAssertionOptions<TExpectation>, EquivalencyAssertionOptions<TExpectation>> config,
            string because = "",
            params object[] becauseArgs)
                where TCollection : IEnumerable<T>
                where TAssertions : GenericCollectionAssertions<TCollection, T, TAssertions>
        {
            foreach (T subject in parent.Subject)
            {
                bool foundMatch = expectations.Any(expectation => 
                {
                    using var scope = new AssertionScope();
                    subject.Should().BeEquivalentTo(expectation, opt => config(opt));
                    string[] failures = scope.Discard();
                    return failures.Length == 0;
                });
                
                Execute.Assertion
                    .ForCondition(foundMatch)
                    .BecauseOf(because, becauseArgs)
                    .FailWith("Expected {context:collection} {0} to only contain equivalents of {1}{reason}, but {2} did not.", 
                        parent.Subject, expectations, subject);
            }
    
            return new((TAssertions)parent);
        }
        
        [CustomAssertion]
        public static AndConstraint<TAssertions> OnlyContainEquivalentsOf<TCollection, T, TAssertions, TExpectation>(
            this GenericCollectionAssertions<TCollection, T, TAssertions> parent,
            IEnumerable<TExpectation> expectations,
            string because = "",
            params object[] becauseArgs)
                where TCollection : IEnumerable<T>
                where TAssertions : GenericCollectionAssertions<TCollection, T, TAssertions>
        {
            return parent.OnlyContainEquivalentsOf(expectations, opt => opt, because, becauseArgs);
        }
    }
    

    For the second assertion in question, the only adjustment I see to make it use anonymous types like the second example above.

    result.Where(x => x.GroupName == "Audio Advanced #1").Should().BeEquivalentTo(new[]
    {
        new { CfName = "TrueHD ATMOS", CfAnchor = "truehd-atmos" },
        new { CfName = "DTS X", CfAnchor = "dts-x" },
        new { CfName = "ATMOS (undefined)", CfAnchor = "atmos-undefined" },
        new { CfName = "DD+ ATMOS", CfAnchor = "dd-atmos" },
        new { CfName = "TrueHD", CfAnchor = "truehd" },
        new { CfName = "DTS-HD MA", CfAnchor = "dts-hd-ma" },
        new { CfName = "DD+", CfAnchor = "ddplus" },
        new { CfName = "DTS-ES", CfAnchor = "dts-es" },
        new { CfName = "DTS", CfAnchor = "dts" }
    });