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
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
= 26. Select Fluent Assertion Alternative Due to Licensing Issue

Date: 2025-09-04

== Problem

We have been using Fluent Assertions as an assertion library to enhance the readability and maintainability of our test code through fluent interfaces. However, starting from version 8, Fluent Assertions has transitioned to a paid NuGet package. Given the current pricing model, the cost outweighs the benefits it provides to our project. Consequently, we are evaluating open-source alternatives to replace Fluent Assertions.

=== Option 1: NFluentAssertions

This library is a fork of Fluent Assertions, allowing us to transition without modifying our existing assertions—only a NuGet package change is required.

The project is maintained by a separate group of developers, raising concerns about its long-term stability and ongoing support.

Repository: https://github.com/tpierrain/NFluent

=== Option 2: MSTest Assertions

These assertions are the standard Microsoft .NET testing utilities, maintained and updated with each .NET release.

They offer a reliable and well-supported alternative but lack the fluent syntax, resulting in assertions that are less readable and more verbose.

Documentation: https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-with-mstest

=== Option 3: Shouldly

Shouldly provides a similar fluent interface to Fluent Assertions with minor syntactic differences.

It is widely supported, maintained by an active community of contributors, and backed by sponsors, ensuring its long-term viability.

The primary downside is the need to refactor existing assertions to align with Shouldly’s syntax.

Repository: https://github.com/shouldly/shouldly

== Decision

After a thorough analysis of available options, we have decided to migrate from Fluent Assertions to Shouldly.

== Consequences
- Improved Long-Term Stability: Shouldly is actively maintained and supported by the open-source community, reducing the risk of unexpected licensing changes.
- Maintainability & Readability: Shouldly retains a fluent syntax, ensuring our tests remain easy to read and maintain.
- Refactoring Effort: Transitioning to Shouldly requires refactoring existing assertions, introducing short-term overhead but ensuring long-term sustainability.
- Cost Reduction: Moving away from a paid assertion library eliminates unnecessary expenditure while maintaining similar functionality.
- Ecosystem Alignment: Shouldly is widely adopted in the .NET ecosystem, ensuring compatibility and integration with modern development workflows.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ namespace EvolutionaryArchitecture.Fitnet.Common.Api.UnitTests;
using ErrorHandling;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Shouldly;
using Xunit;

public sealed class ExceptionMiddlewareTests
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1 @@
global using System.Net;
global using Xunit;
global using FluentAssertions;
global using Microsoft.AspNetCore.Http;
global using Newtonsoft.Json;
global using Shouldly;
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
global using Xunit;
global using FluentAssertions;
global using Shouldly;
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
namespace EvolutionaryArchitecture.Fitnet.Common.Core.UnitTests;

using System;
using System.Collections.Generic;
using System.Linq;

public class ValueObjectTests
{
private const int DefaultIntProperty = 1;
Expand All @@ -13,10 +17,10 @@ internal void Given_two_same_type_objects_When_values_are_the_same_Then_should_b
var secondObject = new FakeValueObject();

// Act & Assert
firstObject.Should().Be(secondObject);
firstObject.Equals(secondObject).Should().BeTrue();
(firstObject == secondObject).Should().BeTrue();
(firstObject != secondObject).Should().BeFalse();
firstObject.ShouldBe(secondObject);
firstObject.Equals(secondObject).ShouldBeTrue();
(firstObject == secondObject).ShouldBeTrue();
(firstObject != secondObject).ShouldBeFalse();
}

[Fact]
Expand All @@ -28,14 +32,14 @@ internal void Given_two_same_type_objects_When_values_are_not_the_same_Then_shou
var thirdObject = new FakeValueObject(property2: Guid.NewGuid().ToString());

// Act & Assert
firstObject.Should().NotBe(secondObject);
firstObject.Equals(secondObject).Should().BeFalse();
(firstObject == secondObject).Should().BeFalse();
(firstObject != secondObject).Should().BeTrue();
firstObject.Should().NotBe(thirdObject);
firstObject.Equals(thirdObject).Should().BeFalse();
(firstObject == thirdObject).Should().BeFalse();
(firstObject != thirdObject).Should().BeTrue();
firstObject.ShouldNotBe(secondObject);
firstObject.Equals(secondObject).ShouldBeFalse();
(firstObject == secondObject).ShouldBeFalse();
(firstObject != secondObject).ShouldBeTrue();
firstObject.ShouldNotBe(thirdObject);
firstObject.Equals(thirdObject).ShouldBeFalse();
(firstObject == thirdObject).ShouldBeFalse();
(firstObject != thirdObject).ShouldBeTrue();
}

[Fact]
Expand All @@ -46,7 +50,7 @@ internal void Given_two_same_type_objects_When_values_are_the_same_Then_should_h
var secondObject = new FakeValueObject();

// Act & Assert
firstObject.GetHashCode().Should().Be(secondObject.GetHashCode());
firstObject.GetHashCode().ShouldBe(secondObject.GetHashCode());
}

[Fact]
Expand All @@ -57,10 +61,10 @@ internal void Given_two_different_type_objects_When_values_are_the_same_Then_sho
var secondObject = new AnotherTypeFakeValueObject();

// Act & Assert
firstObject.Should().NotBe(secondObject);
firstObject.Equals(secondObject).Should().BeFalse();
(firstObject == secondObject).Should().BeFalse();
(firstObject != secondObject).Should().BeTrue();
firstObject.GetType().ShouldNotBe(secondObject.GetType());
firstObject.Equals(secondObject).ShouldBeFalse();
(firstObject == secondObject).ShouldBeFalse();
(firstObject != secondObject).ShouldBeTrue();
}

[Fact]
Expand All @@ -80,8 +84,8 @@ internal void Given_multiple_objects_When_looking_for_specific_one_Then_should_r
var result = valueObjects.Where(vo => vo == targetValueObject).ToList();

// Assert
result.Should().HaveCount(2);
result.Should().AllBeEquivalentTo(targetValueObject);
result.Count.ShouldBe(2);
result.ForEach(vo => vo.ShouldBe(targetValueObject));
}

[Fact]
Expand All @@ -101,7 +105,7 @@ internal void Given_multiple_objects_When_looking_for_non_existing_one_Then_shou
var result = valueObjects.Where(vo => vo == targetValueObject).ToList();

// Assert
result.Should().BeEmpty();
result.ShouldBeEmpty();
}

private class FakeValueObject(int property1 = DefaultIntProperty, string property2 = DefaultStringProperty) : ValueObject
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
global using System.Reflection;
global using JetBrains.Annotations;
global using Xunit;
global using Microsoft.AspNetCore.Mvc.Testing;
global using Microsoft.AspNetCore.Hosting;
global using Microsoft.Extensions.Configuration;
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ namespace EvolutionaryArchitecture.Fitnet.Common.IntegrationTestsToolbox.TestEng

public interface IDatabaseConfiguration
{
public Dictionary<string, string?> Get();
}
Dictionary<string, string?> Get();
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,6 @@ public static async Task<IEnumerable<IReceivedMessage<TMessage>>> WaitToConsumeM
}
}

return testHarness.Consumed!.Select<TMessage>(cancellationToken)!.ToList();
return [.. testHarness.Consumed!.Select<TMessage>(cancellationToken)!];
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Bogus" Version="35.6.1" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.analyzers" Version="1.18.0">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
global using System;
global using Bogus;
global using Shouldly;
global using Xunit;
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
namespace EvolutionaryArchitecture.Fitnet.Contracts.Api.UnitTests.SignContract.Signatures;
using EvolutionaryArchitecture.Fitnet.Contracts.Core.SignContract.Signatures;

using Core.SignContract.Signatures.Exceptions;
using FluentAssertions;

public sealed class SignatureTests
{
Expand All @@ -20,8 +18,8 @@ internal void Given_create_signature_When_signature_is_valid_Then_should_not_thr
var signature = Signature.From(now, value);

// Assert
signature.Value.Should().Be(value);
signature.Date.Should().Be(now);
signature.Value.ShouldBe(value);
signature.Date.ShouldBe(now);
}

[Theory]
Expand All @@ -33,9 +31,12 @@ internal void Given_create_signature_When_signature_has_forbidden_characters_The
var now = DateTimeOffset.Now;

// Act
Action act = () => Signature.From(now, value);
void Act()
{
Signature.From(now, value);
}

// Assert
act.Should().Throw<SignatureNotValidException>();
Should.Throw<SignatureNotValidException>(Act);
}
}
Original file line number Diff line number Diff line change
@@ -1,31 +1,25 @@
namespace EvolutionaryArchitecture.Fitnet.Contracts.Core.UnitTests.Common.Assertions.ErrorOr;

using FluentAssertions.Execution;
using FluentAssertions.Primitives;
using System.Linq;

internal sealed class ErrorOrSuccessAssertions(ErrorOr<Success> subject)
: ReferenceTypeAssertions<ErrorOr<Success>, ErrorOrSuccessAssertions>(subject)
public static class ErrorOrSuccessAssertions
{

protected override string Identifier => "ErrorOr<Success>";

public AndConstraint<ErrorOr<Success>> BeSuccessful(string because = "", params object[] becauseArgs)
public static void ShouldBeSuccessful(this ErrorOr<Success> errorOr, string? message = null)
{
Execute.Assertion
.ForCondition(!Subject.IsError)
.BecauseOf(because, becauseArgs)
.FailWith("Expected {context:ErrorOr<Success>} to be successful{reason}, but found {0}.", string.Join(", ", Subject.Errors.Select(x => x.Description)));

return new AndConstraint<ErrorOr<Success>>(Subject);
if (errorOr.IsError)
{
var errorMessage = string.Join(", ", errorOr.Errors.Select(x => x.Description));
throw new ShouldAssertException(message ?? $"ErrorOr<Success> should be successful but found errors: {errorMessage}");
}
}

public AndConstraint<ErrorOr<Success>> ContainError(Error error, string because = "", params object[] becauseArgs)
public static void ShouldContainError(this ErrorOr<Success> errorOr, Error error, string? message = null)
{
Execute.Assertion
.ForCondition(Subject.IsError && Subject.Errors.Contains(error))
.BecauseOf(because, becauseArgs)
.FailWith("Expected to contain error '{0}' but found errors: {1}", string.Join(", ", Subject.Errors.Select(x => x.Description)), error.Description);

return new AndConstraint<ErrorOr<Success>>(Subject);
errorOr.IsError.ShouldBeTrue("ErrorOr<Success> should be in error state");
if (!errorOr.Errors.Contains(error))
{
var actualErrors = string.Join(", ", errorOr.Errors.Select(x => x.Description));
throw new ShouldAssertException(message ?? $"Expected to contain error '{error.Description}' but found errors: {actualErrors}");
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<PackageReference Include="Bogus" Version="35.6.1" />
<PackageReference Include="EvolutionaryArchitecture.Fitnet.Common.Core" Version="4.1.7" />
<PackageReference Include="EvolutionaryArchitecture.Fitnet.Common.UnitTesting" Version="4.1.7" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.analyzers" Version="1.14.0">
<PrivateAssets>all</PrivateAssets>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
global using ErrorOr;
global using EvolutionaryArchitecture.Fitnet.Common.UnitTesting.Assertions.ErrorOr;
global using FluentAssertions;
global using Shouldly;
global using Xunit;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace EvolutionaryArchitecture.Fitnet.Contracts.Core.UnitTests.PrepareContract.BusinessRules;

using Common.Assertions.ErrorOr;
using Core.PrepareContract.BusinessRules;
using Fitnet.Common.Core.BussinessRules;

Expand All @@ -16,7 +17,7 @@ internal void Given_customer_age_which_is_less_than_18_Then_validation_should_ha
// Assert
var expectedError = BusinessRuleError.Create(nameof(ContractCanBePreparedOnlyForAdultRule),
"Contract can not be prepared for a person who is not adult");
result.Should().ContainError(expectedError);
result.ShouldContainError(expectedError);
}

[Fact]
Expand All @@ -28,7 +29,7 @@ internal void Given_customer_age_which_is_equal_to_18_Then_validation_should_pas
var result = BusinessRuleValidator.Validate(new ContractCanBePreparedOnlyForAdultRule(18));

// Assert
result.Should().BeSuccessful();
result.ShouldBeSuccessful();
}

[Fact]
Expand All @@ -40,6 +41,6 @@ internal void Given_customer_age_which_is_greater_than_18_Then_validation_should
var result = BusinessRuleValidator.Validate(new ContractCanBePreparedOnlyForAdultRule(19));

// Assert
result.Should().BeSuccessful();
result.ShouldBeSuccessful();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ namespace EvolutionaryArchitecture.Fitnet.Contracts.Core.UnitTests.PrepareContra

using Core.PrepareContract.BusinessRules;
using Fitnet.Common.Core.BussinessRules;
using Common.Assertions.ErrorOr;

public sealed class CustomerMustBeSmallerThanMaximumHeightLimitRuleTests
{
[Fact]
internal void Given_customer_heigth_which_is_greater_than_maximum_height_limit_Then_validation_should_have_error()
internal void Given_customer_height_which_is_greater_than_maximum_height_limit_Then_validation_should_have_error()
{
// Arrange
const int height = 211;
Expand All @@ -16,7 +17,7 @@ internal void Given_customer_heigth_which_is_greater_than_maximum_height_limit_T

// Assert
var expectedError = BusinessRuleError.Create(nameof(CustomerMustBeSmallerThanMaximumHeightLimitRule), "Customer height must fit maximum limit for gym instruments");
result.Should().ContainError(expectedError);
result.ShouldContainError(expectedError);
}

[Fact]
Expand All @@ -29,7 +30,7 @@ internal void Given_customer_height_which_is_equal_to_maximum_height_limit_Then_
var result = BusinessRuleValidator.Validate(new CustomerMustBeSmallerThanMaximumHeightLimitRule(height));

// Assert
result.Should().BeSuccessful();
result.ShouldBeSuccessful();
}

[Fact]
Expand All @@ -42,6 +43,6 @@ internal void Given_customer_height_which_is_less_than_maximum_height_limit_Then
var result = BusinessRuleValidator.Validate(new CustomerMustBeSmallerThanMaximumHeightLimitRule(height));

// Assert
result.Should().BeSuccessful();
result.ShouldBeSuccessful();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal void Given_prepare_contract_Then_should_raise_contract_prepared_event()
// Assert
var contract = preparationResult.Value;
var @event = contract.GetPublishedEvent<ContractPreparedEvent>();
@event?.CustomerId.Should().Be(_customerId);
@event?.PreparedAt.Should().Be(_preparedAt);
@event?.CustomerId.ShouldBe(_customerId);
@event?.PreparedAt.ShouldBe(_preparedAt);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ internal void Given_sign_contract_Then_expiration_date_is_set_to_contract_durati

// Assert
var @event = signResult.Value.GetPublishedEvent<BindingContractStartedEvent>();
@event?.ExpiringAt.Should().Be(expectedExpirationDate);
@event?.ExpiringAt.ShouldBe(expectedExpirationDate);
}

private static readonly DateTimeOffset FakeNow = FakeContractDates.PreparedAt.AddDays(1);
Expand All @@ -47,6 +47,6 @@ internal void Given_sign_contract_Then_contracts_becomes_binding_contract()

// Assert
var @event = signResult.Value.GetPublishedEvent<BindingContractStartedEvent>();
@event?.BindingFrom.Should().Be(SignedAt);
@event?.BindingFrom.ShouldBe(SignedAt);
}
}
Loading