Razor Pages unit and integration testing in ASP.NET Core

By Luke Latham

ASP.NET Core supports unit and integration testing of Razor Pages apps. Testing the data access layer (DAL), page models, and integrated page components helps ensure:

  • Parts of a Razor Pages app work independently and together as a unit during app construction.
  • Classes and methods have limited scopes of responsibility.
  • Additional documentation exists on how the app should behave.
  • Regressions, which are errors brought about by updates to the code, are found during automated building and deployment.

This topic assumes that you have a basic understanding of Razor Pages apps, unit testing, and integration testing. If you're unfamiliar with Razor Pages apps or testing concepts, see the following topics:

View or download sample code (how to download)

The sample project is composed of two apps:

App Project folder Description
Message app src/RazorPagesTestingSample Allows a user to add, delete one, delete all, and analyze messages.
Test app tests/RazorPagesTestingSample.Tests Used to test the message app.
  • Unit tests: Data access layer (DAL), Index page model
  • Integration tests: Index page

The tests can be run using the built-in testing features of an IDE, such as Visual Studio. If using Visual Studio Code or the command line, execute the following command at a command prompt in the tests/RazorPagesTestingSample.Tests folder:

dotnet test

Message app organization

The message app is a simple Razor Pages message system with the following characteristics:

  • The Index page of the app (Pages/Index.cshtml and Pages/Index.cshtml.cs) provides a UI and page model methods to control the addition, deletion, and analysis of messages (average words per message).
  • A message is described by the Message class (Data/Message.cs) with two properties: Id (key) and Text (message). The Text property is required and limited to 200 characters.
  • Messages are stored using Entity Framework's in-memory database†.
  • The app contains a data access layer (DAL) in its database context class, AppDbContext (Data/AppDbContext.cs). The DAL methods are marked virtual, which allows mocking the methods for use in the tests.
  • If the database is empty on app startup, the message store is initialized with three messages. These seeded messages are also used in testing.

†The EF topic, Testing with InMemory, explains how to use an in-memory database for testing with MSTest. This topic uses the xUnit testing framework. Testing concepts and test implementations across different testing frameworks are similar but not identical.

Although the app doesn't use the repository pattern and isn't an effective example of the Unit of Work (UoW) pattern, Razor Pages supports these patterns of development. For more information, see Designing the infrastructure persistence layer, Implementing the Repository and Unit of Work Patterns in an ASP.NET MVC Application, and Testing controller logic (the sample implements the repository pattern).

Test app organization

The test app is a console app inside the tests/RazorPagesTestingSample.Tests folder:

Test app folder Description
IntegrationTests
  • IndexPageTest.cs contains the integration tests for the Index page.
  • TestFixture.cs creates the test host to test the message app.
UnitTests
  • DataAccessLayerTest.cs contains the unit tests for the DAL.
  • IndexPageTest.cs contains the unit tests for the Index page model.
Utilities Utilities.cs contains the:
  • TestingDbContextOptions method used to create new database context options for each DAL unit test so that the database is reset to its baseline condition for each test.
  • GetRequestContentAsync method used to prepare the HttpClient and content for requests that are sent to the message app during integration testing.

The test framework is xUnit. The object mocking framework is Moq. Integration tests are conducted using the ASP.NET Core Test Host.

Unit testing the data access layer (DAL)

The message app has a DAL with four methods contained in the AppDbContext class (src/RazorPagesTestingSample/Data/AppDbContext.cs). Each method has one or two unit tests in the test app.

DAL method Function
GetMessagesAsync Obtains a List<Message> from the database sorted by the Text property.
AddMessageAsync Adds a Message to the database.
DeleteAllMessagesAsync Deletes all Message entries from the database.
DeleteMessageAsync Deletes a single Message from the database by Id.

Unit tests of the DAL require DbContextOptions when creating a new AppDbContext for each test. One approach to creating the DbContextOptions for each test is to use a DbContextOptionsBuilder:

var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>()
    .UseInMemoryDatabase("InMemoryDb");

using (var db = new AppDbContext(optionsBuilder.Options))
{
    // Use the db here in the unit test.
}

The problem with this approach is that each test receives the database in whatever state the previous test left it. This can be problematic when trying to write atomic unit tests that don't interfere with each other. To force the AppDbContext to use a new database context for each test, supply a DbContextOptions instance that's based on a new service provider. The test app shows how to do this using its Utilities class method TestingDbContextOptions (tests/RazorPagesTestingSample.Tests/Utilities/Utilities.cs):

public static DbContextOptions<AppDbContext> TestingDbContextOptions()
{
    // Create a new service provider to create a new in-memory database.
    var serviceProvider = new ServiceCollection()
        .AddEntityFrameworkInMemoryDatabase()
        .BuildServiceProvider();

    // Create a new options instance using an in-memory database and 
    // IServiceProvider that the context should resolve all of its 
    // services from.
    var builder = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase("InMemoryDb")
        .UseInternalServiceProvider(serviceProvider);

    return builder.Options;
}

Using the DbContextOptions in the DAL unit tests allows each test to run atomically with a a fresh database instance:

using (var db = new AppDbContext(Utilities.TestingDbContextOptions()))
{
    // Use the db here in the unit test.
}

Each test method in the DataAccessLayerTest class (UnitTests/DataAccessLayerTest.cs) follows a similar Arrange-Act-Assert pattern:

  1. Arrange: The database is configured for the test and/or the expected outcome is defined.
  2. Act: The test is executed.
  3. Assert: Assertions are made to determine if the test result is a success.

For example, the DeleteMessageAsync method is responsible for removing a single message identified by its Id (src/RazorPagesTestingSample/Data/AppDbContext.cs):

public async virtual Task DeleteMessageAsync(int id)
{
    var message = await Messages.FindAsync(id);

    if (message != null)
    {
        Messages.Remove(message);
        await SaveChangesAsync();
    }
}

There are two tests for this method. One test checks that the method deletes a message when the message is present in the database. The other method tests that the database doesn't change if the message Id for deletion doesn't exist. The DeleteMessageAsync_MessageIsDeleted_WhenMessageIsFound method is shown below:

[Fact]
public async Task DeleteMessageAsync_MessageIsDeleted_WhenMessageIsFound()
{
    using (var db = new AppDbContext(Utilities.TestingDbContextOptions()))
    {
        // Arrange
        var seedMessages = AppDbContext.GetSeedingMessages();
        await db.AddRangeAsync(seedMessages);
        await db.SaveChangesAsync();
        var recId = 1;
        var expectedMessages = 
            seedMessages.Where(message => message.Id != recId).ToList();

        // Act
        await db.DeleteMessageAsync(recId);

        // Assert
        var actualMessages = await db.Messages.AsNoTracking().ToListAsync();
        Assert.Equal(
            expectedMessages.OrderBy(x => x.Id), 
            actualMessages.OrderBy(x => x.Id), 
            new Utilities.MessageComparer());
    }
}

First, the method performs the Arrange step, where preparation for the Act step takes place. The seeding messages are obtained and held in seedMessages. The seeding messages are saved into the database. The message with an Id of 1 is set for deletion. When the DeleteMessageAsync method is executed, the expected messages should have all of the messages except for the one with an Id of 1. The expectedMessages variable represents this expected outcome.

// Arrange
var seedMessages = AppDbContext.GetSeedingMessages();
await db.AddRangeAsync(seedMessages);
await db.SaveChangesAsync();
var recId = 1;
var expectedMessages = 
    seedMessages.Where(message => message.Id != recId).ToList();

The method acts: The DeleteMessageAsync method is executed passing in the recId of 1:

// Act
await db.DeleteMessageAsync(recId);

Finally, the method obtains the Messages from the context and compares it to the expectedMessages asserting that the two are equal:

// Assert
var actualMessages = await db.Messages.AsNoTracking().ToListAsync();
Assert.Equal(
    expectedMessages.OrderBy(m => m.Id).Select(m => m.Text), 
    actualMessages.OrderBy(m => m.Id).Select(m => m.Text));

In order to compare that the two List<Message> are the same:

  • The messages are ordered by Id.
  • Message pairs are compared on the Text property.

A similar test method, DeleteMessageAsync_NoMessageIsDeleted_WhenMessageIsNotFound checks the result of attempting to delete a message that doesn't exist. In this case, the expected messages in the database should be equal to the actual messages after the DeleteMessageAsync method is executed. There should be no change to the database's content:

[Fact]
public async Task DeleteMessageAsync_NoMessageIsDeleted_WhenMessageIsNotFound()
{
    using (var db = new AppDbContext(Utilities.TestingDbContextOptions()))
    {
        // Arrange
        var expectedMessages = AppDbContext.GetSeedingMessages();
        await db.AddRangeAsync(expectedMessages);
        await db.SaveChangesAsync();
        var recId = 4;

        // Act
        await db.DeleteMessageAsync(recId);

        // Assert
        var actualMessages = await db.Messages.AsNoTracking().ToListAsync();
        Assert.Equal(
            expectedMessages.OrderBy(m => m.Id).Select(m => m.Text), 
            actualMessages.OrderBy(m => m.Id).Select(m => m.Text));
    }
}

Unit testing the page model methods

Another set of unit tests is responsible for testing page model methods. In the message app, the Index page models are found in the IndexModel class in src/RazorPagesTestingSample/Pages/Index.cshtml.cs.

Page model method Function
OnGetAsync Obtains the messages from the DAL for the UI using the GetMessagesAsync method.
OnPostAddMessageAsync If the ModelState is valid, calls AddMessageAsync to add a message to the database.
OnPostDeleteAllMessagesAsync Calls DeleteAllMessagesAsync to delete all of the messages in the database.
OnPostDeleteMessageAsync Executes DeleteMessageAsync to delete a message with the Id specified.
OnPostAnalyzeMessagesAsync If one or more messages are in the database, calculates the average number of words per message.

The page model methods are tested using seven tests in the IndexPageTest class (tests/RazorPagesTestingSample.Tests/UnitTests/IndexPageTest.cs). The tests use the familiar Arrange-Assert-Act pattern. These tests focus on:

  • Determining if the methods follow the correct behavior when the ModelState is invalid.
  • Confirming the methods produce the correct IActionResult.
  • Checking that property value assignments are made correctly.

This group of tests often mock the methods of the DAL to produce expected data for the Act step where a page model method is executed. For example, the GetMessagesAsync method of the AppDbContext is mocked to produce output. When a page model method executes this method, the mock returns the result. The data doesn't come from the database. This creates predictable, reliable test conditions for using the DAL in the page model tests.

The OnGetAsync_PopulatesThePageModel_WithAListOfMessages test shows how the GetMessagesAsync method is mocked for the page model:

var mockAppDbContext = new Mock<AppDbContext>(optionsBuilder.Options);
var expectedMessages = AppDbContext.GetSeedingMessages();
mockAppDbContext.Setup(
    db => db.GetMessagesAsync()).Returns(Task.FromResult(expectedMessages));
var pageModel = new IndexModel(mockAppDbContext.Object);

When the OnGetAsync method is executed in the Act step, it calls the page model's GetMessagesAsync method.

Unit test Act step (tests/RazorPagesTestingSample.Tests/UnitTests/IndexPageTest.cs):

// Act
await pageModel.OnGetAsync();

IndexPage page model's OnGetAsync method (src/RazorPagesTestingSample/Pages/Index.cshtml.cs):

public async Task OnGetAsync()
{
    Messages = await _db.GetMessagesAsync();
}

The GetMessagesAsync method in the DAL doesn't return the result for this method call. The mocked version of the method returns the result.

In the Assert step, the actual messages (actualMessages) are assigned from the Messages property of the page model. A type check is also performed when the messages are assigned. The expected and actual messages are compared by their Text properties. The test asserts that the two List<Message> instances contain the same messages.

// Assert
var actualMessages = Assert.IsAssignableFrom<List<Message>>(pageModel.Messages);
Assert.Equal(
    expectedMessages.OrderBy(m => m.Id).Select(m => m.Text), 
    actualMessages.OrderBy(m => m.Id).Select(m => m.Text));

Other tests in this group create page model objects that include the DefaultHttpContext, the ModelStateDictionary, an ActionContext to establish the PageContext, a ViewDataDictionary, and a PageContext. These are useful in conducting tests. For example, the message app establishes a ModelState error with AddModelError to check that a valid PageResult is returned when OnPostAddMessageAsync is executed:

[Fact]
public async Task OnPostAddMessageAsync_ReturnsAPageResult_WhenModelStateIsInvalid()
{
    // Arrange
    var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>()
        .UseInMemoryDatabase("InMemoryDb");
    var mockAppDbContext = new Mock<AppDbContext>(optionsBuilder.Options);
    var expectedMessages = AppDbContext.GetSeedingMessages();
    mockAppDbContext.Setup(db => db.GetMessagesAsync()).Returns(Task.FromResult(expectedMessages));
    var httpContext = new DefaultHttpContext();
    var modelState = new ModelStateDictionary();
    var actionContext = new ActionContext(httpContext, new RouteData(), new PageActionDescriptor(), modelState);
    var modelMetadataProvider = new EmptyModelMetadataProvider();
    var viewData = new ViewDataDictionary(modelMetadataProvider, modelState);
    var tempData = new TempDataDictionary(httpContext, Mock.Of<ITempDataProvider>());
    var pageContext = new PageContext(actionContext)
    {
        ViewData = viewData
    };
    var pageModel = new IndexModel(mockAppDbContext.Object)
    {
        PageContext = pageContext,
        TempData = tempData,
        Url = new UrlHelper(actionContext)
    };
    pageModel.ModelState.AddModelError("Message.Text", "The Text field is required.");

    // Act
    var result = await pageModel.OnPostAddMessageAsync();

    // Assert
    Assert.IsType<PageResult>(result);
}

Integration testing the app

The integration tests focus on testing that the app's components work together. Integration tests are conducted using the ASP.NET Core Test Host. Full request-response lifecycle processing is tested. These tests assert that the page produces the correct status code and Location header, if set.

An integration testing example from the sample checks the result of requesting the Index page of the message app (tests/RazorPagesTestingSample.Tests/IntegrationTests/IndexPageTest.cs):

[Fact]
public async Task Request_ReturnsSuccess()
{
    // Act
    var response = await _client.GetAsync("/");

    // Assert
    response.EnsureSuccessStatusCode();
}

There's no Arrange step. The GetAsync method is called on the HttpClient to send a GET request to the endpoint. The test asserts that the result is a 200-OK status code.

Any POST request to the message app must satisfy the antiforgery check that's automatically made by the app's data protection antiforgery system. In order to arrange for a test's POST request, the test app must:

  1. Make a request for the page.
  2. Parse the antiforgery cookie and request validation token from the response.
  3. Make the POST request with the antiforgery cookie and request validation token in place.

The Post_AddMessageHandler_ReturnsRedirectToRoot test method:

  • Prepares a message and the HttpClient.
  • Makes a POST request to the app.
  • Checks the response is a redirect back to the Index page.

Post_AddMessageHandler_ReturnsRedirectToRoot method (tests/RazorPagesTestingSample.Tests/IntegrationTests/IndexPageTest.cs):

[Fact]
public async Task Post_AddMessageHandler_ReturnsRedirectToRoot()
{
    // Arrange
    var data = new Dictionary<string, string>()
    {
        { "Message.Text", "Test message to add." }
    };
    var content = await Utilities.GetRequestContentAsync(_client, "/", data);

    // Act
    var response = await _client.PostAsync("?handler=AddMessage", content);

    // Assert
    Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
    Assert.Equal("/", response.Headers.Location.OriginalString);
}

The GetRequestContentAsync utility method manages preparing the client with the antiforgery cookie and request verification token. Note how the method receives an IDictionary that permits the calling test method to pass in data for the request to encode along with the request verification token (tests/RazorPagesTestingSample.Tests/Utilities/Utilities.cs):

public static async Task<FormUrlEncodedContent> GetRequestContentAsync(
    HttpClient _client, string path, IDictionary<string, string> data)
{
    // Make a request for the resource.
    var getResponse = await _client.GetAsync(path);

    // Set the response's antiforgery cookie on the HttpClient.
    _client.DefaultRequestHeaders.Add("Cookie", 
        getResponse.Headers.GetValues("Set-Cookie"));

    // Obtain the request verification token from the response.
    // Any <form> element in the response contains a token, and
    // they're all the same within a single response.
    //
    // This method uses Regex to parse the element and its value
    // from the response markup. A better approach in a production
    // app would be to use an HTML parser (for example, 
    // HtmlAgilityPack: http://html-agility-pack.net/).
    var responseMarkup = await getResponse.Content.ReadAsStringAsync();
    var regExp_RequestVerificationToken = new Regex(
        "<input name=\"__RequestVerificationToken\" type=\"hidden\" value=\"(.*?)\" \\/>", 
        RegexOptions.Compiled);
    var matches = regExp_RequestVerificationToken.Matches(responseMarkup);
    // Group[1] represents the captured characters, represented
    // by (.*?) in the Regex pattern string.
    var token = matches?.FirstOrDefault().Groups[1].Value;

    // Add the token to the form data for the request.
    data.Add("__RequestVerificationToken", token);

    return new FormUrlEncodedContent(data);
}

Integration tests can also pass bad data to the app to test the app's response behavior. The message app limits message length to 200 characters (src/RazorPagesTestingSample/Data/Message.cs):

public class Message
{
    public int Id { get; set; }

    [Required]
    [DataType(DataType.Text)]
    [StringLength(200, ErrorMessage = "There's a 200 character limit on messages. Please shorten your message.")]
    public string Text { get; set; }
}

The Post_AddMessageHandler_ReturnsSuccess_WhenMessageTextTooLong test Message explicitly passes in text with 201 "X" characters. This results in a ModelState error. The POST doesn't redirect back to the Index page. It returns a 200-OK with a null Location header (tests/RazorPagesTestingSample.Tests/IntegrationTests/IndexPageTest.cs):

[Fact]
public async Task Post_AddMessageHandler_ReturnsSuccess_WhenMessageTextTooLong()
{
    // Arrange
    var data = new Dictionary<string, string>()
    {   
        { "Message.Text", new string('X', 201) }
    };
    var content = await Utilities.GetRequestContentAsync(_client, "/", data);

    // Act
    var response = await _client.PostAsync("?handler=AddMessage", content);

    // Assert
    // A ModelState failure returns to Page (200-OK) and doesn't redirect.
    response.EnsureSuccessStatusCode();
    Assert.Null(response.Headers.Location?.OriginalString);
}

See also