ASP.NET 6.0 —Web API Unit Testing

Justin Muench
CodeX
Published in
14 min readDec 24, 2022
Photo by Clément Hélardot on Unsplash

Unit testing is an essential aspect of software development that helps ensure the reliability and robustness of your code.

It allows you to verify that your code is working as intended and helps you catch bugs and issues early in the development process.

In this article, we’ll delve into the process of unit testing an ASP.NET 6 Core web API, including setting up your test environment, writing test cases, and running your tests.

Whether new to unit testing or a seasoned pro, this article will provide valuable insights and best practices for testing your ASP.NET 6 Core web API.

So, let’s get started and see how we can ensure our web API is rock solid!

We will use the customer service project in this post, 
which I wrote for the logging post.

Project Structure

I have written a sample .NET 6.0 Customer Service.

The solution includes:

  • LoggingAndMonitoringAPIExample: This is the main ASP.NET 6.0 web application, which demonstrates how to perform logging and monitoring in a web application.
  • LoggingAndMonitoringAPIExample.Logic: This project contains the logic for the logging and monitoring functionality.
  • LoggingAndMonitoringAPIExample.Tests: This project includes unit tests for the LoggingAndMonitoringAPIExample and LoggingAndMonitoringAPIExample.Logic projects, which test the logging and monitoring functionality.
  • LoggingAndMonitoringAPIExample.WebApp : This project contains the front end, which still needs to be done.

Based on this solution, unit testing of an ASP.NET 6.0 Web API will be examined and explained in more detail.

Unit Test

A unit test takes a small unit of the app, typically a method, isolates it from the remainder of the code, and verifies that it behaves as expected

(https://learn.microsoft.com/en-us/dotnet/architecture/maui/unit-testing).

Unit tests should be as simple as possible and thus have a low complexity [1]. They should be fast [1].

In addition to being simple and fast, good unit tests are encapsulated and isolated [1]. This means they should be independent of external resources or the state [1]. For example, a unit test should not rely on a database connection or file system access to test a particular method’s behavior [1]. By isolating the unit being tested from its dependencies, developers can more easily identify the root cause of any problems that arise [1].

But all other logical dependencies do not belong in the unit test either; we want to ensure that if the unit test fails, the tested component has failed, not a dependency [1].

Therefore unit tests typically focus on testing individual methods or functions within a codebase [2].

For example, suppose you have a method that calculates the total cost of an order. A unit test might verify that the method returns the correct result when given different input data sets. By testing small code units in isolation, developers can more easily identify problems and ensure that each application unit works as expected.

Reasons for Test Automation

Automated tests can increase the reliability of an application by providing faster feedback to developers and helping to catch regressions [1]. For example, if a developer introduces a bug in their code, an automated test that checks for that specific bug can alert the developer as soon as the code is committed, allowing them to fix the issue more quickly [1].

Automated tests can be integrated into a CI/CD pipeline in various ways [1]. For example, they can be run as part of a build process, triggered whenever new code is committed to the repository. This allows developers to catch problems early in the development process before the code is deployed to production.

Additionally, automated tests can verify the correctness of deployments to staging or production environments. For example, after a new version of an application is deployed, a set of automated tests can be run to ensure that all functionality is working as expected.

Testing every part of the application by hand is, in that respect, far more expensive and time-consuming [1].

After a test is written once, you can use the test without any additional costs as often as you wish [1]. Running a set of automated tests is also much quicker than running manual tests [1].

What isn’t Unit Tests?

It is important to note that a unit test is not a complete system test, which verifies the entire system’s behavior [1].

A unit test is also not an integration test, which verifies the behavior of multiple units of code working together [1]. It is also important to note that unit tests should be used as something other than integration or system tests.

While unit tests help verify the behavior of individual code units, they provide a different level of coverage than integration or system tests, which are necessary to ensure that the entire system is functioning correctly.

I have already written about the different types of tests in my first Unit Test article.

What to test and what not to test?

You should test algorithms, behavior, and rules but not things like data access, UI, or system interactions [1].

First Unit Test

I have added the xUnit Test project to my solution where the tests are written. In xUnit, we use the annotation “[Fact]” to declare our test methods.

  [Fact]
public async Task GetExistsAsync_WithCustomerIdOne_ReturnsTrue()
{
//Arrange
var customerId = 1;
//Act
var result = await _customerService.GetExistsAsync(customerId);
//Assert
Assert.True(result);
}

For demonstration purposes, I am using assert to validate the outcome. I usually use FluentAssertions, because it is better to read, and I like the syntax.

Naming the Unit Test

When naming your tests, it’s essential to follow a consistent naming convention that communicates what the test is doing. This can make understanding the test’s purpose easier and identify any potential issues when it fails.

One approach to naming tests is to use the following three-part structure:

  1. The name of the method being tested: This should be the first part of the test name, as it tells you which method is being tested [3].
  2. The scenario under which it’s being tested: This should describe the specific scenario or condition under which the method is being tested. For example, if you are testing a method that handles user registration, the scenario could be “with valid input” or “with invalid input” [3].
  3. The expected behavior when the scenario is invoked: This should describe the expected behavior of the method when the scenario is invoked. For example, suppose you are testing a method that calculates the total cost of an order. The expected behavior might be “returns the correct total cost” [3].

Standard pattern to design your Unit Tests

The “arrange, act, assert” (AAA) pattern is a standard structure for unit tests that helps to ensure that tests are clear, maintainable, and effective.

Thereby each test should be divided into three sections:

  • Arrange: Set up your tests, like initializing objects and getting dependencies or mocks [1].
  • Act: Executing the code under test [1].
  • Assert: Verifying the result [1].

Some Reasons for using the pattern are:

  • Clarity: By following the AAA pattern, you can clearly distinguish the setup (arrange) phase from the execution (act) phase and the verification (assert) phase of a test. Reading and understanding the test more straightforwardly can help increase your test suite’s maintainability.
  • Maintainability: The AAA pattern promotes a clear separation of concerns between the different phases of a test, which can make it easier to modify and maintain individual tests. For example, if you need to update the setup for a test, you can do so without affecting the execution or verification phases.
  • Modularity: The AAA pattern encourages you to break tests into smaller, more modular units that can be reused across different test cases. This can make creating and maintaining an extensive test suite more straightforward, as you can reuse code across various tests rather than having to write everything from scratch.
  • Reliability: By following the AAA pattern, you can help to ensure that your tests are reliable and consistent. For example, follow the pattern and set up all necessary test data in the arranging phase. You can be confident that your tests are executing correctly and are not dependent on external factors.

xUnit vs. NUnit vs. MSTest

MSTest is the built-in unit test framework from Microsoft [1]. It has supported .NET (Core) since v2.0 [1].

While NUnit is a port of JUnit and has been around for a long time [1]. As the second version of MS Test, NUnit is open source [1]. Both can be used to test .NET 6 Code, but they are not coded with .NET Core or .NET 6 in mind [1].

This is where xUnit comes in [1]. One of NUnit’s creators created it with the help of an ex-Microsoft developer [1]. It was built with the new .NET Core in mind and is currently the best choice for testing .NET Core [1].

Assert

An assert statement verifies the expected outcome of a test by evaluating a boolean expression. It should evaluate to true if the test was successful and false if the test failed [1].

In other words, an assert statement verifies that a test has produced the expected result. It consists of a boolean expression (i.e., an expression that can be either true or false) that is used to check the outcome of the test.

A test can contain one or more assert [1]. The whole test fails if one or more asserts fail [1].

With xUnit come asserts for the most common test scenarios [1]. In principle, tests can contain one or more asserts, but they should have only one assert [1]. This makes it easier to discover the reason for the failing test [1].

However, according to Kevin Dockx, multiple assert statements can be used in a test as long as they test the same behavior [1].

Types of Assertions

Boolean:

Assert.True(customer.IsNew);

Assert.False(customer.IsNew);

Strings:

//Equals
Assert.Equal("John", customer.FirstName, ignoreCase:true);

//StartsWith
Assert.StartsWith(customer.FirstName, customer.FullName);

//EndsWith
Assert.EndsWith(customer.LastName, customer.FullName);

Numeric Values:

// We can use equal and notequal also on numeric values.
Assert.Equal(2, customer.Products);
Assert.NotEqual(3, customer.Products);

// We can also check, if something is in a range
Assert.True(customer.Invoice => 2000 && customer.Invoice <= 2500);

// The better way is to use
Assert.InRange(customer.Invoice, 2000, 2500);

Floating Values:

var customer = new Customer("John", "Doe");
customer.Invoice = 2200.12m;

// We can also use Equal, NotEqual and InRange for floating values.

Arrays & Collections

var customer = new Customer("John", "Doe");

// We can use contains to check if a customer is in a IEnumerable
Assert.Contains(customer, newlyCreatedCustomers);

// Or wen can even check if two IEnumerable's are equal
var actual = new List<Customer>() {customer};
Assert.Equals(actual, newlyCreatedCustomers)

// If you want to check all items in a list
// you can use Assert.All, which runs through every item in an IEnumerable
Assert.All(newlyCreatedCustomers, cus => Assert.True(cus.IsNew));

Asynchronous Code

// This is quite easy
// Lets assume we have a Method GetCustomerAsync
// Just change the signature of your test method from void to async Task
// and await tested method

[Fact]
public async Task GetCustomerAsync_WithCustomerIdOne_ReturnsCustomerOne()
{
var customerId = 1;

var result = await _customerService.GetCustomerAsync(customerId);

Assert.Equal(customerId, result.Id);
}

Exceptions:

// Act & Assert

await Assert.ThrowsAsync<NotImplementedExpcetion>(async () =>
await _customerService.GetExceptionAsync());

There are also assertions for object types (isType) and events (raises).

Asserting private methods

There is no good way to test a private method; therefore, it should not be tested [1]. You could use the [InternalsVisible] — Annotation and set up internals visible in the test project [1]. But in the end, this private method has to be called by any public method, which can be tested [1].

The Repository Pattern

The repository pattern is a design pattern used to abstract an application’s data access layer from the business logic layer. It provides a way to decouple the two layers, making it easier to maintain and test the application.

The repository pattern creates a layer of abstraction between the data access code and the business logic code. This layer, called a repository, acts as a mediator between the two layers. It exposes a set of methods that the business logic can use to access and modify data without directly dealing with the underlying data storage technology.

For example, consider an application that stores customer information in a database. The repository pattern could be used to create a repository class that exposes methods such as GetCustomerById, SaveCustomer, and DeleteCustomer. The business logic could then use these methods to retrieve, update, and delete customer records without worrying about how the data is stored or retrieved from the database.

Mocking

Mocking is a technique used in unit testing to simulate the behavior of dependent components or services. It allows you to create “fake” versions of these components or services that your code can interact with without relying on the actual implementations.

Mocking is helpful because it isolates the code you are testing from its dependencies. This can make it easier to test individual code units in isolation and help ensure that your tests are reliable and consistent.

For example, consider a class that retrieves customer information from a database. To test this class, you might create a mock database that returns predetermined data when queried. This way, you can test the class without relying on an actual database, which could be slow, difficult to set up, or prone to change.

Overall, the repository pattern and mocking are valuable tools for creating maintainable, testable code in ASP.NET 6.0. By abstracting the data access layer and isolating code units from their dependencies, you can generate easier-to-maintain and test code, which can help ensure your project’s success.

Mock Example

This should be a post about mocking in-depth; this is why I only present you a short example of how it could look. For more code, take a look at the example repo.

public CustomerServiceTests()
{
var customerSeedDataFixture = new CustomerSeedDataFixture();

//Creating a Mock of the typ ILoggerFactory
Mock<Microsoft.Extensions.Logging.ILoggerFactory> loggerFactory = new();
//Setting the CreateLogger method up, so it will always Returns a Mock of a Logger.
loggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(new Mock<ILogger<ICustomerService>>().Object);

_customerService = new CustomerService(customerSeedDataFixture.DbContext, loggerFactory.Object);
}

Code Coverage

Code coverage measures how much of your code is exercised by your unit tests. The degree to which your code is being tested is often expressed as a percentage, with a higher rate indicating more thorough testing.

Code coverage is an important metric because it can help you identify areas of your code that need to be adequately tested. Low code coverage could indicate that you must test all possible paths through your code, which could lead to bugs and other issues.

To measure code coverage, you can use a code coverage tool that analyzes your unit tests and reports on which lines of code are being executed. Many code coverage tools, both commercial and open-source, can help you measure and improve your code coverage.

But achieving 100% code coverage is illusory and counterproductive [1]. Kevin Docks considers aiming for 80–90% code coverage a good idea [1].

Deciding What to Unit Test

Deciding what to unit test can be a challenging task, especially in a large codebase. Here are some guidelines that can help you to identify the most critical parts of your code to test:

  • Test the most critical and complex parts of your code: These are the parts of your code that are most likely to contain bugs or that are most important to the correct functioning of your application. Focusing on these areas can help you to ensure that your tests are practical and valuable.
  • Test code prone to change: If you have frequently changed or are likely to change in the future, it is essential to have a good set of unit tests in place to ensure that the changes do not introduce new bugs.
  • Test code that has a high impact on the user: Code that directly impacts the user experience or is critical to your application’s functionality should be a high priority for testing.

Overall, it is essential to have a comprehensive and effective unit test suite in place to ensure the quality and reliability of your code. By measuring your code coverage and focusing on the most critical parts of your code, you can create a test suite that helps you to identify and fix bugs and improve the stability of your application.

Testing Controller?

Controllers in ASP.NET MVC are responsible for handling incoming requests, performing actions, and returning appropriate responses. Because of their central role in the application, it is essential to test controllers to ensure they function correctly and thoroughly.

There are a few different approaches to testing controllers, depending on the complexity of the controller and the level of abstraction you want to achieve. Here are some points to think about:

  • Thick controllers: A thick controller is a controller that contains a lot of business logic and performs many different actions. Testing a thick controller can be challenging, as it may have many dependencies and be difficult to isolate from the rest of the application. One approach to testing thick controllers is to use integration tests, which test the controller in the context of the whole application stack.
  • Thin controllers: A thin controller is a controller that contains minimal business logic and mostly delegates to other components or services. Testing a thin controller can be more accessible, as it has fewer dependencies and is easier to isolate from the rest of the application. You can use unit tests to test thin controllers, which can help you ensure that the controller is invoking the necessary actions and returning the correct responses.

Overall, whether you should test a controller depends on the complexity of the controller and the level of abstraction you want to achieve. If the controller is thick and contains a lot of business logic, use integration tests to test it in the context of the complete application stack. If the controller is thin and primarily delegates to other components or services, you can use unit tests to test it in isolation.

Unit Testing Middleware

Middleware is a term used to describe components in a software system that handle requests and responses as they pass through a pipeline. In ASP.NET, middleware is typically used to perform tasks such as routing requests, handling authentication, and modifying the request and response objects.

Unit testing middleware can be a helpful way to ensure that your middleware is functioning correctly and is isolated from the rest of the application. However, it is crucial to remember that middleware is typically designed to be run in the context of the full application stack and may depend on other components or services to function correctly. As a result, unit testing middleware can be challenging and may require mock objects or other techniques to isolate it from its dependencies.

To ensure that your custom middleware is functioning correctly and is not causing any problems in your application, it is generally a good idea to focus on testing it rather than built-in middleware. This will help you to identify and resolve any issues with your custom middleware.

Next Step— Integration Tests

After you have completed unit testing of individual components or units of code, you may want to perform integration testing to ensure that the different parts of your application are working together correctly. Integration tests are designed to test how different parts of the application interact with each other and can help you to identify issues that may need to be apparent when testing individual units of code in isolation.

Different integration testing levels depend on the scope of the tests and the components being tested. Integration testing typically involves more significant groups of components or entire subsystems of the application. This can include testing the integration between different application layers, such as the data access and business logic.

It is essential to remember that integration tests are more time-consuming and resource-intensive than unit tests, as they involve testing more of the application at once. As a result, it is usually a good idea to prioritize unit testing and focus on integration testing only after you have thoroughly tested the individual components of your application.

Overall, integration testing is an essential step in the testing process that can help you identify and fix issues that may be absent when testing individual code units. By performing integration testing after unit testing, you can ensure that the different parts of your application are working together correctly and that your application is ready for deployment.

Conclusion

Unit testing plays a crucial role in the software development process, as it helps you to find and fix bugs, improve the reliability of your code, and make your application more maintainable.

By writing and running automated unit tests, you can ensure that your code is working as expected and will continue to function correctly even as you make changes and updates to your application.

However, it is essential to remember that unit testing is not a substitute for other types of testing, such as integration or user acceptance testing. It is also necessary to be selective about what you test and to focus on testing the most critical and complex parts of your code.

By following best practices for unit testing, such as the AAA pattern, and using tools like XUnit, you can create a comprehensive and effective unit test suite that helps you ensure your code’s quality and reliability.

Make sure to check out my other Post about Unit Testing.



Want to connect and stay up-to-date on AI, tech, and .NET development?

Follow me on Twitter and LinkedIn - I'm always happy to hear from you.

--

--

Justin Muench
CodeX
Writer for

Software developer with a passion for .NET Core, SQL, and Python. Interested in AI and studying computer science and economics.