In the perfect scenario, developers should write both unit and integration tests for their web applications and services. The former allows you to write easy and atomic strongly-typed assertions, while the latter gives you system-wide validations at the cost of heavier configuration and string-based statements. Programmers usually love the process of testing and see the benefits, but often they are not provided with the necessary time to assert every component perfectly by their business managers.
My name is Ivaylo Kenov, and I am a guest writer at Automate the Planet. I am currently working as a CTO in a large software development team, and I am a huge fan of high-quality software development and clean code. For this reason, I created My Tested ASP.NET– a collection of open-source fluent testing libraries for ASP.NET.
In this blog post, I would like to show you how to assert ASP.NET Core web applications on a tight schedule – combining the best of both unit and integration tests. We will need xUnit, Moq, Shouldly, and My Tested ASP.NET Core MVC.
Component Under Test
The following code shows our ASP.NET Core controller - ArticlesController.
public class ArticlesController : Controller
{
private readonly IArticleService articleService;
public ArticlesController(IArticleService articleService)
=> this.articleService = articleService;
public IActionResult Create() => this.View();
public async Task<IActionResult> Create(ArticleFormModel article)
{
if (this.ModelState.IsValid)
{
await this.articleService.Add(article.Title, article.Content, this.User.GetId());
this.TempData.Add(ControllerConstants.SuccessMessage, "Article created successfully it is waiting for approval!");
return this.RedirectToAction(nameof(UsersController.Mine), ControllerConstants.Users);
}
return this.View(article);
}
}
And this is our IArticleService.
public class ArticleService : IArticleService
{
private readonly BlogDbContext db;
private readonly IDateTimeProvider dateTimeProvider;
public ArticleService(
BlogDbContext db,
IDateTimeProvider dateTimeProvider)
{
this.db = db;
this.dateTimeProvider = dateTimeProvider;
}
public async Task<int> Add(string title, string content, string userId)
{
var article = new Article
{
Title = title,
Content = content,
UserId = userId,
PublishedOn = this.dateTimeProvider.Now();
};
this.db.Articles.Add(article);
await this.db.SaveChangesAsync();
return article.Id;
}
}
The IDateTimeProvider interface is a simple service providing the current UTC time:
public class DateTimeProvider : IDateTimeProvider
{
public DateTime Now() => DateTime.UtcNow;
}
BlogDbContext is just a typical Entity Framework Core DbContext class, which allows us to access our data layer.
The code above is taken from this sample application.
Preparing Our Test Project
To get started with our tests, we can create a xUnit Test Project in Visual Studio, and install MyTested.AspNetCore.Mvc.Universe from NuGet.
Then create a TestStartup class at the root of the test project to register the dependency injection services, which will be used by all test cases in the assembly. This way we will not repeat our code thought our test cases. A quick solution is to inherit from the web project’s Startup class. By default MyTested.AspNetCore.Mvc replaces all ASP.NET Core services with ready to be used mocks. For example, our BlogDbContext database will be automatically replaced with ready for tests in-memory scoped database. We only need to handle our custom services.
For example, the IDateTimeProvider.
// The TestStartup class provides all globally registered services for our tests.
public class TestStartup : Startup
{
public TestStartup(IConfiguration configuration)
: base(configuration)
{
}
public void ConfigureTestServices(IServiceCollection services)
{
// Register all web application services.
base.ConfigureServices(services);
// Replace all custom services which need mocks.
services.ReplaceTransient<IDateTimeProvider>(_ => DateTimeProviderMock.Create);
}
}
And this is our DateTimeProviderMock class.
public class DateTimeProviderMock
{
public static IDateTimeProvider Create
{
get
{
var moq = new Mock<IDateTimeProvider>();
moq.Setup(m => m.Now()).Returns(new DateTime(1, 1, 1));
return moq.Object;
}
}
}
We are now ready for our test cases. As you can see, we do not need to mock every single service in our application.
Route Tests
First, let’s assert the routes in our controller. The testing library will get your route configuration from the TestStartup class.
The Get Create action is pretty straightforward.
public void GetCreateShouldBeRoutedCorrectly()
=> MyRouting // Start a route test.
.Configuration() // Use the globally registered configuration.
.ShouldMap(request => request // Provide the request data.
.WithLocation("/Articles/Create") // Set the URL of the Get request.
.WithUser()) // Specify that the route needs an authenticated user.
.To<ArticlesController>(c => c.Create()); // Map the route to the specific action and controller.
The Post Create action needs a little bit more data.
[InlineData("Test Article", "Test Article Content")]
public void PostCreateShouldBeRoutedCorrectly(string title, string content)
=> MyRouting // Start a route test.
.Configuration() // Use the globally registered configuration.
.ShouldMap(request => request // Provide the request data.
.WithMethod(HttpMethod.Post) // Set the method of the request.
.WithLocation("/Articles/Create") // Set the URL of the Post request.
.WithFormFields(new // Add form field data to the request.
{
Title = title,
Content = content
})
.WithUser() // Specify that the route needs an authenticated user.
.WithAntiForgeryToken()) // Add an Anti-Forgery token, if needed.
.To<ArticlesController>(c => c.Create(new ArticleFormModel // Map the route to the specific route values.
{
Title = title,
Content = content
}));
Controller Tests
Now, let’s assert the controller logic.
The Get Create method needs two tests – one for the action attributes, and one for the view result. We can combine it in a single assertion chain.
public void CreateGetShouldHaveRestrictionsForHttpGetOnlyAndAuthorizedUsersAndShouldReturnView()
=> MyController<ArticlesController> // Specify the controller under test.
.Instance() // Create it from the globally registered services.
.Calling(c => c.Create()) // Specify the action under test.
.ShouldHave()
.ActionAttributes(attrs => attrs // Assert action attributes.
.RestrictingForHttpMethod(HttpMethod.Get)
.RestrictingForAuthorizedRequests())
.AndAlso() // Provide additional assertions.
.ShouldReturn()
.View(); // Validate the view result.
The Post Create method has two execution flows.
If the model state is not valid, we need to assert the returned view result and its model.
public void CreatePostShouldReturnViewWithSameModelWhenInvalidModelState()
=> MyController<ArticlesController> // Specify the controller under test.
.Instance() // Create it from the globally registered services.
.Calling(c => c.Create(With.Default<ArticleFormModel>())) // Specify the action under test and provide empty model.
.ShouldHave()
.InvalidModelState() // Assert invalid model state.
.AndAlso() // Provide additional assertions.
.ShouldReturn()
.View(With.Default<ArticleFormModel>()); // Validate the view result and the same empty model.
And if the model state is valid, we need to assert the database for an added article, the TempData for a success message and finally, the returned redirect to action result.
[InlineData("Article Title", "Article Content")]
public void CreatePostShouldSaveArticleSetModelStateMessageAndRedirectWhenValidModelState(string title, string content)
=> MyController<ArticlesController> // Specify the controller under test.
.Instance() // Create it from the globally registered services.
.Calling(c => c.Create(new ArticleFormModel // Specify the action under test and provide valid model.
{
Title = title,
Content = content
}))
.ShouldHave()
.ValidModelState() // Assert valid model state.
.AndAlso() // Provide additional assertions.
.ShouldHave()
.Data(data => data // Assert the database.
.WithSet<Article>(set => // Assert the Article database set.
{
set.ShouldNotBeEmpty();
set.SingleOrDefault(a => a.Title == title).ShouldNotBeNull(); // Validate our article is saved.
}))
.AndAlso() // Provide additional assertions.
.ShouldHave()
.TempData(tempData => tempData // Validate the TempData success message..
.ContainingEntryWithKey(ControllerConstants.SuccessMessage))
.AndAlso() // Provide additional assertions.
.ShouldReturn()
.Redirect(redirect => redirect // Validate redirect result.
.To<UsersController>(c => c.Mine())); // Validate correct redirect to action.
Don’t forget the action attributes.
public void CreatePostShouldHaveRestrictionsForHttpPostOnlyAndAuthorizedUsers()
=> MyController<ArticlesController> // Specify the controller under test.
.Instance() // Create it from the globally registered services.
.Calling(c => c.Create(With.Empty<ArticleFormModel>())) // Specify the action under test and provide empty model.
.ShouldHave()
.ActionAttributes(attrs => attrs // Assert action attributes.
.RestrictingForHttpMethod(HttpMethod.Post)
.RestrictingForAuthorizedRequests());
The beauty of these tests is that they assert all layers of our application – the database, the services, and the controller. We hit three rabbits with one shot. And the shot is fired in a fluent, readable, and strongly-typed manner**.**
Pipeline Tests
If you are using My Tested ASP.NET Core MVC for .NET Core 3 or later, you can combine the route and controller tests into a single pipeline test.
public void GetCreateShouldShouldReturnView()
=> MyMvc
.Pipeline() // Start a pipeline test.
.ShouldMap(request => request // Provide the request data.
.WithLocation("/Articles/Create")
.WithUser())
.To<ArticlesController>(c => c.Create()) // Validate the route values.
.Which() // Provide additional assertions on the controller.
.ShouldReturn()
.View(); // Validate the view result.
Conclusion
My Tested ASP.NET Core MVC has a very powerful fluent API, providing more than 500 assertion methods – caching, session, authentication, just name it! It sure helps you speed up the testing process in your web development team!
Happy testing, and if you want to read more about the open-source library – this tutorial is the perfect place to start!
