ASP.NET CORE MVC - Testing the MVC Application
Testing the MVC Application with xUnit
Create a separate automated test project, learn Arrange–Act–Assert, test model validation and controller behaviour, and introduce integration testing with ASP.NET Core's test host.
By the end of Part 18, learners can run automated tests with dotnet test, write xUnit tests, test controller results using an isolated EF Core database, and understand the difference between unit-style and integration tests.
1. Verify the Part 17 Application
Open the Xubuntu terminal:
cd ~/aspnet-mvc-tutorial/ProductManagement
dotnet build
Do not continue until the application builds successfully.
2. Why Automated Testing?
Manual browser testing remains useful, but it becomes slow as an application grows. Automated tests allow important behaviour to be checked repeatedly.
A test failure does not automatically mean the test is correct and the application is wrong. Tests themselves are code and must also be maintained.
3. Understand the Three Testing Levels
| Type | Purpose |
|---|---|
| Unit test | Tests a small piece of logic in isolation. |
| Controller/service test | Tests application behaviour with controlled dependencies. |
| Integration test | Exercises multiple ASP.NET Core components together through a test host. |
4. Create a Separate xUnit Project
Move to the tutorial root, not inside the MVC project:
cd ~/aspnet-mvc-tutorial
dotnet new xunit -n ProductManagement.Tests
The folders should now resemble:
aspnet-mvc-tutorial/
├── ProductManagement/
└── ProductManagement.Tests/
5. Reference the MVC Project
Enter the new test project:
cd ~/aspnet-mvc-tutorial/ProductManagement.Tests
dotnet add reference ../ProductManagement/ProductManagement.csproj
This lets the test project use classes from the application.
6. Add the EF Core In-Memory Provider
Still inside ProductManagement.Tests, run:
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.0
The In-Memory provider is useful here for lightweight controller tests. It is not a substitute for every relational-database integration test because it does not behave exactly like SQLite.
7. Run the Template Test
dotnet test
The xUnit template contains a sample UnitTest1.cs. Remove it after confirming the test project works:
rm UnitTest1.cs
8. Arrange–Act–Assert
A common test structure is:
// Arrange
// Prepare objects and input.
// Act
// Execute the behaviour.
// Assert
// Verify the result.
9. Create Product Validation Tests
Create:
touch ProductValidationTests.cs
code ProductValidationTests.cs
Add:
using System.ComponentModel.DataAnnotations;
using ProductManagement.ViewModels;
namespace ProductManagement.Tests;
public class ProductValidationTests
{
private static List<ValidationResult> Validate(
ProductFormViewModel model)
{
var results = new List<ValidationResult>();
var context = new ValidationContext(model);
Validator.TryValidateObject(
model,
context,
results,
validateAllProperties: true);
return results;
}
[Fact]
public void ValidProduct_HasNoValidationErrors()
{
// Arrange
var model = new ProductFormViewModel
{
Name = "Keyboard",
Description = "Mechanical keyboard",
Price = 150m,
Quantity = 10
};
// Act
var results = Validate(model);
// Assert
Assert.Empty(results);
}
[Fact]
public void EmptyName_HasValidationError()
{
var model = new ProductFormViewModel
{
Name = "",
Price = 150m,
Quantity = 10
};
var results = Validate(model);
Assert.Contains(
results,
r => r.MemberNames.Contains("Name"));
}
[Fact]
public void InvalidPrice_HasValidationError()
{
var model = new ProductFormViewModel
{
Name = "Keyboard",
Price = 0m,
Quantity = 10
};
var results = Validate(model);
Assert.Contains(
results,
r => r.MemberNames.Contains("Price"));
}
}
10. Run Only the Validation Tests
dotnet test --filter ProductValidationTests
The tests use the Data Annotation rules already defined in ProductFormViewModel.
11. Create API Controller Tests
We will test the simpler ProductsApiController from Part 17. Create:
touch ProductsApiControllerTests.cs
code ProductsApiControllerTests.cs
Add:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Controllers;
using ProductManagement.Data;
using ProductManagement.Models;
namespace ProductManagement.Tests;
public class ProductsApiControllerTests
{
private static ApplicationDbContext CreateContext()
{
var options =
new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(
Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task GetById_MissingProduct_ReturnsNotFound()
{
// Arrange
await using var context = CreateContext();
var controller =
new ProductsApiController(context);
// Act
var result = await controller.GetById(999);
// Assert
Assert.IsType<NotFoundResult>(
result.Result);
}
[Fact]
public async Task GetById_ExistingProduct_ReturnsOk()
{
// Arrange
await using var context = CreateContext();
context.Products.Add(new Product
{
Id = 1,
Name = "Mouse",
Price = 50m,
Quantity = 5
});
await context.SaveChangesAsync();
var controller =
new ProductsApiController(context);
// Act
var result = await controller.GetById(1);
// Assert
var okResult =
Assert.IsType<OkObjectResult>(
result.Result);
Assert.NotNull(okResult.Value);
}
}
12. Why Use a Unique In-Memory Database?
This line creates a different database name for each test:
Guid.NewGuid().ToString()
It prevents one test's Product records from leaking into another test.
13. Run the Controller Tests
dotnet test --filter ProductsApiControllerTests
14. Test the Complete Test Project
dotnet test
A successful result should report all current tests as passed.
15. Introduce Integration Testing
Controller tests call controller methods directly. An integration test can start the ASP.NET Core application in a test host and send an HTTP request through the application pipeline.
16. Make Program Accessible to Integration Tests
Open the application file:
code ~/aspnet-mvc-tutorial/ProductManagement/Program.cs
Go to the very bottom of Program.cs, immediately after:
app.Run();
Add:
public partial class Program { }
This gives the test project an accessible Program type for WebApplicationFactory<Program>.
17. Add the MVC Testing Package
Return to the test project:
cd ~/aspnet-mvc-tutorial/ProductManagement.Tests
dotnet add package Microsoft.AspNetCore.Mvc.Testing --version 8.0.0
18. Create a Basic Integration Test
Create:
touch ApplicationIntegrationTests.cs
code ApplicationIntegrationTests.cs
Add:
using Microsoft.AspNetCore.Mvc.Testing;
namespace ProductManagement.Tests;
public class ApplicationIntegrationTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program>
_factory;
public ApplicationIntegrationTests(
WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task HomePage_ReturnsSuccess()
{
// Arrange
var client = _factory.CreateClient();
// Act
var response = await client.GetAsync("/");
// Assert
response.EnsureSuccessStatusCode();
}
}
19. Run the Integration Test
dotnet test --filter ApplicationIntegrationTests
The application startup currently includes Identity role seeding and uses the configured database. More advanced integration tests normally replace production services and databases with test-specific equivalents. This first integration test is intentionally small.
20. Run All Tests from the Tutorial Root
Create a solution so both projects can be managed together:
cd ~/aspnet-mvc-tutorial
dotnet new sln -n ProductManagementTutorial
dotnet sln ProductManagementTutorial.sln add ProductManagement/ProductManagement.csproj
dotnet sln ProductManagementTutorial.sln add ProductManagement.Tests/ProductManagement.Tests.csproj
dotnet test ProductManagementTutorial.sln
21. What Should Be Tested?
| Behaviour | Example test |
|---|---|
| Validation | Price cannot be zero or negative. |
| Missing data | Unknown Product returns 404. |
| Successful retrieval | Existing Product returns 200. |
| Authorization | Non-owner cannot modify another user's Product. |
| Routing/pipeline | Home page returns a successful HTTP response. |
22. Authorization-Sensitive Testing
Part 12 and Part 13 contain important ownership and Admin rules. These deserve automated tests, but they require constructing authenticated principals and Identity dependencies. The key behaviours to test are:
Normal user + own Product → allowed
Normal user + another Product → forbidden
Admin + another Product → allowed
Anonymous user + protected page → authentication required
These are excellent candidates for the next stage of a real project's test suite.
23. No Migration Is Required
Part 18 adds a test project and one Program.cs declaration. It does not change the EF Core schema, so no migration is required.
24. Troubleshooting
UseInMemoryDatabase cannot be found
Confirm Microsoft.EntityFrameworkCore.InMemory version 8.x is installed in ProductManagement.Tests and the file imports Microsoft.EntityFrameworkCore.
WebApplicationFactory cannot be found
Install Microsoft.AspNetCore.Mvc.Testing version 8.x in the test project.
Program is inaccessible
Open the application's Program.cs and confirm public partial class Program {{ }} appears after app.Run();.
A validation test fails
Compare the expected test rule with the actual Data Annotations in ProductFormViewModel.cs. The test should reflect the application's real validation rules.
25. Hands-On Exercise
- Run all tests.
- Temporarily change a validation expectation and observe a failed test.
- Restore it.
- Add a test for a negative Quantity.
- Add another GET-by-ID test using a different Product.
- Run
dotnet testagain.
26. Knowledge Check
- What is xUnit?
- What does Arrange–Act–Assert mean?
- Why should tests be repeatable?
- Why use a separate test project?
- What is the purpose of the EF Core In-Memory provider here?
- Why is it not identical to SQLite?
- What does
Assert.IsTypeverify? - What is an integration test?
- What does
WebApplicationFactoryprovide? - Why does Part 18 not need a migration?
27. Part 18 Summary
- created an xUnit test project;
- referenced the MVC application;
- learned Arrange–Act–Assert;
- tested Data Annotation validation;
- used an isolated EF Core In-Memory database;
- tested API controller results;
- introduced
WebApplicationFactory; - created a basic HTTP integration test;
- created a solution containing both projects; and
- identified ownership and role authorization as important future test cases.
Appendix — Full Code for Final Verification
Appendix A — ProductValidationTests.cs
using System.ComponentModel.DataAnnotations;
using ProductManagement.ViewModels;
namespace ProductManagement.Tests;
public class ProductValidationTests
{
private static List<ValidationResult> Validate(ProductFormViewModel model)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model),
results, validateAllProperties: true);
return results;
}
[Fact]
public void ValidProduct_HasNoValidationErrors()
{
var model = new ProductFormViewModel
{
Name = "Keyboard",
Description = "Mechanical keyboard",
Price = 150m,
Quantity = 10
};
Assert.Empty(Validate(model));
}
[Fact]
public void EmptyName_HasValidationError()
{
var model = new ProductFormViewModel
{
Name = "",
Price = 150m,
Quantity = 10
};
Assert.Contains(Validate(model),
r => r.MemberNames.Contains("Name"));
}
[Fact]
public void InvalidPrice_HasValidationError()
{
var model = new ProductFormViewModel
{
Name = "Keyboard",
Price = 0m,
Quantity = 10
};
Assert.Contains(Validate(model),
r => r.MemberNames.Contains("Price"));
}
}
Appendix B — ProductsApiControllerTests.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Controllers;
using ProductManagement.Data;
using ProductManagement.Models;
namespace ProductManagement.Tests;
public class ProductsApiControllerTests
{
private static ApplicationDbContext CreateContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
[Fact]
public async Task GetById_MissingProduct_ReturnsNotFound()
{
await using var context = CreateContext();
var controller = new ProductsApiController(context);
var result = await controller.GetById(999);
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public async Task GetById_ExistingProduct_ReturnsOk()
{
await using var context = CreateContext();
context.Products.Add(new Product
{
Id = 1,
Name = "Mouse",
Price = 50m,
Quantity = 5
});
await context.SaveChangesAsync();
var controller = new ProductsApiController(context);
var result = await controller.GetById(1);
var okResult = Assert.IsType<OkObjectResult>(result.Result);
Assert.NotNull(okResult.Value);
}
}
Appendix C — ApplicationIntegrationTests.cs
using Microsoft.AspNetCore.Mvc.Testing;
namespace ProductManagement.Tests;
public class ApplicationIntegrationTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ApplicationIntegrationTests(
WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task HomePage_ReturnsSuccess()
{
var client = _factory.CreateClient();
var response = await client.GetAsync("/");
response.EnsureSuccessStatusCode();
}
}
Appendix D — Program.cs Final Addition
At the very end of the application's Program.cs:
app.Run();
public partial class Program { }
Appendix E — Final Commands
cd ~/aspnet-mvc-tutorial
dotnet build ProductManagementTutorial.sln
dotnet test ProductManagementTutorial.sln
If the MVC application builds, the test project builds, validation/controller tests pass, and the basic integration test can request the home page successfully, Part 18 is complete.
Part 19 moves into the final stage of the series: production configuration, environments, secrets, HTTPS, database considerations and dotnet publish.