By Luke Latham
ASP.NET Core supports unit tests of Razor Pages apps. Tests of the data access layer (DAL) and page models help 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 and unit tests. If you’re unfamiliar with Razor Pages apps or test concepts, see the following topics:
The sample project is composed of two apps:
|Message app||src/RazorPagesTestSample||Allows a user to add, delete one, delete all, and analyze messages.|
|Test app||tests/RazorPagesTestSample.Tests||Used to unit test the message app: Data access layer (DAL) and Index page model.|
The tests can be run using the built-in test 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/RazorPagesTestSample.Tests folder:
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
Messageclass (Data/Message.cs) with two properties:
Textproperty 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 tests.
†The EF topic, Test with InMemory, explains how to use an in-memory database for tests with MSTest. This topic uses the xUnit test framework. Test concepts and test implementations across different test 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 Test controller logic (the sample implements the repository pattern).
Test app organization
The test app is a console app inside the tests/RazorPagesTestSample.Tests folder.
|Test app folder||Description|
Unit tests of the data access layer (DAL)
The message app has a DAL with four methods contained in the
AppDbContext class (src/RazorPagesTestSample/Data/AppDbContext.cs). Each method has one or two unit tests in the test app.
||Deletes a single
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
public static DbContextOptions<AppDbContext> TestDbContextOptions() // 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;
DbContextOptions in the DAL unit tests allows each test to run atomically with 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:
- Arrange: The database is configured for the test and/or the expected outcome is defined.
- Act: The test is executed.
- 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
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.TestDbContextOptions())) // 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
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
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
// 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
- Message pairs are compared on the
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.TestDbContextOptions())) // 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 tests of the page model methods
Another set of unit tests is responsible for tests of page model methods. In the message app, the Index page models are found in the
IndexModel class in src/RazorPagesTestSample/Pages/Index.cshtml.cs.
|Page model method||Function|
||Obtains the messages from the DAL for the UI using the
||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
IndexPageTests class (tests/RazorPagesTestSample.Tests/UnitTests/IndexPageTests.cs). The tests use the familiar Arrange-Assert-Act pattern. These tests focus on:
- Determining if the methods follow the correct behavior when the
- Confirming the methods produce the correct
- 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.
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);
OnGetAsync method is executed in the Act step, it calls the page model’s
Unit test Act step (tests/RazorPagesTestSample.Tests/UnitTests/IndexPageTests.cs):
// Act await pageModel.OnGetAsync();
IndexPage page model’s
OnGetAsync method (src/RazorPagesTestSample/Pages/Index.cshtml.cs):
public async Task OnGetAsync() Messages = await _db.GetMessagesAsync();
GetMessagesAsync method in the DAL doesn’t return the result for this method call. The mocked version of the method returns the result.
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
ActionContext to establish the
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);