Unit testing

Unit tests are small, quick tests that check the behavior of a single method or chunk of logic. Instead of testing a whole group of classes, or the entire system (as integration tests do), unit tests rely on mocking or replacing the objects the method-under-test depends on.

For example, the TodoController has two dependencies: an ITodoItemService and the UserManager. The TodoItemService, in turn, depends on the ApplicationDbContext. (The idea that you can draw a line from TodoController -> TodoItemService -> ApplicationDbContext is called a dependency graph).

When the application runs normally, the ASP.NET Core dependency injection system injects each of those objects into the dependency graph when the TodoController or the TodoItemService is created.

When you write a unit test, on the other hand, you’ll manually inject mock or test-only versions of those dependencies. This means you can isolate just the logic in the class or method you are testing. (If you’re testing a service, you don’t want to also be accidentally writing to your database!)

Create a test project

It’s a common practice to create a separate project for your tests, to keep things clean and organized. The new test project should live in a directory that’s next to (not inside) your main project’s directory.

If you’re currently in your project directory, cd up one level. (This directory will also be called AspNetCoreTodo). Then use these commands to scaffold a new test project:

  1. mkdir AspNetCoreTodo.UnitTests
  2. cd AspNetCoreTodo.UnitTests
  3. dotnet new xunit

xUnit.NET is a popular test framework for .NET code that can be used to write both unit and integration tests. Like everything else, it’s a set of NuGet packages that can be installed in any project. The dotnet new xunit template already includes everything you need.

Your directory structure should now look like this:

  1. AspNetCoreTodo/
  2. AspNetCoreTodo/
  3. AspNetCoreTodo.csproj
  4. Controllers/
  5. (etc...)
  6. AspNetCoreTodo.UnitTests/
  7. AspNetCoreTodo.UnitTests.csproj

Since the test project will use the classes defined in your main project, you’ll need to add a reference to the main project:

  1. dotnet add reference ../AspNetCoreTodo/AspNetCoreTodo.csproj

Delete the UnitTest1.cs file that’s automatically created. You’re ready to write your first test.

Write a service test

Take a look at the logic in the AddItemAsync method of the TodoItemService:

  1. public async Task<bool> AddItemAsync(NewTodoItem newItem, ApplicationUser user)
  2. {
  3. var entity = new TodoItem
  4. {
  5. Id = Guid.NewGuid(),
  6. OwnerId = user.Id,
  7. IsDone = false,
  8. Title = newItem.Title,
  9. DueAt = DateTimeOffset.Now.AddDays(3)
  10. };
  11. _context.Items.Add(entity);
  12. var saveResult = await _context.SaveChangesAsync();
  13. return saveResult == 1;
  14. }

This method makes a number of decisions or assumptions about the new item before it actually saves it to the database:

  • The OwnerId property should be set to the user’s ID
  • New items should always be incomplete (IsDone = false)
  • The title of the new item should be copied from newItem.Title
  • New items should always be due 3 days from now

These types of decisions made by your code are called business logic, because it’s logic that relates to the purpose or “business” of your application. Other examples of business logic include things like calculating a total cost based on product prices and tax rates, or checking whether a player has enough points to level up in a game.

These decisions make sense, and it also makes sense to have a test that ensures that this logic doesn’t change down the road. (Imagine if you or someone else refactored the AddItemAsync method and forgot about one of these assumptions. It might be unlikely when your services are simple, but it becomes important to have automated checks as your application becomes more complicated.)

To write a unit test that will verify the logic in the TodoItemService, create a new class in your test project:

AspNetCoreTodo.UnitTests/TodoItemServiceShould.cs

  1. using System;
  2. using System.Threading.Tasks;
  3. using AspNetCoreTodo.Data;
  4. using AspNetCoreTodo.Models;
  5. using AspNetCoreTodo.Services;
  6. using Microsoft.EntityFrameworkCore;
  7. using Xunit;
  8. namespace AspNetCoreTodo.UnitTests
  9. {
  10. public class TodoItemServiceShould
  11. {
  12. [Fact]
  13. public async Task AddNewItem()
  14. {
  15. // ...
  16. }
  17. }
  18. }

The [Fact] attribute comes from the xUnit.NET package, and it marks this method as a test method.

There are many different ways of naming and organizing tests, all with different pros and cons. I like postfixing my test classes with Should to create a readable sentence with the test method name, but feel free to use your own style!

The TodoItemService requires an ApplicationDbContext, which is normally connected to your development or live database. You won’t want to use that for tests. Instead, you can use Entity Framework Core’s in-memory database provider in your test code. Since the entire database exists in memory, it’s wiped out every time the test is restarted. And, since it’s a proper Entity Framework Core provider, the TodoItemService won’t know the difference!

Use a DbContextOptionsBuilder to configure the in-memory database provider, and then make a call to AddItem:

  1. var options = new DbContextOptionsBuilder<ApplicationDbContext>()
  2. .UseInMemoryDatabase(databaseName: "Test_AddNewItem").Options;
  3. // Set up a context (connection to the DB) for writing
  4. using (var inMemoryContext = new ApplicationDbContext(options))
  5. {
  6. var service = new TodoItemService(inMemoryContext);
  7. var fakeUser = new ApplicationUser
  8. {
  9. Id = "fake-000",
  10. UserName = "fake@fake"
  11. };
  12. await service.AddItemAsync(new NewTodoItem { Title = "Testing?" }, fakeUser);
  13. }

The last line creates a new to-do item called Testing?, and tells the service to save it to the (in-memory) database. To verify that the business logic ran correctly, retrieve the item:

  1. // Use a separate context to read the data back from the DB
  2. using (var inMemoryContext = new ApplicationDbContext(options))
  3. {
  4. Assert.Equal(1, await inMemoryContext.Items.CountAsync());
  5. var item = await inMemoryContext.Items.FirstAsync();
  6. Assert.Equal("Testing?", item.Title);
  7. Assert.Equal(false, item.IsDone);
  8. Assert.True(DateTimeOffset.Now.AddDays(3) - item.DueAt < TimeSpan.FromSeconds(1));
  9. }

The first verification step is a sanity check: there should never be more than one item saved to the in-memory database. Assuming that’s true, the test retrieves the saved item with FirstAsync and then asserts that the properties are set to the expected values.

Asserting a datetime value is a little tricky, since comparing two dates for equality will fail if even the millisecond components are different. Instead, the test checks that the DueAt value is less than a second away from the expected value.

Both unit and integration tests typically follow the AAA (Arrange-Act-Assert) pattern: objects and data are set up first, then some action is performed, and finally the test checks (asserts) that the expected behavior occurred.

Here’s the final version of the AddNewItem test:

AspNetCoreTodo.UnitTests/TodoItemServiceShould.cs

  1. public class TodoItemServiceShould
  2. {
  3. [Fact]
  4. public async Task AddNewItem()
  5. {
  6. var options = new DbContextOptionsBuilder<ApplicationDbContext>()
  7. .UseInMemoryDatabase(databaseName: "Test_AddNewItem")
  8. .Options;
  9. // Set up a context (connection to the DB) for writing
  10. using (var inMemoryContext = new ApplicationDbContext(options))
  11. {
  12. var service = new TodoItemService(inMemoryContext);
  13. await service.AddItemAsync(new NewTodoItem { Title = "Testing?" }, null);
  14. }
  15. // Use a separate context to read the data back from the DB
  16. using (var inMemoryContext = new ApplicationDbContext(options))
  17. {
  18. Assert.Equal(1, await inMemoryContext.Items.CountAsync());
  19. var item = await inMemoryContext.Items.FirstAsync();
  20. Assert.Equal("Testing?", item.Title);
  21. Assert.Equal(false, item.IsDone);
  22. Assert.True(DateTimeOffset.Now.AddDays(3) - item.DueAt < TimeSpan.FromSeconds(1));
  23. }
  24. }
  25. }

Run the test

On the terminal, run this command (make sure you’re still in the AspNetCoreTodo.UnitTests directory):

  1. dotnet test

The test command scans the current project for tests (marked with [Fact] attributes in this case), and runs all the tests it finds. You’ll see an output similar to:

  1. Starting test execution, please wait...
  2. [xUnit.net 00:00:00.7595476] Discovering: AspNetCoreTodo.UnitTests
  3. [xUnit.net 00:00:00.8511683] Discovered: AspNetCoreTodo.UnitTests
  4. [xUnit.net 00:00:00.9222450] Starting: AspNetCoreTodo.UnitTests
  5. [xUnit.net 00:00:01.3862430] Finished: AspNetCoreTodo.UnitTests
  6. Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
  7. Test Run Successful.
  8. Test execution time: 1.9074 Seconds

You now have one test providing test coverage of the TodoItemService. As an extra-credit challenge, try writing unit tests that ensure:

  • MarkDoneAsync returns false if it’s passed an ID that doesn’t exist
  • MarkDoneAsync returns true when it makes a valid item as complete
  • GetIncompleteItemsAsync returns only the items owned by a particular user