1

I am trying to mock a class which registers users to test. and In the test code I can see it clearly fails at callbackUrl below.

The PageModel class has a field IUrlHelper declared as Url. The IUrlHelper interface has 5 methods which does NOT include .Page(). Mocking those 5 would be easy but I have no idea how to mock extension methods.

Can someone please help? I have been stuck on this for ages.

RegisterModel

public class RegisterModel : PageModel
{
    private readonly IUrlHelper _urlHelper;

    public RegisterModel(
        IUrlHelper urlHelper)
        {}

    public async Task<IActionResult> OnPostAsync(
        string returnUrl = null)
    {
        returnUrl = returnUrl ?? Url.Content("~/");
        var callbackUrl = Url.Page(
                    "/Account/ConfirmEmail",
                    pageHandler: null,
                    values: new { userId = "full code has IdentityUserCreated", code = "string" },
                    protocol: Request.Scheme);
        LocalRedirect(returnUrl);                          
        return Page();
    }
}

RegisterModelTests

[TestFixture]
public class RegisterModelTests
{
    private Mock<IUrlHelper> _mockUrlHelper;

    [SetUp]
    public void SetUp()
    {
        _mockUrlHelper = new Mock<IUrlHelper>();
        SetUpUrlHelper();
    }

    public RegisterModel CreateRegisterModel()
    {
        return new RegisterModel(
            _mockUrlHelper.Object
        );
    }

    [Test]
    public async Task GivenValidInput_OnPostAsync_CreatesANewUser()
    {
        // Arrange
        var unitUnderTest = CreateRegisterModel();

        // Act
        var result = await unitUnderTest.OnPostAsync("/asdsad/asda");

        // Assert
        if (result != null)
            Assert.Pass();
    }

    private void SetUpUrlHelper()
    {
        _mockUrlHelper.Setup(x => x.Page(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IdentityUser>(),
               It.IsAny<string>())).Returns("callbackUrl").Verifiable();
    }
steve
  • 797
  • 1
  • 9
  • 27
  • 1
    1) You're not going to be able to effectively unit test a Razor Page. You should be running an integration test instead, using the test server. 2) `Url.Page` is failing because there's no `HttpContext`. There's no need to mock it, as the return will be consistent, you simply need to ensure there's an `HttpContext`. Hence the need for integration testing. – Chris Pratt Mar 15 '19 at 16:45
  • I agree, I posted an option below but that is more a last ditch version. In this case I would ask myself what I am testing, it feels like you are testing the dotnet libraries and not your code. – ICodeGorilla Mar 15 '19 at 16:59
  • @ChrisPratt @ICodeGorilla in terms of `HttpContext` would this post resolve that? https://learn.microsoft.com/en-us/aspnet/core/test/razor-pages-tests?view=aspnetcore-2.1#unit-tests-of-the-page-model-methods . Or would you say Integration testing is still the way to go? – steve Mar 15 '19 at 17:02
  • That's why I couched my statement a bit. It's possible to unit test if you mock out all the dependencies, but there's a lot and at a certain point you're more testing that you mocked everything correctly than your actual code. Something like a Razor Page or a controller action is really system of things, and it just makes more sense to do integration testing. – Chris Pratt Mar 15 '19 at 17:05
  • Yes, but I think you need to ask yourself what is important here? I think you only care about the callback url? The rest is boiler plate controller code. Have you thought of maybe extracting that out into a service and then testing that? @ChrisPratt I have done some integration testing where we just created our own implementation of the httpContextAccessor and had the IOC inject that. The value of that felt less than the effort. But you could do that once and then you have a mix of both, it at least adds to the TDD approach. – ICodeGorilla Mar 15 '19 at 17:06
  • @ICodeGorilla I made the code simple for StackOverflow, in that method there's SignInManager, UserManager, IEmailSender used. I left them out as the code passes over them successfully. I tried extracting code but after a commit review I was told not to tamper with Microsoft's code and make it work how it came. What do you think? The callBackUrl is ultimately used as an account confirmation link. – steve Mar 15 '19 at 17:09
  • This is where it gets tricky, I don't know the layering of the site. Personally I always just go with does it do one thing and one thing well. For this I would argue that you should be able to use that url in multiple places, lets say email, site, sms, whatever. To make it easier to use make a service that will generate that for you without tying it to the page. – ICodeGorilla Mar 15 '19 at 17:13
  • 1
    @ICodeGorilla thanks i'll try that! – steve Mar 15 '19 at 17:18
  • @steve were you able to fix this? – Alexander Aug 25 '19 at 11:13
  • @Alexander yes I was able to, thanks – steve Aug 25 '19 at 11:18
  • how can you help? i am now having that problem.. cannot make it work. I already tried wrapping url.page extension method to IUrlHelperWrapper on an interface IUrlHelperWrapper. then setting it up in moq, but still getting error. Now I am separating the extension method to another service. still trying this approach. Thanks – Alexander Aug 25 '19 at 11:33
  • @Alexander hi Alexander, can you post that as a separate question please? I will then provide the code that worked for me. Thanks! I hope my solution works for you. link the question here. – steve Aug 28 '19 at 11:01

2 Answers2

1

The short answer is that you can not mock an extension method as they are static methods and moq can only handle object. But this post will tell you more.

You may want to change the way your testing, moving it to a controller level. But I think there is a technical solution. We can do it but maybe we shouldn't.

You should however be able to shim the method. It is swapping out a method address with another. Make sure you really need it and ensure that it is only used in testing.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;

public static class ShimHelper
{
    public static void Replace<TOriginal, TTarget>()
    {
        var typeOfOriginal = typeof(TOriginal);
        Replace<TTarget>(typeOfOriginal);
    }

    public static void Replace<TTarget>(Type typeOfOriginal)
    {
        var targetMethods = GetStaticPublicMethods<TTarget>();
        foreach (var targetMethod in targetMethods)
        {
            var parameters = targetMethod.GetParameters().Select(x => x.ParameterType).ToArray();
            var originalMethod = typeOfOriginal.GetMethod(targetMethod.Name, parameters);
            if (originalMethod != null)
            {
                SwapMethodBodies(originalMethod, targetMethod);
            }
            else
            {
                Debug.WriteLine(
                    "*****************************************************************************************");
                Debug.WriteLine($"Method not found - {targetMethod.Name}");
                Debug.WriteLine(
                    "*****************************************************************************************");
            }
        }
    }

    private static List<MethodInfo> GetStaticPublicMethods<T>()
    {
        return typeof(T).GetMethods(BindingFlags.Public | BindingFlags.Static)
            .Distinct().ToList();
    }

    private static void SwapMethodBodies(MethodInfo a, MethodInfo b)
    {
        RuntimeHelpers.PrepareMethod(a.MethodHandle);
        RuntimeHelpers.PrepareMethod(b.MethodHandle);

        unsafe
        {
            if (IntPtr.Size == 4)
            {
                Replace32Bit(a, b);
            }
            else
            {
                Replace64Bit(a, b);
            }
        }
    }

    private static unsafe void Replace64Bit(MethodInfo a, MethodInfo b)
    {
        var inj = (long*)b.MethodHandle.Value.ToPointer() + 1;
        var tar = (long*)a.MethodHandle.Value.ToPointer() + 1;
        *tar = *inj;
    }

    private static unsafe void Replace32Bit(MethodInfo a, MethodInfo b)
    {
        var inj = (int*)b.MethodHandle.Value.ToPointer() + 2;
        var tar = (int*)a.MethodHandle.Value.ToPointer() + 2;
        *tar = *inj;
    }
}

Usage:

ShimHelper.Replace<ExtensionClass, MockedExtensionClass>();

Where your mocked extension class matches the method signature exactly. Run this in your test fixture setup and you should be good.

ICodeGorilla
  • 588
  • 1
  • 5
  • 26
0

I tried ICodeGorilla's solution but found that Static Types cannot be used as type arguments. So I modified the code a bit to this:

        public static void Replace(Type original, Type target)
        {
            var targetMethods = GetStaticPublicMethods(target);
            foreach (var targetMethod in targetMethods)
            {
                var parameters = targetMethod.GetParameters().Select(x => x.ParameterType).ToArray();
                var originalMethod = original.GetMethod(targetMethod.Name, parameters);
                if (originalMethod != null)
                {
                    SwapMethodBodies(originalMethod, targetMethod);
                }
                else
                {
                    Debug.WriteLine(
                        "*****************************************************************************************");
                    Debug.WriteLine($"Method not found - {targetMethod.Name}");
                    Debug.WriteLine(
                        "*****************************************************************************************");
                }
            }
        }

        private static List<MethodInfo> GetStaticPublicMethods(Type t)
        {
            return t.GetMethods(BindingFlags.Public | BindingFlags.Static)
                .Distinct().ToList();
        }

The usage is now:

ShimHelper.Replace(
                typeof(ExtensionClass), 
                typeof(MockedExtensionClass));

I found this worked very nicely for AjaxRequestExtensions in MVC.

Greg Gorman
  • 196
  • 1
  • 9