Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce .Protected().As<TDuck>() to perform duck-typed setup and verification of protected members #495

Merged
merged 5 commits into from
Oct 23, 2017

Conversation

stakx
Copy link
Contributor

@stakx stakx commented Oct 22, 2017

This proposes a new feature that aims to offer more complete support for setting up and verifying protected members (including the ability to set up protected generic methods, and protected methods with by-ref parameters).

It closes #223 and #249 if merged.

Problem:

The current Setup and Verify methods accessible via mock.Protected() admittedly offer a handy way to get at protected members, but with the following downsides:

  • They are by necessity completely "untyped". As such, they do not fit well into Moq's generally strongly-typed API.
  • They are not easy to extend with support for generic methods, or methods with by-ref parameters.

Proposed solution:

The new feature proposed here adds a new method, mock.Protected().As<TDuck>(), which offers Setup and Verify methods by which protected members can be accessed in a strongly-typed manner through a totally unrelated type TDuck. This type's members are expected to correspond to the mocked type's members (by having identical signatures).

abstract class Foo
{
    protected abstract void Protected(); 
}

interface Fooish
{
    void Protected();
}

var mock = new Mock<Foo>();
mock.Protected().As<Fooish>()
    .Setup(m => m.Protected())....; // `Foo.Protected` gets set up instead of `Fooish.Protected`

When does this feature offer true added value?

This feature might seem pointless since it forces the user to declare a type just for testing; one could argue that it would be just as easy, and more meaningful, to simply let Foo implement Fooish and then mock Fooish directly. This is a fair assessment, but there might be cases where the type to be mocked is not owned by the user and cannot be modified. In these cases, the new feature offers a way to get at all protected members of that type.

@stakx stakx added the draft label Oct 22, 2017
Copy link
Contributor Author

@stakx stakx left a comment

Choose a reason for hiding this comment

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

Apart from missing setup and verification methods, and missing unit tests, there are some smallish details that need to be addressed.

/// Any type with members whose signatures are identical to the mock's protected members (except for their accessibility level).
/// </typeparam>
IProtectedAsMock<TMock, TDuck> As<TDuck>()
where TDuck : class;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is it wise to overload the As verb with yet another meaning, or should this be named differently (perhaps Like<TDuck>)?

}

public ISetup<T> Setup(Expression<Action<TDuck>> expression)
{
Copy link
Contributor Author

Choose a reason for hiding this comment

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

What kind of argument validation needs to be performed here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done: expression must not be null, and it must not be about a member that doesn't exist in T.

}

public ISetup<T, TResult> Setup<TResult>(Expression<Func<TDuck, TResult>> expression)
{
Copy link
Contributor Author

Choose a reason for hiding this comment

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

What kind of argument validation needs to be performed here?


private static LambdaExpression ReplaceDuck(LambdaExpression expression)
{
var targetParameter = Expression.Parameter(typeof(T), expression.Parameters[0].Name);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Add a Debug.Assert verifying that the passed-in LambdaExpression has one parameter.

/// <summary>
/// <see cref="ExpressionVisitor"/> used to replace occurrences of `TDuck.Member` sub-expressions with `T.Member`.
/// </summary>
private sealed class DuckReplacer : ExpressionVisitor
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Does nesting this non-generic type inside a generic type mean that the JIT / runtime will potentially generate it more than once? It would perhaps be safer to un-nest it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Based on the answers to the Stack Overflow question, "Should I avoid nested types in generic types?", I'd say its fairly safe to keep this class nested, since the outer class' type parameters are all constrained to be reference types.

{
if (node.Expression is ParameterExpression left && left.Type == this.duckType)
{
var targetParameter = Expression.Parameter(typeof(T), left.Name);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replace typeof(T) with this.targetType.

.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance)
.Where(ctm => IsCorrespondingMethod(duckMethod, ctm));

var targetMethod = candidateTargetMethods.Single();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should there be a check whether exactly one target method was identified (and if not, throw an exception with a meaningful Message)?

.GetProperties(BindingFlags.NonPublic | BindingFlags.Instance)
.Where(ctp => IsCorrespondingProperty(duckProperty, ctp));

return candidateTargetProperties.Single();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should there be a check whether exactly one target property was identified (and if not, throw an exception with a meaningful Message)?

return candidateTargetProperties.Single();
}

private static bool IsCorrespondingMethod(MethodInfo duckMethod, MethodInfo candidateTargetMethod)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Check if we are comparing method signatures for a precise match anywhere else in Moq's code base, and either reuse existing code, or make this implementation here reusable. Also check if the framework already offers something to compare method signatures—no need to reinvent the wheel.

/// <param name="expression">Lambda expression that specifies the expected method invocation.</param>
/// <seealso cref="Mock{T}.Setup{TResult}(Expression{Func{T, TResult}})"/>
ISetup<T, TResult> Setup<TResult>(Expression<Func<TDuck, TResult>> expression);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Missing: SetupGet, SetupSet, SetupSequence, and all Verify methods.

@stakx stakx force-pushed the protected-as branch 2 times, most recently from 4dbb452 to 7dd3cb4 Compare October 22, 2017 18:25
Since `SetupSet`'s parameter cannot be an expression (they may not
contain any assignments), there's nothing to rewrite like with the
other methods; if we stayed true to how Moq usually handles this kind
of expression tree limitation, we'd declare an `Action<TDuck>` param-
eter and then execute it inside a `FluentMockContext`. This doesn't
work either, since we have no instance of `TDuck` to hand to the
delegate.
@stakx stakx removed the draft label Oct 23, 2017
@stakx stakx merged commit 9db16a0 into devlooped:develop Oct 23, 2017
@stakx stakx deleted the protected-as branch October 23, 2017 19:08
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.

None yet

1 participant