I have worked as a .NET developer for seven years. In my early days, I ignored tests because they caused a lot of delays in development, and I didn't understand the importance of testing your software. My key takeaways today are that tests:
- Force you to split your code into smaller pieces.
- Make your code easier to read.
- Make it easier for your co-workers to contribute.
- Make it easier to refactor.
- Make it easier to pick up a project you haven't touched for a long time.
- Make you more confident in your code.
All the things mentioned above will make it easier for you and the people around you to do their job, while at the same time improving the quality of your code. Therefore, I would like to show you two ways to test a controller.
The Controller
Let's establish an easy controller that uses Entity Framework and has the ability to create and return a resource.
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class CustomersController(IntroDemoContext context) : ControllerBase
{
[HttpGet("{id}", Name = "GetCustomer")]
[ProducesResponseType(typeof(CustomerDTO), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetCustomer([FromRoute] Guid id, CancellationToken cancellationToken = default)
{
var customer = await context.Customers
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (customer is null)
{
return Problem("Resource not found", statusCode: 404);
}
return Ok(new CustomerDTO(customer));
}
[HttpPost]
[ProducesResponseType(typeof(CustomerDTO), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> PostCustomer([FromBody] NewCustomerDTO newCustomerDTO, CancellationToken cancellationToken = default)
{
var duplicateName = await context.Customers
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Name.Equals(newCustomerDTO.Name), cancellationToken: cancellationToken);
if (duplicateName is not null)
{
return Problem($"An Customer with the name: {newCustomerDTO.Name} already exists", statusCode: 400);
}
var customer = new Customer(newCustomerDTO);
context.Customers.Add(customer);
await context.SaveChangesAsync(cancellationToken);
return CreatedAtRoute(nameof(GetCustomer), new { version = "1", id = customer.Id }, new CustomerDTO(customer));
}
}
Unit Tests
Before we start with the actual controller tests, lets create an easy helper for the Entity Framework DbContext. For the purpose of this guide, I'm going to use an in-memory database, but this is generally discouraged by Microsoft.
internal static class DatabaseHelper
{
internal static IntroDemoContext SetupNewDatabase()
{
// Use new guid as name to make sure that there is no conflicts with parallel tests.
var options = new DbContextOptionsBuilder<IntroDemoContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new IntroDemoContext(options);
}
}
For the actual unit tests, let's create an xUnit class with a private readonly Faker for creating customers. I'm using a NuGet package called Bogus, which allows me to create resources with random data.
public class CustomersControllerTests
{
private readonly Faker<Customer> _faker = new Faker<Customer>()
.RuleFor(x => x.Name, f => f.Name.FirstName())
.RuleFor(x => x.Id, f => f.Random.Guid());
}
Lets go ahead and create the first test for retrieving one customer.
- Give it a descriptive name.
- Create the expected DbContext and add a customer to the database.
- Create a new CustomerController.
- Retrieve the customer and check that the returned result is correct.
[Fact]
public async Task GetCustomer_ReturnsOkResult()
{
// Arrange
var customer = _faker.Generate();
var context = DatabaseHelper.SetupNewDatabase();
// Add data to database
context.Customers.AddRange(customer);
await context.SaveChangesAsync();
var sut = new CustomersController(context);
// Act
var result = await sut.GetCustomer(customer.Id);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result);
var responseData = okResult.Value as CustomerDTO;
Assert.NotNull(responseData);
Assert.Equal(customer.Id, responseData.Id);
Assert.Equal(customer.Name, responseData.Name);
}
We would also like to test the creation of a new customer.
- Give it a descriptive name.
- Create the expected DbContext.
- Create a new CustomerController.
- Post a new Customer and check that the returned result is as expected.
[Fact]
public async Task PostCustomer_ReturnsCreatedResult_WhenCustomerIsCreated()
{
// Arrange
var context = DatabaseHelper.SetupNewDatabase();
var sut = new CustomersController(context);
var newCustomerDTO = new NewCustomerDTO()
{
Name = "Foo"
};
// Act
var result = await sut.PostCustomer(newCustomerDTO);
// Assert
var okResult = Assert.IsType<CreatedAtRouteResult>(result);
var responseData = okResult.Value as CustomerDTO;
Assert.NotNull(responseData);
Assert.Equal(newCustomerDTO.Name, responseData.Name);
}
Integration Tests
For integration tests, there is a bit more configuration required, but they will take a higher approach and test larger portions of the application in one go. We have to use WebApplicationFactory and customize it to work with both authentication and Entity Framework.
To make the authentication work, we will have to create an AuthHandler where we accept custom claims.
internal class TestAuthHelper(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
Claim[] claims =
[
new Claim(ClaimTypes.Name, "FirstName LastName"),
new Claim(ClaimConstants.ObjectId, Guid.NewGuid().ToString())
];
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, "Test");
var result = AuthenticateResult.Success(ticket);
return Task.FromResult(result);
}
}
We also need to create a helper for the database context, and once again I will use an in-memory database. The in-memory database is easy to use and it works without any config in GitLab/GitHub/Azure DevOps pipelines. In this class, I'm using a lock to ensure that the database initialization is thread-safe.
internal static class DatabaseHelper
{
private static readonly object _lock = new();
private static bool _databaseInitialized;
internal static void InitializeDatabase(IntroDemoContext context)
{
lock (_lock)
{
if (!_databaseInitialized)
{
context.Database.EnsureDeleted();
context.Database.EnsureCreated();
_databaseInitialized = true;
}
}
}
}
Once we have created most of the configuration, we can begin customizing the WebApplicationFactory. Every service applied in this factory will override the services specified in program.cs. Therefore, we will add our custom authentication and in-memory database here. All other services in program.cs will behave as they would if the application were started manually, unless you override them.
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Test");
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHelper>("Test", options => { });
var dbDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<IntroDemoContext>));
if (dbDescriptor != null)
{
services.Remove(dbDescriptor);
}
var databaseName = Guid.NewGuid().ToString();
services.AddDbContext<IntroDemoContext>(options =>
{
options.UseInMemoryDatabase(databaseName);
});
var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
var scopedServices = scope.ServiceProvider;
var databaseContext = scopedServices.GetRequiredService<IntroDemoContext>();
try
{
DatabaseHelper.InitializeDatabase(databaseContext);
}
catch (Exception ex)
{
Log.Error(ex, "An error occured while initializing the database");
}
});
}
}
When the CustomWebApplication is ready, we can create the CustomerControllerTests class and use our custom config. I'm still using Bogus to generate resources and the factory to actually call the endpoints.
Lets create the tests we want:
- Give them a descriptive name.
- Create the expected DbContext and add a customers to the database.
- Create the httpClient used to call the endpoints.
- Retrieve the customer and check that the returned result is correct.
- Post a new customer and check that the returned result is correct.
public class CustomerControllerTests(CustomWebApplicationFactory factory) : IClassFixture<CustomWebApplicationFactory>
{
private readonly Faker<Customer> _faker = new Faker<Customer>()
.RuleFor(x => x.Name, f => f.Name.FirstName())
.RuleFor(x => x.Id, f => f.Random.Guid());
[Fact]
public async Task GetCustomers_ReturnsOkResult()
{
// Arrange
var httpClient = factory.CreateClient();
var context = factory.Services.GetRequiredService<IntroDemoContext>();
var customer = _faker.Generate();
context.Customers.Add(customer);
await context.SaveChangesAsync();
//Act
var response = await httpClient.GetAsync($"api/v1/Customers/{customer.Id}");
var responseData = await response.Content.ReadFromJsonAsync<CustomerDTO>();
//Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(responseData);
Assert.Equal(customer.Id, responseData.Id);
Assert.Equal(customer.Name, responseData.Name);
}
[Fact]
public async Task PostCustomer_ReturnsCreatedResult_WhenCustomerIsCreated()
{
// Arrange
var httpClient = factory.CreateClient();
var newCustomerDTO = new NewCustomerDTO()
{
Name = "Foo"
};
// Act
var response = await httpClient.PostAsJsonAsync("api/v1/Customers", newCustomerDTO);
var responseData = await response.Content.ReadFromJsonAsync<CustomerDTO>();
//Assert
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(responseData);
Assert.Equal(newCustomerDTO.Name, responseData.Name);
}
}
Conclusion
Testing your API endpoints is essential to ensure that a system is manageable and works as intended. It will force you and your colleagues to think more about the code structure and increase the overall confidence. You can mix and match both unit tests and integration tests, where unit tests will ensure that components work as expected, and integration tests provide a higher-level assessment of the entire application. Remember that AI is a very good tool for writing tests, and with just a few good prompts it will be able to write pretty good tests and also a lot of configuration.