fluentassertions icon indicating copy to clipboard operation
fluentassertions copied to clipboard

Scoped value formatters

Open FLAMESpl opened this issue 1 year ago • 21 comments

Background and motivation

This feature is about allowing adding custom IValueFormatters to AssertionScope. In my development I discovered that I need to selectively customize formatting of an object based on the test's context. There is an option to remove once added formatter but it will not play nicely if tests would be executed simultanously. Having scoped formatters have a side bonus of reusable parametrizing formatter instances.

API Proposal

First make IFormatterCollection interface.

public interface IFormatterCollection : ICollection<IValueFormatter>
{
}

Then add it to AssertionScope:

public class AssertionScope
{
    public IFormatterCollection Formatters { get; } = // initialize from parent scope
}

Formatter API remains unchanged. Internally it will utilize IFormatterCollection for sake of coherence.

API Usage

using (var scope = new AssertionScope())
{
   scope.Formatters .Add(new MyCustomFormatter())
   // some assertions
}

Alternative Designs

Alternative would be to add a custom key-value store in AssertionScope that could be read inside a IValueFormatter.

Risks

None I can think of.

Are you willing to help with a proof-of-concept (as PR in that or a separate repo) first and as pull-request later on?

Yes, please assign this issue to me.

FLAMESpl avatar Nov 24 '23 14:11 FLAMESpl

Maybe scope.UsingFormatter(new MyCustomFormatter()) is nicer?

dennisdoomen avatar Nov 25 '23 07:11 dennisdoomen

It is also an option, this way collection of formatters could be internal.

FLAMESpl avatar Nov 28 '23 15:11 FLAMESpl

I'm fine with that. What about you @jnyrup ?

dennisdoomen avatar Nov 30 '23 06:11 dennisdoomen

I like the proposal of having scoped formatters 👍

As the signature is Formatter.ToString(object value, FormattingOptions options = null) placing the scoped formatters on FormattingOptions might be a good place?

When retrieving all formatters in Formatter.Format it will then search in scoped custom formatters, static custom formatters and lastly static default formatters.

Perhaps we should also move the current Formatter.CustomFormatters to FormattingOptions? This should be doable without introducing any breaking changes, by forwarding the existing AddFormatter and RemoveFormatter. The statically set custom formatters will then be set on the static AssertionOptions.FormattingOptions and the scoped ones will be set on AssertionScope.FormattingOptions.

To set a scoped formatter you'll only have to do

using var scope = new AssertionScope();
scope.FormattingOptions.AddFormatter(new MyCustomFormatter());

jnyrup avatar Feb 24 '24 13:02 jnyrup

That sounds like a great idea. Let's go for that.

dennisdoomen avatar Feb 24 '24 19:02 dennisdoomen

@FLAMESpl are you still interested in this one?

dennisdoomen avatar Mar 21 '24 06:03 dennisdoomen

@dennisdoomen: I would certainly be interested 😃

ChristoWolf avatar Mar 21 '24 07:03 ChristoWolf

I meant in implementing this.

dennisdoomen avatar Mar 21 '24 07:03 dennisdoomen

Ah whoops, I misunderstood.

ChristoWolf avatar Mar 21 '24 09:03 ChristoWolf

I am willing to take that, if @FLAMESpl doesn't want to.

IT-VBFK avatar Mar 21 '24 13:03 IT-VBFK

Should a nested AssertionScope pick up all previous defined scoped formatters?

ITaluone avatar Mar 22 '24 08:03 ITaluone

Everything that is associated with an AssertionScope flows in the nested scopes.

dennisdoomen avatar Mar 22 '24 09:03 dennisdoomen

Ok, the tricky part is how to determine which scoped formatters to remove at scope dispose 🤔

ITaluone avatar Mar 22 '24 09:03 ITaluone

Yeah, it means that the scope needs some state on which scope has added a particular formatter.

dennisdoomen avatar Mar 22 '24 13:03 dennisdoomen

Since @FLAMESpl does not respond to this issue, I started the implementation. I have one remaining question: Should inner scope formatting options be allowed to modify outer scope scoped formatters?

For example: Should this be possible?

[Fact]
public void Test2()
{
    using var outerScope = new AssertionScope();

    var outerFormatter = new OuterFormatter();
    var innerFormatter = new InnerFormatter();
    outerScope.FormattingOptions.AddFormatter(outerFormatter);

    using var innerScope = new AssertionScope();
    innerScope.FormattingOptions.AddFormatter(innerFormatter);

    innerScope.FormattingOptions.RemoveFormatter(outerFormatter);
}

My main concern is, that this would become pretty "slow" when traversing back a lot of parent AssertionScopes, like:

[Fact]
public void Test2()
{
    using var outerScope = new AssertionScope();

    var outerFormatter = new OuterFormatter();
    var innerFormatter = new InnerFormatter();
    outerScope.FormattingOptions.AddFormatter(outerFormatter);

    using var innerScope = new AssertionScope();
    innerScope.FormattingOptions.AddFormatter(innerFormatter);

    using var innerScope1 = new AssertionScope();
    innerScope1.FormattingOptions.AddFormatter(innerFormatter);

    // ...
    using var innerScope100 = new AssertionScope();
    innerScope100.FormattingOptions.AddFormatter(innerFormatter);

    innerScope100.FormattingOptions.RemoveFormatter(outerFormatter);

}

That said: my current implementation does not allow such a thing.

ITaluone avatar Jun 18 '24 12:06 ITaluone

Should inner scope formatting options be allowed to modify outer scope scoped formatters?

No, but inner scope options should be able to override the formatters used by the outer scope. So if the inner options remove a formatter that was added by the outer scope, that should only affect the inner scope.

dennisdoomen avatar Jun 18 '24 12:06 dennisdoomen

I struggle to understand, why the scoped formatters are not tied to the AssertionScope?

The fact that the formatters are mainly static and we forward Formatter.AddFormatter() to FormattingOptions.AddFormatter I cannot distinguish whether this should go to the static custom formatters or to the scoped formatters.

Second, with the current implementation I am able to do the following: AssertionOption.Formatters.AddFormatter() which will then add a scoped formatter and not a custom formatter..

So: long story short I wonder what you are thinking about this:

using var outerScope = new AssertionScope();

var outerFormatter = new OuterFormatter();
outerScope.AddFormatter(outerFormatter);

which adds a scoped formatter, and

// as suggested forwarding the first to the second
Formatter.AddFormatter(); 
AssertionOptions.FormattingOptions.AddFormatter(); 

to add a custom formatter.

Edit: After a few times back-and-forth I finally managed this.. Ever day a new challenge 🎉🎉

ITaluone avatar Jun 19 '24 13:06 ITaluone

This usually happens only if I run all tests. (Please note, that this couldn't be a issue on my side, because this obviously happens on GHA too: see here: https://github.com/fluentassertions/fluentassertions/actions/runs/9565943493/job/26370017856

I've never seen this before, but it does seem to be related to formatters.

dennisdoomen avatar Jun 20 '24 15:06 dennisdoomen

I struggle to understand, why the scoped formatters are not tied to the AssertionScope?

Well, that's what you are trying to change, right?

The fact that the formatters are mainly static and we forward Formatter.AddFormatter() to FormattingOptions.AddFormatter I cannot distinguish whether this should go to the static custom formatters or to the scoped formatters.

I don't understand the question. When you use Formatter.AddFormatter, with the proposed changes, you're adding a global formatter to the AssertionOptions.FormattingOptions

Second, with the current implementation I am able to do the following: AssertionOption.Formatters.AddFormatter() which will then add a scoped formatter and not a custom formatter..

This should add a global formatter that is available to all assertion scopes.

So: long story short I wonder what you are thinking about this:

using var outerScope = new AssertionScope();

var outerFormatter = new OuterFormatter();
outerScope.AddFormatter(outerFormatter);

which adds a scoped formatter, and

// as suggested forwarding the first to the second
Formatter.AddFormatter(); 
AssertionOptions.FormattingOptions.AddFormatter(); 

to add a custom formatter.

I thought we said to use outerScope.FormattingOptions.AddFormatter(formatter)?

dennisdoomen avatar Jun 20 '24 15:06 dennisdoomen

I have to admit, I had blinders on.. After I rethought the API it suddenly made sense.. Sorry for the spam..

I struggle to understand, why the scoped formatters are not tied to the AssertionScope?

Well, that's what you are trying to change, right?

What I meant was: why are the scoped formatters tied to FormattingOptions and not to AssertionScope directly. But like I said.. I rethought this and it made sense

ITaluone avatar Jun 20 '24 16:06 ITaluone

Don't worry about that. I had to study the entire thread and look at the code to build a good understanding as well.

dennisdoomen avatar Jun 20 '24 18:06 dennisdoomen

When will this be released?

ChristoWolf avatar Jul 12 '24 08:07 ChristoWolf

When will this be released?

With v7 - we don't have an ETA

jnyrup avatar Jul 12 '24 09:07 jnyrup