Skip to content

Issue4824 replace delegates#5196

Merged
OsirisTerje merged 9 commits intomainfrom
Issue4824_ReplaceDelegates
Apr 18, 2026
Merged

Issue4824 replace delegates#5196
OsirisTerje merged 9 commits intomainfrom
Issue4824_ReplaceDelegates

Conversation

@manfred-brands
Copy link
Copy Markdown
Member

@manfred-brands manfred-brands commented Mar 29, 2026

Fixes #4824

Note this requires nunit/nunit.analyzers#982
When completed remove TODO sections.

I have added separate commits to mark the Delegate methods as Obsolete if we want to do that as well.

@stevenaw stevenaw changed the base branch from main to release/5.X March 30, 2026 12:23
@manfred-brands manfred-brands force-pushed the Issue4824_ReplaceDelegates branch 2 times, most recently from c4b9f76 to d3deb37 Compare April 11, 2026 04:08
@manfred-brands manfred-brands marked this pull request as ready for review April 11, 2026 05:50
@manfred-brands
Copy link
Copy Markdown
Member Author

manfred-brands commented Apr 11, 2026

There is one compatibility issue:

Func<object?> code = () => null;
Assert.That(code, Is.Not.Null);

Previously this was executed as Assert.That<TActual>(TActual value, ...) which checked if the 'pointer' was null.
Now, it executes the delegate and checks the returned value.

To test a Func<T> variable for null one must cast it to object?:

Assert.That((object?)code, Is.Not.Null);

or obtain the value indirectly.
This calls Func<TActual> with TActual being Func<object?>.
It returns the code variable which then can be test for null:

Assert.That(() => code, Is.Not.Null);

Note this is the same behaviour if the user had used an ActualValueDelegate variable:

ActualValueDelegate<object?> code = () => null;
Assert.That(code, Is.Not.Null);

Note that Action and TestDelegate are only executed for ThrowsConstraint.
In other case it use the function point as the actual value to the constraint.

@OsirisTerje
Copy link
Copy Markdown
Member

OsirisTerje commented Apr 11, 2026

@manfred-brands So a major change here is that with your changes, all delegates are executed, not only the ones that have the Throws constraints?
Have I got that right ?

Current behavior (version 4.5.X):

Delegate Type Can Send to Assert.That? Executed? When Useful?
Func<T> ✅ Yes ❌ No Never (useless)
Action ✅ Yes ❌ No Never (useless)
TestDelegate ✅ Yes ✅ Only with Throws Only with Throws
ActualValueDelegate<T> ✅ Yes ✅ Yes Always
Lambdas ✅ Yes Depends on conversion Depends on target type

@manfred-brands
Copy link
Copy Markdown
Member Author

@OsirisTerje

Have I got that right ?

Regarding your Never (useless) comment for Func<T>.
As I said before it can be used to test for null.
Is there practically a case where you would have a method that return a value?
I can think of optional processing tasks like Func<T, T>.

Func<T>? getValue = DetermineGetValueMethod();
Assert.That(getValue, Is.Not.Null);
T value = getValue.Invoke();

@OsirisTerje
Copy link
Copy Markdown
Member

Ok, but with your changes in this PR, are the delegates executed ?

Ref

Now, it executes the delegate and checks the returned value.

@manfred-brands
Copy link
Copy Markdown
Member Author

Ok, but with your changes in this PR, are the delegates executed ?

Ref

Now, it executes the delegate and checks the returned value.

Yes

@OsirisTerje
Copy link
Copy Markdown
Member

@manfred-brands Is this PR just waiting for nunit/nunit.analyzers#982 ?

@manfred-brands
Copy link
Copy Markdown
Member Author

@manfred-brands Is this PR just waiting for nunit/nunit.analyzers#982 ?

Also on a decision if we want to put this in the 4.6 branch instead possible with a setting to switch behaviour as it then would be a non-breaking feature. It would get my vote.

@stevenaw
Copy link
Copy Markdown
Member

stevenaw commented Apr 12, 2026

I'm just catching up after being away for a week, but if I read right then it sounds like we are invoking the lambda in all cases now?

I don't think I have an "informed" opinion yet, but my first instinct is that the previous behavior felt a bit more natural to me, as did the workaround. Previously, where we only executed for throws, one could "work around" this by just manually invoking the method and passing the return value in. It also followed the "opt-in" model where the lambda could be "opted into" executing by having calling code do that. If that's right, then having to cast a Func to an object to "opt out" feels a bit more "hidden" to me and less clear.

To help me understand, was there a use case towards moving to the new model?

@manfred-brands
Copy link
Copy Markdown
Member Author

@stevenaw to clarify, the new behaviour for Func<T> is the same as for ActualValueDelegate<T>. There is also no difference for lambdas.

The only difference is for a variable declared as Func<T> which previously was treated like object but now the same as ActualValueDelegate<T>.

@manfred-brands manfred-brands force-pushed the Issue4824_ReplaceDelegates branch from bc8a64d to aa698ac Compare April 13, 2026 00:28
@manfred-brands
Copy link
Copy Markdown
Member Author

a setting to switch behaviour as it then would be a non-breaking feature.

We cannot make such a setting as we cannot distinguish between a variable of type Func<T> or a lambda.
We could make an analyzer rule which can detect if a variable is used and tested for null, like:
Assert.That(FuncVariable, Is.Not.Null);
If FuncVariable has a non-class return type, that is clearly a test on the variable.
But if the return type is a class, we can't tell if the user intended execution or not.

@stevenaw
Copy link
Copy Markdown
Member

to clarify, the new behaviour for Func is the same as for ActualValueDelegate. There is also no difference for lambdas.

The only difference is for a variable declared as Func which previously was treated like object but now the same as ActualValueDelegate.

Ok that's great then, thanks for confirming @manfred-brands . I have no concerns with how you've described this

@OsirisTerje
Copy link
Copy Markdown
Member

OsirisTerje commented Apr 13, 2026

Also on a decision if we want to put this in the 4.6 branch instead possible with a setting to switch behavior as it then would be a non-breaking feature. It would get my vote.

Since most of the possible other usages was "useless" and also invoked warnings, I think this may go into 4.6 (imho).

But, the analyzer then needs to be updated wrt those warnings. The once I see may be affected is

image

If the switch is to be able to be sent down from the runsettings, then we need to add that setting to the adapter too.

IF anyone wants to check a delegate for null (current behavior) I am fine with the (object) cast.

And we need to document all of this, so a document issue is needed.

PS: One thing I like here is that this will also support deferred execution which we use for the After modifier (delay and repeat).

@manfred-brands
Copy link
Copy Markdown
Member Author

Also on a decision if we want to put this in the 4.6 branch instead possible with a setting to switch behavior as it then would be a non-breaking feature. It would get my vote.

Since most of the possible other usages was "useless" and also invoked warnings, I think this may go into 4.6 (imho).

But, the analyzer then needs to be updated wrt those warnings. The once I see may be affected is

I'll check my PR for the Func with parameters.

If the switch is to be able to be sent down from the runsettings, then we need to add that setting to the adapter too.

We cannot do a switch as that would disable all code using lambda.
The lambda will use the Func overload so we cannot switch that to old behaviour.
Basically any recompiled code will evaluate the Func.
We could add special code that:
IFF the constraint is Null or 'Not.Null' AND the Func does not return a class, it must want to check the delegate itself.

IF anyone wants to check a delegate for null (current behavior) I am fine with the (object) cast.

And we need to document all of this, so a document issue is needed.

@OsirisTerje
Copy link
Copy Markdown
Member

Sounds good to me.
Do you change the target to main ?

Comment thread src/NUnitFramework/framework/Assume.cs
@OsirisTerje
Copy link
Copy Markdown
Member

Just noted this analyzer issue nunit/nunit.analyzers#982, so that comment above is ok

Copy link
Copy Markdown
Member

@OsirisTerje OsirisTerje left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Just some places where CS1573 probably should be silenced, left one comment on that.

@manfred-brands manfred-brands force-pushed the Issue4824_ReplaceDelegates branch from aa698ac to 0752378 Compare April 14, 2026 09:50
@manfred-brands manfred-brands changed the base branch from release/5.X to main April 14, 2026 09:52
@manfred-brands
Copy link
Copy Markdown
Member Author

@OsirisTerje I retargeted to main.

Locally, I get build errors which look related to what you are working on:

     ConsoleWrite_WritesToResult_AdhocContext (7ms): Error Message:   Assert.That(stdout.ToString(), Is.EqualTo(SOME_MORE_TEXT))
        Expected string length 27 but was 0. Strings differ at index 0.
        Expected: "Should be written to stdout"
        But was:  <string.Empty>

@OsirisTerje
Copy link
Copy Markdown
Member

Re local build fails:

Tested locally here, and it WOMM. But this test is flaky, once the Phase2 is merged, it should be better, I hope.

Wonder a bit why we never get rid of that appveyor build......

/// <param name="code">A func returning the value to be tested</param>
/// <returns>A ConstraintResult</returns>
[Obsolete("This was never implemented and will be removed.")]
ConstraintResult ApplyTo<TActual>(ref TActual actual);
Copy link
Copy Markdown
Member

@stevenaw stevenaw Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question:

@manfred-brands I'm only just starting to dig into this PR, but this change caught my eye. Changing an interface method's signature would typically be a breaking change, but the PR has been repointed to main now instead of release/5.x. Do we need to adjust this to ensure no one is broken?

EDIT: Similar questions and concerns about related changes to the Constraint base class too

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @stevenaw, you are correct:

This was marked Obsolete in 4.4 and as I initially targeted 5.X I thought to remove it.
I could rewrite the PR and remove the commit removing this overload and change that to a separate PR targeting 5.X if you prefer.

How breaking is it?
I people implemented it previously, nothing from our side is calling it.
The implementation we had, simply called the normal ApplyTo.

A Github Override Search shows lots of clones/copies of NUnit Framework.

A GitHub Call Search only shows one usage in HarmonyTest which calls in to the base class.

Yes, the change to IConstraint to require new overloads is more breaking. Again I was targeting 5.X previously.
I can take the same approach as #4813 where a new IAsyncConstraint interface was added deriving from IConstraint with runtime checks to see if the resolved constraint is actually async:

if (constraint is not IAsyncConstraint asyncConstraint)
{
    throw new NotSupportedException($"Constraint {constraint?.GetType().Name} does not support async execution.");
}

I'm not in favour of converting compile time to runtime checks.
But we could do that for 4.6 and change it to the current for 5.X/

Or we can retarget this PR back to 5.X?

@stevenaw
Copy link
Copy Markdown
Member

stevenaw commented Apr 17, 2026

Re local build fails:

Tested locally here, and it WOMM. But this test is flaky, once the Phase2 is merged, it should be better, I hope.

Wonder a bit why we never get rid of that appveyor build......

My suspicion is that GH must've been having issues at the time the PR was last updated. @manfred-brands I was able to resolve the issue previously in a PR by amending the most recent commit to include whitespace, which forced GH to reevaluate the branch due to the changing commit hash.

The most recent GH Actions build seemed to have passed. I have rerun it as well just now to see if that also helps
https://github.com/nunit/nunit/actions/runs/24320163783

EDIT: I wonder if it's the change in target branch that is complicating this. That build I linked seems to still reference v5 and should be ignored

@OsirisTerje
Copy link
Copy Markdown
Member

How breaking is it?

I don't think it is too harmful (will probably come back to bite me, but....).
Suggest we keep this on 4.6. We can make a note in the release notes about the possibilities for breaking there.

Then we should continue the discussion on versioning we started, and also consider if we should be more aggressive with using the major versioning. Right now we have a self-inflicting wound/rule to keep as few major versions as possible. We don't have to do that. But this is for #5223 .

@OsirisTerje OsirisTerje merged commit dba634d into main Apr 18, 2026
8 checks passed
@OsirisTerje OsirisTerje deleted the Issue4824_ReplaceDelegates branch April 18, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert TestDelegate and ActualValueDelegate to Action and Func<T>

3 participants