dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.43k stars 10.02k forks source link

Add support to create HtmlHelper instances for testing purposes #4843

Closed drauch closed 5 years ago

drauch commented 7 years ago

We are writing our own tag helpers which make use of HtmlHelper internally (e.g., to render an Editor). This seems to be the recommended behavior, see also https://docs.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro#tag-helpers-compared-to-html-helpers

For unit testing purposes we could "rather easily" instantiate an HtmlHelper in ASP.NET MVC 5 (e.g., see https://stackoverflow.com/questions/25206507/unit-testing-htmlhelper-extension-method-fails/25216750#25216750). This is no longer the case in ASP.NET Core. Is there a utility or some other easy way to instantiate an HtmlHelper for a unit test?

Eilon commented 7 years ago

Hi @drauch , there's no built-in utility I'm aware of to do this automatically.

When you tried to instantiate an HtmlHelper can you show us where you got stuck?

drauch commented 7 years ago

In the meantime I actually got to instantiate an HtmlHelper (the hardest part being instantiating the required RazorViewEngine object).

However, when I run my unit test it looks like htmlHelper.Editor(...) internally tries to obtain another HtmlHelper from the RequestServices collection - StackTrace:

System.ArgumentNullException : Value cannot be null.
Parameter name: provider
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
   at Microsoft.AspNetCore.Mvc.ViewFeatures.Internal.TemplateRenderer.MakeHtmlHelper(ViewContext viewContext, ViewDataDictionary viewData)
[...]

Is there a way to mitigate this problem? Do I have to register my HtmlHelper with the container as well? Why is htmlHelper.Editor() internally instantiating yet another HtmlHelper object?

drauch commented 7 years ago

My code (for any interested reader):

protected IHtmlHelper CreateHtmlHelper (IModelMetadataProvider modelMetadataProvider)
{
  var mvcViewOptionsValue = new MvcViewOptions();
  var mvcViewOptions = A.Fake<IOptions<MvcViewOptions>>();
  A.CallTo(() => mvcViewOptions.Value).Returns(mvcViewOptionsValue);

  mvcViewOptions.Value.ViewEngines.Add(CreateViewEngine(modelMetadataProvider));

  var viewEngine = new CompositeViewEngine(mvcViewOptions);
  var bufferScope = new MemoryPoolViewBufferScope(ArrayPool<ViewBufferValue>.Shared, ArrayPool<char>.Shared);

  return new HtmlHelper(
    CreateHtmlGenerator(modelMetadataProvider),
    viewEngine,
    modelMetadataProvider,
    bufferScope,
    HtmlEncoder.Default,
    UrlEncoder.Default);
}

private IViewEngine CreateViewEngine (IModelMetadataProvider modelMetadataProvider)
{
  var fileProvider = new PhysicalFileProvider("C:\\"); // some path, we do not want to locate non-default views anyways

  var razorViewEngineOptionsValue = new RazorViewEngineOptions();
  razorViewEngineOptionsValue.FileProviders.Add(fileProvider);
  razorViewEngineOptionsValue.ViewLocationFormats.Add("Views/{1}/{0}");
  razorViewEngineOptionsValue.AreaViewLocationFormats.Add("Areas/{2}/{1}/{0}");

  var razorViewEngineOptions = A.Fake<IOptions<RazorViewEngineOptions>>();
  A.CallTo(() => razorViewEngineOptions.Value).Returns(razorViewEngineOptionsValue);

  var applicationPartManager = new ApplicationPartManager();
  var defaultRazorViewEngineFileProviderAccessor = new DefaultRazorViewEngineFileProviderAccessor(razorViewEngineOptions);
  var loggerFactory = new LoggerFactory();

  var razorCompilationService = new RazorCompilationService(
    new DefaultRoslynCompilationService(
      new CSharpCompiler(new RazorReferenceManager(applicationPartManager, razorViewEngineOptions), razorViewEngineOptions),
      defaultRazorViewEngineFileProviderAccessor,
      razorViewEngineOptions,
      loggerFactory),
    new MvcRazorHost(new DefaultChunkTreeCache(fileProvider), new TagHelperDescriptorResolver(true)),
    defaultRazorViewEngineFileProviderAccessor,
    loggerFactory);

  var pageFactory = new DefaultRazorPageFactoryProvider(
    razorCompilationService,
    new DefaultCompilerCacheProvider(applicationPartManager, defaultRazorViewEngineFileProviderAccessor));

  var pageActivator = new RazorPageActivator(
    modelMetadataProvider,
    new UrlHelperFactory(),
    new JsonHelper(new JsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared), ArrayPool<char>.Shared),
    new DiagnosticListener("Test"),
    HtmlEncoder.Default,
    new ModelExpressionProvider(modelMetadataProvider, new ExpressionTextCache()));

  return new RazorViewEngine(pageFactory, pageActivator, HtmlEncoder.Default, razorViewEngineOptions, loggerFactory);
}

Note that CreateHtmlGenerator() is missing, take a look at https://github.com/aspnet/Mvc/blob/rel/2.0.0-preview2/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TestableHtmlGenerator.cs for how to instantiate an HtmlGenerator.

dougbu commented 7 years ago

Why is htmlHelper.Editor() internally instantiating yet another HtmlHelper object?

HtmlHelper instances are context-specific and cannot be shared as ViewContext values change.

Do I have to register my HtmlHelper with the container as well?

Yes.

Eilon commented 7 years ago

@dougbu can you take a crack at this and maybe we can come up with at least a sample of how to do this? This is somewhat tangentially related to "making testing better" in MVC. We expect plenty of people to write tag helpers and HTML helpers so hopefully there's something we can do to make this experience a bit smoother.

drauch commented 7 years ago

@By setting the TagHelper's ViewContext to an appropriate ViewContext instance (see code below), contextualizing and using the HtmlHelper in my tag helper is possible in an integrative way.

protected ViewContext CreateViewContext (IModelMetadataProvider modelMetadataProvider)
{
  var builder = new ContainerBuilder();
  builder.Register(ctx => CreateHtmlHelper(modelMetadataProvider)).As<IHtmlHelper>();
  var container = builder.Build();

  var serviceProvidersFeature = new ServiceProvidersFeature();
  serviceProvidersFeature.RequestServices = new AutofacServiceProvider(container);

  var featureCollection = new FeatureCollection();
  featureCollection.Set<IServiceProvidersFeature>(serviceProvidersFeature);

  var defaultHttpContext = new DefaultHttpContext(featureCollection);

  var actionContext = new ActionContext(defaultHttpContext, new RouteData(), new ActionDescriptor());
  var view = new NullView();
  var viewData = new ViewDataDictionary(modelMetadataProvider, new ModelStateDictionary());
  var tempData = A.Dummy<ITempDataDictionary>();
  var writer = TextWriter.Null;
  var htmlHelperOptions = new HtmlHelperOptions();

  return new ViewContext(actionContext, view, viewData, tempData, writer, htmlHelperOptions);
}

I'm using the Autofac container as IServiceProvider.

Although this works, it's been a LOT of very intense work for something which should be very easy I in order to maximize testability of tag helpers and html helpers.

drauch commented 7 years ago

Oh, just realized it is not really working 100% yet. HtmlHelper.Editor() does not yet use the correct templates ... my guess is I'm building up my model explorer in a wrong way. Does this look correct to you?

protected ModelExplorer CreateModelExplorerForType (IModelMetadataProvider modelMetadataProvider, Type modelType)
{
  var dataAnnotationsLocalizationOptions = A.Fake<IOptions<MvcDataAnnotationsLocalizationOptions>>();
  A.CallTo(() => dataAnnotationsLocalizationOptions.Value).Returns(new MvcDataAnnotationsLocalizationOptions());

  var localizationOptions = A.Fake<IOptions<LocalizationOptions>>();
  A.CallTo(() => localizationOptions.Value).Returns(new LocalizationOptions());

  var bindingMetadataProvider = new DefaultBindingMetadataProvider();
  var displayMetadataProvider = new DataAnnotationsMetadataProvider(
      dataAnnotationsLocalizationOptions,
      new ResourceManagerStringLocalizerFactory(new HostingEnvironment(), localizationOptions));
  var validationMetadataProvider = new DefaultValidationMetadataProvider();
  var detailsProvider = new DefaultCompositeMetadataDetailsProvider(
      new IMetadataDetailsProvider[] { bindingMetadataProvider, displayMetadataProvider, validationMetadataProvider });

  var details = new DefaultMetadataDetails(ModelMetadataIdentity.ForType(modelType), ModelAttributes.GetAttributesForType(modelType));

  var defaultModelMetadata = new DefaultModelMetadata(modelMetadataProvider, detailsProvider, details);
  return new ModelExplorer(modelMetadataProvider, defaultModelMetadata, null);
}
mkArtakMSFT commented 6 years ago

@DamianEdwards FYI

peterson1 commented 5 years ago

I'm stuck here as well. I can't figure out how to create an instance of IHtmlHelper for testing purposes. Mocking it didn't work. Did anyone find a way to write unit tests for custom HTML/Tag helpers?

drauch commented 5 years ago

We ultimately decided to use an IHtmlHelper interface in our tag helpers and simply mock it for our tag helper unit tests. This is somewhat bad, because we can't really test some stuff, e.g., that the correct editor template is rendered, but it is better than no tests for our tag helpers.

mkArtakMSFT commented 5 years ago

We ultimately decided to use an IHtmlHelper interface in our tag helpers and simply mock it for our tag helper unit tests. This is somewhat bad, because we can't really test some stuff, e.g., that the correct editor template is rendered, but it is better than no tests for our tag helpers.

This sounds exactly what we would suggest. For the rest we would recommend using integration tests: https://docs.microsoft.com/en-us/aspnet/core/test/integration-tests?view=aspnetcore-3.0#test-app-prerequisites