Skip to content

[TUnit.Mocks] Get<T>() can't be configured for T inaccessible to the calling assembly (unlike NSubstitute's runtime-proxy auto-recursion) #6514

Description

@samtrion

Summary

When migrating from NSubstitute to TUnit.Mocks, I hit a case where a mocked type's generic method is called internally by a third-party SDK with a type argument that our assembly cannot name — an internal type in that SDK. NSubstitute can still handle this transparently; TUnit.Mocks structurally cannot, because it's a compile-time source generator running in the consuming assembly rather than a runtime proxy.

Context

Microsoft.Azure.Functions.Worker.FunctionContext.Features is an IInvocationFeatures, whose Get<T>() is called inside the Worker SDK itself (via context.GetInvocationResult()) with T = Microsoft.Azure.Functions.Worker.Context.Features.IFunctionBindingsFeature — an internal type. Our test assembly has no InternalsVisibleTo grant to that SDK assembly, so we cannot even write IFunctionBindingsFeature in our code, let alone configure a mock for it.

What NSubstitute does that works

using NSubstitute;

var features = Substitute.For<IInvocationFeatures>();
// We never configure Get<IFunctionBindingsFeature>() at all - we can't even name the type.
// But when the Worker SDK internally calls features.Get<IFunctionBindingsFeature>(),
// NSubstitute's Castle DynamicProxy transparently returns an auto-generated substitute
// for ANY T, regardless of its accessibility to our assembly - because Castle's proxy
// assembly (DynamicProxyGenAssembly2) is one of the assemblies the Worker SDK grants
// InternalsVisibleTo, while our own assembly is not.
context.Features.Returns(features);

This works because NSubstitute's substitution happens at runtime, inside an assembly the target SDK already trusts.

What TUnit.Mocks does instead

using TUnit.Mocks;

var features = IInvocationFeatures.Mock();
context.Features.Returns(features);

// Later, when Microsoft.Azure.Functions.Worker's own extension method internally calls:
//   features.Get<IFunctionBindingsFeature>()
// TUnit.Mocks generated Get<T>() overloads only for T's our compile-time code actually
// asked to configure. For any other T - especially one we structurally cannot name because
// it's internal to another assembly - the call falls through to a plain unconfigured
// default (null), and there is no API to register "whatever T is asked for, return X"
// the way NSubstitute's proxy does implicitly.

The observable symptom is a NullReferenceException deep inside Worker SDK code that has nothing to do with the test's actual intent, because Get<IFunctionBindingsFeature>() silently returns null instead of an auto-substitute.

Minimal repro shape

public interface IFeatures
{
    T Get<T>();
}

internal interface IInternalOnly { } // simulates a type from another assembly we can't name

public class Consumer
{
    public string Run(IFeatures features)
    {
        // Simulates a third-party SDK internally requesting an internal type we can't reference
        var internalFeature = features.Get<IInternalOnly>(); // we can't write this line ourselves
        return internalFeature is null ? "null" : "configured";
    }
}

With NSubstitute, Substitute.For<IFeatures>() returns "configured" for any T without any setup. With IFeatures.Mock(), there is no way to make an unconfigured/unnameable T resolve to anything but the default value.

Question / ask

Is this a known, accepted limitation of the compile-time-generator approach (i.e. "if you need this, keep NSubstitute for that one mock"), or is there a supported escape hatch we're missing — e.g. a way to register a fallback handler for Get<T>() regardless of T, or a lower-level hook that isn't restricted to types nameable by the source generator? If it's a hard limitation, it'd be worth calling out explicitly in the docs so people migrating from NSubstitute know to expect it for this shape of code (generic accessor methods called by third-party internals).

Versions

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions