How to test your C# Web API

profile
Tim Deschryver
timdeschryver.dev

If you've read some of my other blog posts, you probably know that I'm not a big fan of unit tests. Sure, they have their purposes, but often it means that one or more parts of the System Under Test (SUT) are being mocked or stubbed. It's this practice that I'm not too keen about.

To have full confidence in my code, it is integration tests (or functional tests) that I'm grabbing for. In my experience, integration tests are also easier and faster to write.

With an integration test, we test the API from the outside out by spinning up the (in-memory) API client and making an actual HTTP request. I get confidence out of it because I mock as little as possible, and I will consume my API in the same way as an application (or user) would.

Note

The following tests are written in .NET 5, and are using xUnit as testrunner. For .NET 6 with minimal APIs you have to make a small tweak, which you can read in my blog post Functional Tests to support Minimal Web APIs.

A simple test link

The only requirement to write an integration test is to use the Microsoft.AspNetCore.Mvc.Testing NuGet package. You can install this package with the following command.

Tip

I also use FluentAssertions to write my assertions because it contains some useful and readable utility methods to assert that the response is correct. I also recommend AutoFixture to stop worrying about test setups.

The Microsoft.AspNetCore.Mvc.Testing packages include a WebApplicationFactory<TEntryPoint> class that spawns a TestServer instance, which runs the API in-memory during a test. This is convenient because we don't need to have the API running before we run these integration tests.

Once the application instance is created, we can create a new HTTP client to make our HTTP request. This is all that's required to write the first test.

How neat is this! To write a test that provides real value, there's (almost) no setup! The test simply spawns the application, makes a request, and verifies that it was successful.

A simple test using xUnit's Fixtures link

To make the test compact we can use the IClassFixture from xUnit. The test class now inherits from IClassFixture<WebApplicationFactory<Api.Startup>>, and gets an instance of its generic injected into the constructor. To prevent the repetition of creating a client in each test, the constructor instantiates an HttpClient and assigns it to the private _client variable, which is used in each test.

Because the test class implements the xUnit IClassFixture interface, the test cases inside the class share a single test context. Meaning that the application is now only bootstrapped once for all the test cases inside the test class, and is disposed of when all tests have been completed.

Writing your own WebApplicationFactory link

Sadly, in a real application, things get more complicated. In a real application, we have to deal with external dependencies, and these might need to be mocked. For example, to prevent that e-mails are sent as a side effect of a test.

But I do suggest keeping as many as possible real instances of dependencies that you're in control of, for example, the database. For the dependencies that are out of your reach, mostly 3rd-party driven-ports, there's a need to create mocked instances. This allows you to return expected data and prevents that test data is created in a 3rd party service.

Note

Jimmy Bogard explains why you should avoid in-memory databases for your tests in his blog post "Avoid In-Memory Databases for Tests"

The WebApplicationFactory allows you to alter the internals of the application, intervene with the pipeline of a request, or to replace objects in the Dependency Injection (DI) container.

To implement a custom WebApplicationFactory, create a new class that inherits from WebApplicationFactory.

Override Injected Instances of the DI Container link

To change the DI setup of the application, override the ConfigureWebHost method. Now, you can configure the application with your test setup by using the ConfigureTestServices extension method.

In the example below, the IWeatherForecastConfigService is configured to use a WeatherForecastConfigMock mock instance instead of its real implementation.

Test specific appsettings link

I like to run integration tests against a test-specific database because it allows running seed and teardown scripts without affecting the development or QA environment.

Therefore, create a new integrationsettings.json settings file within your test project and set the variables that need to be overwritten to run your tests. In the example below, the settings file contains the connectionstring pointing towards the integration test database.

To configure the in-memory application to use this settings file, use the ConfigureAppConfiguration extension method to add the test configuration settings.

Using the ApiWebApplicationFactory in tests link

To use your custom WebApplicationFactory, simply swap the default WebApplicationFactory class with your own implementation.

Or if you're using the xUnit fixture:

A custom and reusable xUnit fixture link

What I like to do is make each test independent of the other. This has as benefit that the test cases don't interfere with each other and that each case can be written or debugged on its own. To be able to do this, we have to perform a reseed of the database before each test is run.

Tip

To reseed the database I'm working with I use the Respawn package

Ideally, we don't want this code to leak through the test cases, these should remain compact and focused. To hide common logic (for example, clearing a database) and to keep things DRY, I create an abstraction layer.

There are multiple options to do this, but I usually introduce an abstract class, IntegrationTest. The responsibility of this class is to encapsulate the boilerplate code, and it also exposes commonly used variables within the test cases, the most important one being an HttpClient because we need it in every test to send HTTP requests to the application.

The test class can now inherit from the IntegrationTest fixture, this looks as follows.

As you can see in the code above, the test class doesn't contain setup logic because of the IntegrationTest abstraction.

I also like it because it marks the test class to contain integration tests. This makes it possible to filter a test run to only run the integration tests of your project, or to exclude them.

While the WebApplicationFactory configures the internals of the application, think of the abstract IntegrationTest class as a tool to make it easier to interact with the application.

One-off test setups link

To prevent an exponential growth of test fixtures, we can use the WithWebHostBuilder method from the WebApplicationFactory class. This is helpful for tests that require a specific setup.

The WithWebHostBuilder method will create a new instance of the WebApplicationFactory. If a custom WebApplicationFactory class is used (in this example, ApiWebApplicationFactory) the logic inside ConfigureWebHost will still be executed.

In the code below we use the InvalidWeatherForecastConfigMock class to fake an invalid configuration, which should result in a bad request. Because this setup is only required once, we can set it up inside the test itself.

Testing endpoints behind an authentication wall link

We need to have a way to make an authenticated request in order to test endpoints that require a user to be authenticated or to have a certain claim

Using a real token link

The first option is to not touch anything and to authenticate the test user before firing an HTTP request to the application. Once the token is generated it can be put aside, so you don't have to generate a token for each test case. I'm not a big fan of this method because it slows down the execution of the tests, and more importantly, these integration tests are not responsible to test the authentication nor the authorization behavior of your application. You can have a handful of tests for this, but it shouldn't intrude in every test case.

Tip

Be sure so also check out Stephan van Rooij's blog post on this topic Integration tests on protected API, which utilitizes Testcontainers and the SvRooij.Testcontainers.IdentityProxy package.

AllowAnonymousFilter link

The second and most simple option is to allow anonymous requests, this can be done by adding the AllowAnonymousFilter. For simple applications, this can be enough, but the option we'll se enect is probably the best and most flexible solution.

AuthenticationHandler link

The most complete solution to handle the authentication and authorization is to write a custom authentication handler. This gives you full control over the test user and gives you the flexibility to write multiple users for different scenarios.

This authentication handler implements AuthenticationHandler and overrides the HandleAuthenticateAsync method. Within this method, we can create a valid claim for the IntegrationTest authentication schema that represents an authenticated user.

Next, register the IntegrationTest authentication schema and configure the application to use this schema as the default within the WebApplicationFactory.

Useful utilities link

Testing multiple endpoints at once parameterized xUnit tests link

For tests that require an identical setup, we can write a Theory instead of a Fact and use InlineData to parameterize our tests. I suggest only applying this for simple requests that just verify that these endpoints don't throw an error.

Keep test cases short and readable with extension methods link

The test case should focus on its use case, and not on the technical details to arrange or assert the test case. Don't be afraid to write your own extension methods to accomplish this.

For example, invoking an API request and deserializing the response of the request adds a lot of boilerplate and duplication to a test case. To make a test concise, extract this logic and refactor it into an extension method.

If we refactor the test to use the extension method, it immediately looks better. The test case reads easier and with a single look, we can now understand the refactored test.

Parallel tests link

If multiple tests try to read and write to the same database, there's a high possibility that this may lead to deadlock errors. As a solution, turn off the parallelization of the test runner.

With xUnit, this is done by setting the parallelizeTestCollections property to false inside the xunit.runner.json config file. For more info, see the xUnit docs.

Conclusion link

Previously I didn't like to write tests for a C# API. But now that I've discovered functional testing, I enjoy writing them.

With little to no setup required, the time spent on writing tests has been cut in half (if not more!) while they provide more value. Whereas previously most of the time was spent (at least for me) on the setup of the test, and not the actual test itself. The time spent on writing them feels more like time well spent.

If you follow the theory about a refactor, you shouldn't be changing your tests. In practice, we found out (the hard way) that this is not always true. Thus, this usually also meant regression bugs in our case. Because integration tests don't care about the implementation details, it means that you won't have to refactor or rewrite previously written tests when you're refactoring application code. As maintainers of the codebase this gives us more confidence when we change, move, and delete code. The test itself almost doesn't change over time, which also trims down the time spent on the maintenance of such tests.

Does this mean I don't write unit tests? No, it does not, but I write them less than before. I only write unit tests for real business logic that don't require dependencies, just input in and output out (a pure method).

Yes, it's true that integration tests might take a bit longer to run, but it's worth it in my opinion. It also isn't that bad, we now have faster machines and a better a infratructure as before, so it isn't a big problem.

In short, I like that integration tests give me more confidence that the code we ship, is actually working the way it's intended to work. We're not mocking important parts of the application, we're testing the application as a whole.

The full example can be found on GitHub.

More resources link

Incoming links

Outgoing links

Feel free to update this blog post on GitHub, thanks in advance!

Join My Newsletter (WIP)

Join my weekly newsletter to receive my latest blog posts and bits, directly in your inbox.

Support me

I appreciate it if you would support me if have you enjoyed this post and found it useful, thank you in advance.

Buy Me a Coffee at ko-fi.com PayPal logo

Share this post