shouldly / shouldly

Should testing for .NET—the way assertions should be!

Home Page:https://docs.shouldly.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

ShouldBe() fails to compare FSharpOption value

loop-evgeny opened this issue · comments

As of Shouldly 4.0.3, ShouldBe() fails with F# options when it should succeed.

Code:

namespace FSharpTest

open System.Collections.Generic
open NUnit.Framework
open Shouldly

[<TestFixture>]
type ShouldlyTests() = 
    [<Test>]
    member this.TestFSharpOption() =
        let a = HashSet<string>([|"test"|])
        let b = HashSet<string>([|"test"|])
        a.ShouldBe(b) // OK
        (Some a).ShouldBe(Some b) // Fails with
            // Shouldly.ShouldAssertException : Some a
            //    should be
            // Some(System.Collections.Generic.HashSet`1[System.String])
            //    but was
            // Some(System.Collections.Generic.HashSet`1[System.String])

Project file:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="FsharpShouldlyTests.fs" />
  </ItemGroup>
  
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.1" />
    <PackageReference Include="NUnit" Version="3.12.0" />
    <PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
    <PackageReference Include="Shouldly" Version="4.0.3" />
  </ItemGroup>
  
</Project>

There is not a clear way to solve this. We would have to special-case certain types and that would be a big break from previous behavior, which has legitimate uses.

The IEnumerable<T> overload of ShouldBe behaves differently than the default ShouldBe overload which delegates equality checks to the type itself, in this case the FSharpOption type. We could special-case the FSharpOption type, but that wouldn't fix it for every other type which contains HashSet<string> members and compares them as part of its custom equality check. ValueTuple, custom classes and structs, Nullable<ValueTypedCollection<T>> would behave the way FSharpOption is currently behaving. Even IEnumerable<IEnumerable<T>> doesn't special-case the default equality when comparing the inner enumerables to each other.

When you don't want to use the default equality implemented by a type, it means you'll have to unwrap the containing type yourself in all these cases, FSharpOption being one of many, and then call .ShouldBe on IEnumerable instances directly.

Other examples of this that are prevalent are people's custom Option<T> structs and Result<T> structs. There for sure Shouldly can not consistently do anything with the values they contain, other than what it currently does which is to delegate to the Equals implementation on the type itself.

With other types you would unwrap this way:

collectionOfHashsetsA.ShouldHaveSingleItem().ShouldBe(
    collectionOfHashsetsB.ShouldHaveSingleItem());


nullableOfImmutableArrayA.ShouldNotBeNull().ShouldBe(
    nullableOfImmutableArrayB.ShouldNotBeNull());

But since Shouldly doesn't bring a dependency on F#, we can't ship an unwrapping method specifically for FSharpOption:

optionOfHashsetA.ShouldBeSome().ShouldBe(
    optionOfHashsetB.ShouldBeSome());

Such extension methods can now be created in your own test code, though.

Without such extension methods, the code that would be consistent with how Shouldly works elsewhere would be similar to this:

[<Test>]
member this.TestFSharpOption() =
    let someA = Some(HashSet<string>([|"test"|]))
    let someB = Some(HashSet<string>([|"test"|]))
    someA.IsSome.ShouldBe(someB.IsSome)
    someA.Value.ShouldBe(someB.Value)

Or, if you have FSharpOption on one side and not necessarily on the other:

[<Test>]
member this.TestFSharpOption() =
    let someA = Some(HashSet<string>([|"test"|]))
    let b = HashSet<string>([|"test"|])
    someA.IsSome.ShouldBeTrue();
    someA.Value.ShouldBe(b)

This is in principle the same as #767 only with FSharpOption instead of Dictionary.