Automated testing

Writing tests is an important part of building any application. Testing your code helps you find and avoid bugs, and makes it easier to refactor your code later without breaking functionality or introducing new problems.

In this chapter you’ll learn how to write both unit tests and integration tests that exercise your ASP.NET Core application. Unit tests are small tests that make sure a single method or chunk of logic works properly. Integration tests (sometimes called functional tests) are larger tests that simulate real-world scenarios and test multiple layers or parts of your application.

Unit testing

Unit tests are small, short tests that check the behavior of a single method or class. When the code you’re testing relies on other methods or classes, unit tests rely on mocking those other classes so that the test only focuses on one thing at a time.

For example, the TodoController class 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 service container and 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 have to handle the dependency graph yourself. It’s typical to provide test-only or “mocked” versions of those dependencies. This means you can isolate just the logic in the class or method you are testing. (This is important! 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 best practice to create a separate project for your tests, so they are kept separate from your application code. 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 root directory will also be called AspNetCoreTodo). Then use this command to scaffold a new test project:

dotnet new xunit -o AspNetCoreTodo.UnitTests

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:

AspNetCoreTodo/
    AspNetCoreTodo/
        AspNetCoreTodo.csproj
        Controllers/
        (etc...)

    AspNetCoreTodo.UnitTests/
        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 AspNetCoreTodo project:

dotnet add reference ../AspNetCoreTodo/AspNetCoreTodo.csproj

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

If you’re using Visual Studio Code, you may need to close and reopen the Visual Studio Code window to get code completion working in the new project.

Write a service test

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

public async Task<bool> AddItemAsync(
    TodoItem newItem, ApplicationUser user)
{
    newItem.Id = Guid.NewGuid();
    newItem.IsDone = false;
    newItem.DueAt = DateTimeOffset.Now.AddDays(3);
    newItem.UserId = user.Id;

    _context.Items.Add(newItem);

    var saveResult = await _context.SaveChangesAsync();
    return saveResult == 1;
}

This method makes a number of decisions or assumptions about the new item (in other words, performs business logic on the new item) before it actually saves it to the database:

  • The UserId 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

Imagine if you or someone else refactored the AddItemAsync() method and forgot about part of this business logic. The behavior of your application could change without you realizing it! You can prevent this by writing a test that double-checks that this business logic hasn’t changed (even if the method’s internal implementation changes).

It might seem unlikely now that you could introduce a change in business logic without realizing it, but it becomes much harder to keep track of decisions and assumptions in a large, complex project. The larger your project is, the more important it is to have automated checks that make sure nothing has changed!

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

using System;
using System.Threading.Tasks;
using AspNetCoreTodo.Data;
using AspNetCoreTodo.Models;
using AspNetCoreTodo.Services;
using Microsoft.EntityFrameworkCore;
using Xunit;

namespace AspNetCoreTodo.UnitTests
{
    public class TodoItemServiceShould
    {
        [Fact]
        public async Task AddNewItemAsIncompleteWithDueDate()
        {
            // ...
        }
    }
}

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 [Fact] attribute comes from the xUnit.NET package, and it marks this method as a test method.

The TodoItemService requires an ApplicationDbContext, which is normally connected to your 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 AddItemAsync():

var options = new DbContextOptionsBuilder<ApplicationDbContext>()
    .UseInMemoryDatabase(databaseName: "Test_AddNewItem").Options;

// Set up a context (connection to the "DB") for writing
using (var context = new ApplicationDbContext(options))
{
    var service = new TodoItemService(context);

    var fakeUser = new ApplicationUser
    {
        Id = "fake-000",
        UserName = "[email protected]"
    };

    await service.AddItemAsync(new TodoItem
    {
        Title = "Testing?"
    }, fakeUser);
}

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, write some more code below the existing using block:

// Use a separate context to read data back from the "DB"
using (var context = new ApplicationDbContext(options))
{
    var itemsInDatabase = await context
        .Items.CountAsync();
    Assert.Equal(1, itemsInDatabase);
    
    var item = await context.Items.FirstAsync();
    Assert.Equal("Testing?", item.Title);
    Assert.Equal(false, item.IsDone);

    // Item should be due 3 days from now (give or take a second)
    var difference = DateTimeOffset.Now.AddDays(3) - item.DueAt;
    Assert.True(difference < TimeSpan.FromSeconds(1));
}

The first assertion 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.

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.

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.

Run the test

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

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 output similar to:

Starting test execution, please wait...
 Discovering: AspNetCoreTodo.UnitTests
 Discovered:  AspNetCoreTodo.UnitTests
 Starting:    AspNetCoreTodo.UnitTests
 Finished:    AspNetCoreTodo.UnitTests

Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 1.9074 Seconds

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

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

Integration testing

Compared to unit tests, integration tests are much larger in scope. exercise the whole application stack. Instead of isolating one class or method, integration tests ensure that all of the components of your application are working together properly: routing, controllers, services, database code, and so on.

Integration tests are slower and more involved than unit tests, so it’s common for a project to have lots of small unit tests but only a handful of integration tests.

In order to test the whole stack (including controller routing), integration tests typically make HTTP calls to your application just like a web browser would.

To perform an integration test, you could start your application and manually make requests to http://localhost:5000. However, ASP.NET Core provides a better alternative: the TestServer class. This class can host your application for the duration of the test, and then stop it automatically when the test is complete.

Create a test project

If you’re currently in your project directory, cd up one level to the root AspNetCoreTodo directory. Use this command to scaffold a new test project:

dotnet new xunit -o AspNetCoreTodo.IntegrationTests

Your directory structure should now look like this:

AspNetCoreTodo/
    AspNetCoreTodo/
        AspNetCoreTodo.csproj
        Controllers/
        (etc...)

    AspNetCoreTodo.UnitTests/
        AspNetCoreTodo.UnitTests.csproj

    AspNetCoreTodo.IntegrationTests/
        AspNetCoreTodo.IntegrationTests.csproj

If you prefer, you can keep your unit tests and integration tests in the same project. For large projects, it’s common to split them up so it’s easy to run them separately.

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

dotnet add reference ../AspNetCoreTodo/AspNetCoreTodo.csproj

You’ll also need to add the Microsoft.AspNetCore.TestHost NuGet package:

dotnet add package Microsoft.AspNetCore.TestHost

Delete the UnitTest1.cs file that’s created by dotnet new. You’re ready to write an integration test.

Write an integration test

There are a few things that need to be configured on the test server before each test. Instead of cluttering the test with this setup code, you can keep this setup in a separate class. Create a new class called TestFixture:

AspNetCoreTodo.IntegrationTests/TestFixture.cs

using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;

namespace AspNetCoreTodo.IntegrationTests
{
    public class TestFixture : IDisposable  
    {
        private readonly TestServer _server;

        public HttpClient Client { get; }

        public TestFixture()
        {
            var builder = new WebHostBuilder()
                .UseStartup<AspNetCoreTodo.Startup>()
                .ConfigureAppConfiguration((context, config) =>
                {
                    config.SetBasePath(Path.Combine(
                        Directory.GetCurrentDirectory(),
                        "..\\..\\..\\..\\AspNetCoreTodo"));
                    
                    config.AddJsonFile("appsettings.json");
                });

            _server = new TestServer(builder);

            Client = _server.CreateClient();
            Client.BaseAddress = new Uri("http://localhost:8888");
        }

        public void Dispose()
        {
            Client.Dispose();
            _server.Dispose();
        }
    }
}

This class takes care of setting up a TestServer, and will help keep the tests themselves clean and tidy.

Now you’re (really) ready to write an integration test. Create a new class called TodoRouteShould:

AspNetCoreTodo.IntegrationTests/TodoRouteShould.cs

using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

namespace AspNetCoreTodo.IntegrationTests
{
    public class TodoRouteShould : IClassFixture<TestFixture>
    {
        private readonly HttpClient _client;

        public TodoRouteShould(TestFixture fixture)
        {
            _client = fixture.Client;
        }

        [Fact]
        public async Task ChallengeAnonymousUser()
        {
            // Arrange
            var request = new HttpRequestMessage(
                HttpMethod.Get, "/todo");

            // Act: request the /todo route
            var response = await _client.SendAsync(request);

            // Assert: the user is sent to the login page
            Assert.Equal(
                HttpStatusCode.Redirect,
                response.StatusCode);

            Assert.Equal(
                "http://localhost:8888/Account" +
                "/Login?ReturnUrl=%2Ftodo",
                response.Headers.Location.ToString());
        }
    }
}

This test makes an anonymous (not-logged-in) request to the /todo route and verifies that the browser is redirected to the login page.

This scenario is a good candidate for an integration test, because it involves multiple components of the application: the routing system, the controller, the fact that the controller is marked with [Authorize], and so on. It’s also a good test because it ensures you won’t ever accidentally remove the [Authorize] attribute and make the to-do view accessible to everyone.

Run the test

Run the test in the terminal with dotnet test. If everything’s working right, you’ll see a success message:

Starting test execution, please wait...
 Discovering: AspNetCoreTodo.IntegrationTests
 Discovered:  AspNetCoreTodo.IntegrationTests
 Starting:    AspNetCoreTodo.IntegrationTests
 Finished:    AspNetCoreTodo.IntegrationTests

Total tests: 1. Passed: 1. Failed: 0. Skipped: 0.
Test Run Successful.
Test execution time: 2.0588 Seconds

Wrap up

Testing is a broad topic, and there’s much more to learn. This chapter doesn’t touch on UI testing or testing frontend (JavaScript) code, which probably deserve entire books of their own. You should, however, have the skills and base knowledge you need to learn more about testing and to practice writing tests for your own applications.

The ASP.NET Core documentation (https://docs.asp.net) and Stack Overflow are great resources for learning more and finding answers when you get stuck.


Licenses and Attributions


Speak Your Mind

-->