Application testing
.NET .NET Core ASP.NET Core C# Entity Framework Core Unit Testing Visual Studio Web API

How to Unit Test a .NET Core Minimal Web API

Welcome to today’s post.

In today’s post I will show now to create unit tests for a .NET Core Web API.

In one of the previous posts, I showed how to create a basic minimal Web API using .NET Core 6. I then expanded on this by showing how to integrate SQL data access and in-memory data access into our minimal Web API by including entity framework.

The next step is to enhance our minimal Web API by including unit testing into our Web API.

In the first section, I will show how to create the unit test project that will contain our unit tests.

Creating the Unit Test Project

Before we can create unit tests for our minimal Web API, we create a unit test project in our solution. I have nominated NUnit as the test framework. You can alternatively use xUnit or MSTest as your test frameworks.

The unit test project is selectable from the list of templates within Visual Studio when filtering by “test” shown:

Once your unit test project is created, you will have the following scaffolded class:

namespace BookLoan.CatalogMin.API.UnitTest
{
    public class Tests
    {
        [SetUp]
        public void Setup()
        {
        }	

        [TearDown]
        public async Task TearDown()
        {
        }

        [Test]
        public async Task Test1()
        {
            ....
        }
}

In the next section, I will show how to implement the data context and data access layer within our minimal web API.

Implementation of the Data Access Layer

Recall that in our minimal Web API application, we declared the data context for our data. This is shown below moved into a separate file (Data.cs):

using Microsoft.EntityFrameworkCore;

public class BookLoanDb : DbContext
{
    public BookLoanDb(DbContextOptions<BookLoanDb> options)
        : base(options) { }

    public DbSet<BookViewModel> Books => Set<BookViewModel>();
}

To create in-memory instances of our data for unit test purposes, we implement a factory that can be called from our unit test methods in a separate file (InMemoryData.cs):

using Microsoft.EntityFrameworkCore;

public class InMemoryDb : IDbContextFactory<BookLoanDb>
{
    public BookLoanDb CreateDbContext()
    {
        var options = new DbContextOptionsBuilder<BookLoanDb>()
            .UseInMemoryDatabase("BookLoan - InMemoryDb")
            .Options;

        return new BookLoanDb(options);
    }
}

After each test completes, we will need to clear out the in-memory data to prepare it for the subsequent unit tests. This can be done in the NUnit TearDown() method. First add the reference to the entity framework library:

using Microsoft.EntityFrameworkCore;

The TearDown() method is shown:

[TearDown]
public async Task TearDown()
{
    await using var dbContext = new InMemoryDb().CreateDbContext();

    await dbContext.Books.ForEachAsync(b =>
    {
        dbContext.Books.Remove(b);
    });
    await dbContext.SaveChangesAsync();
}

The task for us is to expose the application logic within the minimal Web API application to our unit test project test methods. Recall that our Web API methods consist of a route mapping endpoint and a lambda method, each being self-contained.

The web API endpoint and logic for the /bookitems GET method was as shown: 

app.MapGet("/bookitems", async (BookLoanDb db) =>
{
    var books = await db.Books.ToListAsync();
    if (books is null)
        return Results.NotFound();
    return Results.Ok(books);
});

We separate the logic into a static method:

public static async Task<Microsoft.AspNetCore.Http.IResult> 
    BookItems(BookLoanDb db)
{
    var books = await db.Books.ToListAsync();
    if (books is null)
        return Results.NotFound();
    return Results.Ok(books);
}

We then associate the method into the API method endpoint as shown:

app.MapGet("/bookitems", BookLoanMethods.BookItems);

After repeating the above process of refactoring, we will have the following endpoint definitions in our Program.cs file:

app.MapGet("/bookitems", BookLoanMethods.BookItems);

app.MapGet("/bookitems/{id}", BookLoanMethods.BookItem);

app.MapPost("/bookitems", BookLoanMethods.CreateBook);

app.MapPut("/bookitems{id}", BookLoanMethods.UpdateBook);

app.MapDelete("/bookitems{id}", BookLoanMethods.DeleteBook);

Our static methods which contain the logic are moved into another file (DataService.cs):

using Microsoft.EntityFrameworkCore;

public static class BookLoanMethods
{
    public static async Task<Microsoft.AspNetCore.Http.IResult> BookItems(BookLoanDb db)
    {
        var books = await db.Books.ToListAsync();
        if (books is null)
            return Results.NotFound();
        return Results.Ok(books);
    }

    public static async Task<Microsoft.AspNetCore.Http.IResult> BookItem(int id, BookLoanDb db)
    {
        var book = await db.Books.FindAsync(id);
        if (book is null)
            return Results.NotFound();
        if (book is BookViewModel)
            return Results.Ok(book);
        return Results.NotFound();
    }

    public static async Task<Microsoft.AspNetCore.Http.IResult> CreateBook(BookViewModel book, BookLoanDb db)
    {
        db.Books.Add(book);
        await db.SaveChangesAsync();
        return (Microsoft.AspNetCore.Http.IResult)Results.Created($"/bookitems/{book.ID}", book);
    }

    public static async Task<Microsoft.AspNetCore.Http.IResult> UpdateBook(int id, BookViewModel inBook, BookLoanDb db)
    {
        var book = await db.Books.FindAsync(id);

        if (book is null)
            return Results.NotFound();

        book.ISBN = inBook.ISBN;
        book.Author = inBook.Author;
        book.Edition = inBook.Edition;
        book.Genre = inBook.Genre;
        book.Location = inBook.Location;
        book.MediaType = inBook.MediaType;
        book.Title = inBook.Title;
        book.YearPublished = inBook.YearPublished;
        book.DateUpdated = DateTime.Now;

        await db.SaveChangesAsync();

        return Results.NoContent();
    }

    public static async Task<Microsoft.AspNetCore.Http.IResult> DeleteBook(int id, BookLoanDb db)
    {
        var book = await db.Books.FindAsync(id);

        if (book is null)
            return Results.NotFound();

        if (book is BookViewModel)
        {
            db.Books.Remove(book);
            await db.SaveChangesAsync();
            return Results.Ok(book);
        }
        return Results.NotFound();
    }
}

In the next section, I will show how we implement unit tests for our minimal web API.

Implementation of the Unit Tests

The first unit test, ListBooksTest() that we implement is to list all the records from the Books table in our database.

I will walk through this first one and explain what I did first, then explain the improved solution to an issue I discovered during integration with the main API.

First, I create the in-memory data context:

await using var dbContext = new InMemoryDb().CreateDbContext();

I then create two test records.

var book1 = new BookViewModel
{
    ID = 1,
    Title = "Book 1",
    Author = "J. Bloggs",
    Genre = "Fiction",
    MediaType = "Book",
    YearPublished = 2022,
    ISBN = "AABBCC112233",
    Edition = "1",
    Location = "Sydney",
    DateCreated = DateTime.Now,
    DateUpdated = DateTime.Now
};

var book2 = new BookViewModel
{
    ID = 2,
    Title = "Book 2",
    Author = "A. Smith",
    Genre = "Fiction",
    MediaType = "Book",
    YearPublished = 2021,
    ISBN = "BBCCDD112233",
    Edition = "1",
    Location = "Sydney",
    DateCreated = DateTime.Now,
    DateUpdated = DateTime.Now
};

We then call the CreateBook() method to create each book.

var IsCreated1 = (Microsoft.AspNetCore.Http.IResult)await BookLoanMethods.CreateBook(
    book1, 
    dbContext
);

My initial method of determining the StatusCode from the HTTP response call is to check the type of the response object. I did this:

Assert.IsTrue(IsCreated1.GetType().ToString() == "Microsoft.AspNetCore.Http.Result.CreatedResult");

This is rather horrible and looks untidy. One reason why I did this was because the Web API methods that return from the main application return a response of type Microsoft.AspNetCore.Http.IResult. Below is a debug showing one of the response types from an API call:

The unit test project using the equivalent Microsoft.AspNetCore package has a Microsoft.AspNetCore.Http namespace but no publicly visible IResult type. The only visible type are those for MVC controller based responses, which are in the namespace Microsoft.AspNetCore.Mvc.

This causes InvalidCastException exceptions when attempting to cast responses of with type

Microsoft.AspNetCore.Http.IResult with Microsoft.AspNetCore.Mvc.IActionResult. An example of this incompatibility in types is with:

Microsoft.AspNetCore.Mvc.OkObjectResult

and

Microsoft.AspNetCore.Http.Result.OkObjectResult

A solution to this provided in ASP.NET Core 7.0, which exposes the internal IResult type and allows it to be casted without exceptions.

For now, what I did is to create a couple of helpers to extract the StatusCode and the object Value property within the response result. Below is a debug screenshot showing the properties we wish to extract from the IResult instance in the HTTP response:

The helpers to extract the StatusCode and response object are shown below:

using System.Reflection;

namespace BookLoan.CatalogMin.API.UnitTest
{
    public static class Helpers
    {
        public static System.Object? GetResponseObject(Microsoft.AspNetCore.Http.IResult rslt)
        {
            System.Reflection.TypeInfo typeInfo = rslt.GetType().GetTypeInfo();
            foreach (var prop in typeInfo.GetProperties())
            {
                if (prop.Name == "Value")
                    return (BookViewModel)prop.GetValue(rslt);
            }
            return null;
        }

        public static int? GetResponseStatusCode(Microsoft.AspNetCore.Http.IResult rslt)
        {
            System.Reflection.TypeInfo typeInfo = rslt.GetType().GetTypeInfo();
            foreach (var prop in typeInfo.GetProperties())
            {
                if (prop.Name == "StatusCode")
                    return (int?)prop.GetValue(rslt);
            }
            return null;
        }
    }
}

To extract the response static code to check a 201 (created) response using the GetResponseStstusCode() helper method , we simply do the following:

Assert.IsTrue(Helpers.GetResponseStatusCode(IsCreated1) == 
    Microsoft.AspNetCore.Http.StatusCodes.Status201Created);

To call the logic to retrieve all book and check the status, we simply do the following:

var listBooks = (Microsoft.AspNetCore.Http.IResult)await 
    BookLoanMethods.BookItems(dbContext);

Assert.IsTrue(
    Helpers.GetResponseStatusCode(listBooks) == 
    Microsoft.AspNetCore.Http.StatusCodes.Status200OK
);

The resulting test method looks as shown:

[Test]
public async Task ListBooksTest()
{
    await using var dbContext = new InMemoryDb().CreateDbContext();

    var book1 = new BookViewModel
    {
        ID = 1,
        Title = "Book 1",
        Author = "J. Bloggs",
        Genre = "Fiction",
        MediaType = "Book",
        YearPublished = 2022,
        ISBN = "AABBCC112233",
        Edition = "1",
        Location = "Sydney",
        DateCreated = DateTime.Now,
        DateUpdated = DateTime.Now
    };

    var book2 = new BookViewModel
    {
        ID = 2,
        Title = "Book 2",
        Author = "A. Smith",
        Genre = "Fiction",
        MediaType = "Book",
        YearPublished = 2021,
        ISBN = "BBCCDD112233",
        Edition = "1",
        Location = "Sydney",
        DateCreated = DateTime.Now,
        DateUpdated = DateTime.Now
    };

    var IsCreated1 = (Microsoft.AspNetCore.Http.IResult)await 
        BookLoanMethods.CreateBook(book1, dbContext);

    Assert.IsTrue(Helpers.GetResponseStatusCode(IsCreated1) == 
        Microsoft.AspNetCore.Http.StatusCodes.Status201Created);

    var IsCreated2 = (Microsoft.AspNetCore.Http.IResult)await 
        BookLoanMethods.CreateBook(book2, dbContext);

    Assert.IsTrue(Helpers.GetResponseStatusCode(IsCreated2) == 
        Microsoft.AspNetCore.Http.StatusCodes.Status201Created);

    Assert.IsTrue(dbContext.Books.Count() == 2);

    var listBooks = (Microsoft.AspNetCore.Http.IResult)
        await BookLoanMethods.BookItems(dbContext);

    Assert.IsTrue(Helpers.GetResponseStatusCode(listBooks) == 
        Microsoft.AspNetCore.Http.StatusCodes.Status200OK);    
}

To test logic that returns an object within the HTTP response then updates it, I will implement a unit test UpdateBookTest().

As with the previous unit test, I test the creation of two records in-memory:

var IsCreated1 = (Microsoft.AspNetCore.Http.IResult)await BookLoanMethods.CreateBook(
    book1, 
    dbContext
);

Assert.IsTrue(Helpers.GetResponseStatusCode(IsCreated1) == 
    Microsoft.AspNetCore.Http.StatusCodes.Status201Created);

var IsCreated2 = (Microsoft.AspNetCore.Http.IResult)await BookLoanMethods.CreateBook(
    book2, 
    dbContext
);

Assert.IsTrue(Helpers.GetResponseStatusCode(IsCreated2) == 
    Microsoft.AspNetCore.Http.StatusCodes.Status201Created);

I then create an object to store the record we wish to update in the in-memory data store:

var updatedbook = new BookViewModel
{
    ID = 1,
    Title = "Book 1",
    Author = "J. Bloggs",
    Genre = "Fiction",
    MediaType = "Book",
    YearPublished = 2022,
    ISBN = "AABBCC112233",
    Edition = "2",
    Location = "Sydney",
    DateCreated = DateTime.Now,
    DateUpdated = DateTime.Now
};

A test for updating a record is to check the 204 response (no content), which is shown:

var IsUpdated = (Microsoft.AspNetCore.Http.IResult)await BookLoanMethods.UpdateBook(
    1, 
    updatedbook, 
    dbContext
);
 
Assert.IsTrue(Helpers.GetResponseStatusCode(IsUpdated) == 
    Microsoft.AspNetCore.Http.StatusCodes.Status204NoContent);

We then test that we can read the updated record 200 response, which is shown:

var getUpdatedBook = (Microsoft.AspNetCore.Http.IResult)await BookLoanMethods.BookItem(
    1, 
    dbContext
);

Assert.IsTrue(Helpers.GetResponseStatusCode(getUpdatedBook) == 
    Microsoft.AspNetCore.Http.StatusCodes.Status200OK);

Last of all, we obtain the response object, which is of type IResult using the GetResonseObject() helper method:

var obj = (Microsoft.AspNetCore.Http.IResult)getUpdatedBook;

BookViewModel? val = (BookViewModel?)Helpers.GetResponseObject(obj);

We then apply assertions to test the updated properties of the result object:

if (val != null)
    Assert.IsTrue(val.Edition == "2");
else
    Assert.Fail("Returned object is null");

The unit test method to retrieve a single record is as follows (with routine code I have already explained omitted for brevity):

[Test]
public async Task GetBookTest()
{
    // obtain data context …

    // declare records …

    // create two records and test responses …
 
    // obtain record and test for the 200 response code: 

    var getBook = (Microsoft.AspNetCore.Http.IResult)await BookLoanMethods.BookItem(
        1, 
        dbContext
    );

    Assert.IsTrue(Helpers.GetResponseStatusCode(getBook) == 
            Microsoft.AspNetCore.Http.StatusCodes.Status200OK);
}

The unit test to append / create a new record is as follows (with routine code I have already explained omitted for brevity):

[Test]
public async Task AddBookTest()
{
     // obtain data context …

    // declare records …

	// create and append record and test for the 201 created code: 
	
    var IsCreated = (Microsoft.AspNetCore.Http.IResult)await 
 		BookLoanMethods.CreateBook(book1, dbContext);
            
    Assert.IsTrue(Helpers.GetResponseStatusCode(IsCreated) == 
 		Microsoft.AspNetCore.Http.StatusCodes.Status201Created);
}

Finally, the unit test for record deletion is as follows (with routine code I have already explained omitted for brevity):

[Test]
public async Task DeleteBookTest()
{
    // obtain data context …

    // declare records …

    // remove record and test for the 200 status code: 

    var IsRemoved = (Microsoft.AspNetCore.Http.IResult)await 
        BookLoanMethods.DeleteBook(1, dbContext);
    
    Assert.IsTrue(Helpers.GetResponseStatusCode(IsRemoved) == 
        Microsoft.AspNetCore.Http.StatusCodes.Status200OK);
}

The overall structure of the minimal Web API and unit test projects within the solution looks as follows:

After running the unit tests, if all is successful, the Test Explorer will show all unit tests as passed with the green ticks and the duration of each test:

The resulting code has increased as we have added some code to implement in-memory data context. We refactored the API methods and allowed them to be visible to our unit test methods. As an alternative, we could also have moved the API logic into a separate service class and injected those into the unit test methods. We also experienced an issue with casting the result types between the web API application and the unit test application. This issue has since been fixed in ASP.NET Core 7.0. I have applied a workaround using reflection and useful helper methods to extract the response properties from the IResult type.

From the above we have achieved our goal of unit testing the minimal web API application.

That is all for today’s post.

I hope you have found this post useful and informative.

Social media & sharing icons powered by UltimatelySocial