3
votes

TryUpdateModelAsync is an internal protected method of abstract class PageModel, which makes it, to my view, unmockable. Is there any approach to unit test any of the action that involves this method?

I saw this solution on the internet: asp.net core mvc controller unit testing when using TryUpdateModel.

However, it only applies to ASP.NET Core MVC Web App since TryUpdateModelAsync is a public method, so we can add an adapator to wrap this method and make our adaptor call the actual method. In Razor Page, this TryUpdateModelAsync is inaccessible from outside.

The problem I was having right now is when I was unit testing the OnPostAsync method, TryUpdateModelAsync always throw an exception. I could never pass through that line to check the code logic after it.

So here is the action I want to unit test:

 public class CreateModel : PageModel
    {
     //elided
        [BindProperty]
        public Post Post { get; set; }

        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }
            if (await TryUpdateModelAsync<Post>(Post, "Post", p => p.Title, p => p.SubTitle, p => p.Author, p => p.Content, p => p.CategoryID)){
                //do stuff
                return RedirectToPage("./Index");
            }
            //do stuff
            return Page();
        }

Here's the unit test I wrote for it:

  [Test]
  public async Task InvokeOnPostAsyncWithInvalidModelState_ShouldReturnPageResultType(){
            //Arrange
            InitPostModelProperties();
            var createModel = new CreateModel(){
                PageContext = _pageContext,
                TempData = _tempData,
                Url = _urlHelper
            };
           //do arrangement

            //Act
            var result = await createModel.OnPostAsync();

            //Assert
        }

UPDATE:

Here's the full initialization process I did for this test:

  public void InitPostModelProperties(){
 _httpContext = new DefaultHttpContext(){
                //RequestServices = services.BuildServiceProvider()
            };
            _modelStateDictionary = new ModelStateDictionary();
            _modelMetaDataProvider = new EmptyModelMetadataProvider();
            _actionContext = new ActionContext(_httpContext, new RouteData(), new PageActionDescriptor(), _modelStateDictionary);
            _viewData = new ViewDataDictionary(_modelMetaDataProvider, _modelStateDictionary);
            _tempData = new TempDataDictionary(_httpContext, Mock.Of<ITempDataProvider>());
            _pageContext = new PageContext(_actionContext){
                ViewData = _viewData
            };
            _urlHelper = new UrlHelper(_actionContext);
        }

Then I got following exception message:

  Error Message:
   System.ArgumentNullException : Value cannot be null.
Parameter name: metadataProvider
  Stack Trace:
     at Microsoft.AspNetCore.Mvc.ModelBinding.Internal.ModelBindingHelper.TryUpdateModelAsync(Object model, Type modelType, String prefix, ActionContext actionContext, IModelMetadataProvider metadataProvider, IModelBinderFactory modelBinderFactory, IValueProvider valueProvider, IObjectModelValidator objectModelValidator, Func`2 propertyFilter)
   at Microsoft.AspNetCore.Mvc.RazorPages.PageModel.TryUpdateModelAsync[TModel](TModel model, String name, Expression`1[] includeExpressions)

Then I changed my initialization to the following:

  public void InitPostModelProperties(){
  var services = new ServiceCollection();
  services.AddTransient<IModelMetadataProvider, ModelMetadataProvider>();
 _httpContext = new DefaultHttpContext(){
                RequestServices = services.BuildServiceProvider()
            };
            _modelStateDictionary = new ModelStateDictionary();
            _modelMetaDataProvider = new EmptyModelMetadataProvider();
            _actionContext = new ActionContext(_httpContext, new RouteData(), new PageActionDescriptor(), _modelStateDictionary);
            _viewData = new ViewDataDictionary(_modelMetaDataProvider, _modelStateDictionary);
            _tempData = new TempDataDictionary(_httpContext, Mock.Of<ITempDataProvider>());
            _pageContext = new PageContext(_actionContext){
                ViewData = _viewData
            };
            _urlHelper = new UrlHelper(_actionContext);
        }

Then I got new exception message:

Outcome: Failed
    Error Message:
    System.InvalidOperationException : No service for type 'Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider' has been registered.
    Stack Trace:
       at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(IServiceProvider provider, Type serviceType)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
   at Microsoft.AspNetCore.Mvc.RazorPages.PageModel.get_MetadataProvider()
   at Microsoft.AspNetCore.Mvc.RazorPages.PageModel.TryUpdateModelAsync[TModel](TModel model, String name, Expression`1[] includeExpressions)

I followed the exception message and added these lines:

services.AddSingleton<IModelMetadataProvider, EmptyModelMetadataProvider>();

Got new exception message:

  Outcome: Failed
    Error Message:
    System.InvalidOperationException : No service for type 'Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinderFactory' has been registered.
    Stack Trace:
       at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(IServiceProvider provider, Type serviceType)
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
   at Microsoft.AspNetCore.Mvc.RazorPages.PageModel.get_ModelBinderFactory()
   at Microsoft.AspNetCore.Mvc.RazorPages.PageModel.TryUpdateModelAsync[TModel](TModel model, String name, Expression`1[] includeExpressions)

I kept adding new lines:

 services.AddSingleton<IModelBinderFactory,ModelBinderFactory>();

Only to find the service can't be instansiated beause ModelBinderFactory class is an abstract class

I am stuck here.

UPDATE

The whole test code is shown below:

SRC:

  public class BaseTests
    {
        private  HugoBlogContext _context;

        public HugoBlogContext Context => _context;
        public List<Category> CatList;
        public List<Post> PostList;

        public List<Tag> TagList;

        public List<PostTag> PostTagList;

        public BaseTests(){}


 public class PageModelBaseTests: BaseTests
    {
        protected ICategoryRepository _categoryRepository;

        protected IPostRepository _postRepository;

        protected IPostTagRepository _postTagRepository;

        protected ITagRepository _tagRepository;

        protected ISelectTagService _selectTagService;

        protected IDropdownCategoryService _dropdownCategoryService;

        protected HttpContext _httpContext;

        protected ModelStateDictionary _modelStateDictionary;

        protected ActionContext _actionContext;

        protected ModelMetadataProvider _modelMetaDataProvider;

        protected ViewDataDictionary _viewData;

        protected TempDataDictionary _tempData;

        protected PageContext _pageContext;

        protected UrlHelper _urlHelper;



        [SetUp]
        public void Init(){
            _categoryRepository = new CategoryRepository(Context);
            _postRepository = new PostRepository(Context);
            _postTagRepository = new PostTagRepository(Context);
            _tagRepository = new TagRepository(Context);
            _selectTagService = new SelectTagService(_tagRepository, _postRepository, _postTagRepository);
            _dropdownCategoryService = new DropdownCategoryService(_categoryRepository);
        }


        public void InitPostModelProperties(){
            var services = new ServiceCollection();
            services.AddSingleton<IModelMetadataProvider, EmptyModelMetadataProvider>();
            services.AddSingleton<IModelBinderFactory,ModelBinderFactory>();
            _httpContext = new DefaultHttpContext(){
                RequestServices = services.BuildServiceProvider()
            };
            _modelStateDictionary = new ModelStateDictionary();
            _modelMetaDataProvider = new EmptyModelMetadataProvider();
            _actionContext = new ActionContext(_httpContext, new RouteData(), new PageActionDescriptor(), _modelStateDictionary);
            _viewData = new ViewDataDictionary(_modelMetaDataProvider, _modelStateDictionary);
            _tempData = new TempDataDictionary(_httpContext, Mock.Of<ITempDataProvider>());
            _pageContext = new PageContext(_actionContext){
                ViewData = _viewData
            };
            _urlHelper = new UrlHelper(_actionContext);
        }


[TestFixture]
  public class CreateModelTests: PageModelBaseTests
    {

[Test]
    public async Task InvokeOnPostAsyncWithValidModelState_ShouldReturnRedirectPageResultTypeAndCategoryShouldBeUpdated(){
              //Arrange
            InitPostModelProperties();
            var createModel = new CreateModel(_selectTagService, _dropdownCategoryService ,_postRepository){
                PageContext = _pageContext,
                TempData = _tempData,
                Url = _urlHelper,
            };
            createModel.Post = new Post{
                Title = "Create",
                SubTitle = "Create",
                Content = "Create",
                Author = "Create",
                CategoryID = CatList.Count + 1,
            };
            var selectedTags = new string[3]{"4","5","6"};

            //Act
            var result = await createModel.OnPostAsync(selectedTags);

            //Assert
            result.Should().BeOfType<RedirectToPageResult>();
            Context.Posts.Where(c => c.CategoryID == (CatList.Count + 1)).Should().NotBeNull();
        }
    }

UPDATE After mocking, I got the following Exception Message:

  Outcome: Failed
    Error Message:
    System.NullReferenceException : Object reference not set to an instance of an object.
    Stack Trace:
       at Microsoft.AspNetCore.Mvc.ModelBinding.Internal.ModelBindingHelper.TryUpdateModelAsync(Object model, Type modelType, String prefix, ActionContext actionContext, IModelMetadataProvider metadataProvider, IModelBinderFactory modelBinderFactory, IValueProvider valueProvider, IObjectModelValidator objectModelValidator, Func`2 propertyFilter)
   at Microsoft.AspNetCore.Mvc.RazorPages.PageModel.TryUpdateModelAsync[TModel](TModel model, String name, Expression`1[] includeExpressions)

FYI: I also mocked IObjectValidator by using services.AddSingleton<IObjectValidator>(Mock.Of<IObjectValidator>())

1
You can mock the abstract class and add it to the service collection.Nkosi
I agree with Nkosi. Mock the abstract class and add it to the service collection.Simone Spagna
Do you mean by using services.AddTransient<IModelBinderFactory, SOMETHING>();?SteinGate
By passing a mock into the service collection. services.AddSingleton<IModelBinderFactory>(Mock.Of<IModelBinderFactory>());Nkosi
There is new exception thrown. It says Object reference not set to an instance of an object.. But I can't tell which one isn't instansiated by jus looking at the exception meesageSteinGate

1 Answers

0
votes

I'm not sure it's our responsibility to test TryValidateModel or TryUpdateModelAsync; surely it is safe to presume that the framework methods are correct, right.

Another argument is that you are testing your implementation and not someone else's.

Anyhow, if you would like to ignore the methods, you can try this in your test method (and I am using Moq as my Mocking Framework) to ignore the methods completely and in effect presume they are correct:

var objectModelValidatorMock = new Mock<IObjectModelValidator>();
objectModelValidatorMock
    .Setup(o => o.Validate(
        It.IsAny<ActionContext>(),
        It.IsAny<ValidationStateDictionary>(),
        It.IsAny<string>(),
        It.IsAny<object>()));

var sut = new NewController
{
    ObjectValidator = objectModelValidatorMock.Object
};