ASP.NET CORE MVC - Testing the MVC Application

ASP.NET CORE MVC TUTORIAL SERIES · PART 18

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.

Objective

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.

Change code ↓ dotnet test ↓ Tests execute automatically ↓ Pass ✓ or Fail ✗

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

TypePurpose
Unit testTests a small piece of logic in isolation.
Controller/service testTests application behaviour with controlled dependencies.
Integration testExercises 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.
ARRANGE Prepare ↓ ACT Execute ↓ ASSERT Verify

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.

Test ↓ HttpClient ↓ ASP.NET Core test host ↓ Routing / Middleware / Controller ↓ HTTP response

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
Important

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?

BehaviourExample test
ValidationPrice cannot be zero or negative.
Missing dataUnknown Product returns 404.
Successful retrievalExisting Product returns 200.
AuthorizationNon-owner cannot modify another user's Product.
Routing/pipelineHome 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

Database note

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

  1. Run all tests.
  2. Temporarily change a validation expectation and observe a failed test.
  3. Restore it.
  4. Add a test for a negative Quantity.
  5. Add another GET-by-ID test using a different Product.
  6. Run dotnet test again.

26. Knowledge Check

  1. What is xUnit?
  2. What does Arrange–Act–Assert mean?
  3. Why should tests be repeatable?
  4. Why use a separate test project?
  5. What is the purpose of the EF Core In-Memory provider here?
  6. Why is it not identical to SQLite?
  7. What does Assert.IsType verify?
  8. What is an integration test?
  9. What does WebApplicationFactory provide?
  10. 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
Final Part 18 checkpoint

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.

Next: Part 19 — Preparing for Production

Part 19 moves into the final stage of the series: production configuration, environments, secrets, HTTPS, database considerations and dotnet publish.