How To Test Your ASP.NET Core MVC Stack
Last time, we saw how to implement behavior testing in ASP.NET Core with a scenario approach. Thanks to this, we can now cover the code of a complete business scenario with a single test ! Easy as pie :)
The demonstration illustrated a behavior test which involved a web API. The test workflow can be illustrated as follow:
API stack vs MVC stack
With an API, the test logic is quite simple. When your scenario is completed, you can simply check the values stored in your data repository and the HTTP response message (status code + optional response content of JSON type or other).
But when working with a MVC stack, in some cases, the test logic is not as obvious. Indeed, the difference is about the returned value you want to check. With an API, the business data are returned as the content of the HTTP response message and is easily testable. A Web MVC front-end application is a bit different. The content of the message does not contain only business data but also formatted data inside an HTML layout.
To be clear:
We do not want to test HTML content ;) that is to say, we do not want to test the View part of our MVC stack, we want to test the Model part.
Let's see how to do that!
The View's Model repository
The main idea of the solution is quite simple:
What about saving the view's model in a dedicated repository and access to this repository from our test?
Sounds good, let's see the implementation. One solution is to implement the repository as an in-memory dictionary which will be hydrated by an ASP.NET filter when the action result is executed. Thus, our tests will be able to read the repository and test the content, according to the current scenario:
Implementation
The model repository can be implemented as a dictionary where the key is represented by the model type and the value by the model value:
public class ViewModelRepository
{
private readonly ConcurrentDictionary<Type, object> repository = new ConcurrentDictionary<Type, object>();
public void Add<TModel>(TModel model) where TModel : class
{
if (!this.repository.TryAdd(model.GetType(), model))
{
throw new ArgumentException($"The model {model.GetType().Name} is already registered");
}
}
public TModel Get<TModel>() where TModel : class
{
object value;
this.repository.TryGetValue(typeof(TModel), out value);
return value as TModel;
}
}
Filter
We want to save the model returned by our controller with the view. Cherry on top, let's do that automatically using an ASP.NET Result Filter called after an action result execution:
public class SaveViewModelResultFilter : IResultFilter
{
private ViewModelRepository modelRepository;
public SaveViewModelResultFilter(ViewModelRepository modelRepository)
{
this.modelRepository = modelRepository;
}
public void OnResultExecuted(ResultExecutedContext context)
{
object model = null;
var viewResult = context.Result as ViewResult;
if (viewResult != null)
{
model = viewResult.Model;
}
else
{
PartialViewResult partialViewResult = context.Result as PartialViewResult;
if (partialViewResult != null)
{
model = partialViewResult.ViewData.Model;
}
}
if (model != null)
{
this.modelRepository.Add(model);
}
}
public void OnResultExecuting(ResultExecutingContext context)
{
}
}
Configuration
One last step, let's see how to configure our application to use that filter within a test context.
There is a little tricky thing here. Indeed, we want to use the filter exclusively from a test but not in production mode. Moreover, we don't want to override any application MVC option from our test configuration. So I suggest to define a filters collection from our test configuration, configure our application to read the filters from this collection and add them to the ASP.NET Core MVC filters.
Let's define a specific type for our collection:
public class FilterInceptionCollection : Collection<Type>
{
}
Now let's add an instance of this collection to the DI container and configure ASP.NET Core MVC to use these filters:
namespace Microsoft.Extensions.DependencyInjection
{
public static class FilterExtensions
{
public static IMvcBuilder AddFilterCollection(this IMvcBuilder mvcBuilder)
{
var filterCollection = new FilterInceptionCollection();
mvcBuilder.Services.Add(ServiceDescriptor.Singleton(filterCollection));
mvcBuilder.AddMvcOptions(options =>
{
filterCollection.ToList().ForEach(ft => options.Filters.Add(ft));
});
return mvcBuilder;
}
}
}
Finally let's define a helper method to add a filter from our test context:
namespace Microsoft.AspNetCore.Builder
{
public static class FilterExtensions
{
public static FilterInceptionCollection AddTestFilter<TFilterType>(this IApplicationBuilder app)
where TFilterType : IFilterMetadata
{
var filterCollection = app.ApplicationServices.GetService(typeof(FilterInceptionCollection)) as FilterInceptionCollection;
if (filterCollection != null)
{
filterCollection.Add(typeof(TFilterType));
}
return filterCollection;
}
}
}
And voilà ! You can now register the filter collection from your main startup class:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().AddFilterCollection();
..
And add the ASP.NET test filter inside your test configuration. If you use the IStartupConfigurationService that we saw in my last post, you can do it from the Configure method:
public virtual void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
...
app.AddTestFilter<SaveViewModelResultFilter>();
...
Sample
It's time to play with our new toy :) Here is a sample of what a behavior test which target an MVC Web application could look like (once again if you are not familiar with the IStartupConfigurationService, please take a look to my last post):
[TestMethod]
public void TestScenario()
{
Data expectedModel = CreateExpectedScenarioData();
var webHostBuilder = new WebHostBuilder();
webHostBuilder.UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "[PATH_TO_WEB_APPLICATION]"))
.ConfigureServices(s => s.AddSingleton<IStartupConfigurationService, TestStartupConfigurationService<[DBCONTEXT_TYPE]>>())
.UseStartup<Startup>();
var testServer = new TestServer(webHostBuilder);
var response = testServer.CreateClient().GetAsync("/data").Result;
var model = testServer.Host
.Services
.GetRequiredService<ViewModelRepository>()
.Get<Data>();
Assert.AreEqual(expectedModel.Prop1, model.Prop1);
Assert.AreEqual(expectedModel.Prop2, model.Prop2);
...
}
Content Root
One particular thing here is the call to the UseContentRoot method from the WebHostBuilder class. Indeed, the content root is the root path from where ASP.NET Core will look for content files such as MVC Views. The default value is the Web project root. As a consequence, we need to override it, because within the test context, the content root will take the value of the Test project and not the Web project root path.
MVC Dependency Context
Another step is required to test the MVC part. Indeed when you compile an ASP.NET Core project containing views, some dependency context files (.deps) are generated and are used by the Razor engine to resolve references.
Since our Unit Test Project references our ASP.NET Core application and we want to test our MVC part, we have to copy the .deps files to the Unit Test Project output path.
Here is how to do that, add the following target to your Unit Test Project csproj file:
<Target Name="CopyDepsFiles" AfterTargets="Build" Condition="'$(TargetFramework)'!=''">
<ItemGroup>
<DepsFilePaths Include="$([System.IO.Path]::ChangeExtension('%(_ResolvedProjectReferencePaths.FullPath)', '.deps.json'))" />
</ItemGroup>
<Copy SourceFiles="%(DepsFilePaths.FullPath)" DestinationFolder="$(OutputPath)" Condition="Exists('%(DepsFilePaths.FullPath)')" />
</Target>
This command will look for all files with extension .deps.json in the referenced projects full path and copy them to the output path.
To Be Continued
We have enhanced our skills in testing an ASP.NET Core applications by successfully covering the MVC stack!
High Five o/' '\o :)
Nevertheless, there remains an obstacle on our road to the Holy Grail, its name ? Anti Forgery Token.
Indeed, this last part will prevent us from testing our MVC Post methods. But don't worry, I have a pretty stylish workaround. But for the time being, let's take a break, make yourself comfortable and I look forward to seeing you in the next article for a complete review of this issue.
See you next time !
Discover also our other posts about testing ASP.NET Core apps.