code-snippets/docs/dotnet/controller-testing.md
Liam Pietralla 8ad5845efc
Some checks failed
Build, Test & Publish / Build and Publish Container Image (push) Has been cancelled
Build, Test & Publish / Deploy to Infrastructure (push) Has been cancelled
Build, Test & Publish / Build (push) Has been cancelled
initial commit
2024-09-05 13:54:08 +10:00

488 lines
15 KiB
Markdown

# Testing Controller
Testing controllers can be an important part of your application testing strategy. Controllers are the entry point for your application and are responsible for handling requests and responses. In this guide we will cover how to test controllers in a Web API application.
Testing controllers is a little more tricky but the best method is to actually run a test version of your app, and interact with it using HTTP requests. This style of testing is known as integration testing and is a great way to test the entire application stack, even though we will often use a in-memory database to avoid the need for a real database.
.NET provides the `WebApplicationFactory` class to help with this. This class allows you to create a test server that can be used to send HTTP requests to your application.
# Example Application
In this example we will create a simple Web API to maintain a user list. To get started create a new Web API project titled `UserApp` and enabled controller support.
## User Model and DB Context
Create a new `Data` folder and insert the following files:
::: code-group
```csharp [User.cs]
namespace UserApp.Data
{
public class User
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
}
}
```
:::
::: code-group
```csharp [UserAppContext.cs]
using Microsoft.EntityFrameworkCore;
namespace UserApp.Data
{
public class UserAppContext : DbContext
{
public UserAppContext(DbContextOptions<UserAppContext> options) : base(options) { }
public DbSet<User> Users { get; set; }
}
}
```
:::
::: code-group
```csharp [UserViewModel.cs]
namespace UserApp.Models
{
public class UserViewModel(string email, string firstName, string lastName)
{
public string Email { get; set; } = email;
public string FirstName { get; set; } = firstName;
public string LastName { get; set; } = lastName;
}
}
```
:::
As part of this ensure the following NuGet packages are installed:
* Microsoft.EntityFrameworkCore
## Service Interface and Implementation
First create a entity not found exception:
::: code-group
```csharp [EntityNotFoundException.cs]
namespace UserApp.Infrastructure.Exceptions
{
public class EntityNotFoundException(string message) : Exception(message) { }
}
```
:::
Then we can create the service interface and implementation:
::: code-group
```csharp [IUserService.cs]
using UserApp.Data;
namespace UserApp.Services
{
public interface IUserService
{
public Task<IEnumerable<User>> GetUsersAsync();
public Task<User> GetUserAsync(int id);
public Task<User> AddUserAsync(string email, string firstName, string lastName);
public Task<User> UpdateUserAsync(int id, string email, string firstName, string lastName);
public Task<User> DeleteUserAsync(int id);
}
}
```
```csharp [UserService.cs]
using Microsoft.EntityFrameworkCore;
using UserApp.Data;
using UserApp.Infrastructure.Exceptions;
namespace UserApp.Services
{
public class UserService(UserAppContext context) : IUserService
{
private readonly UserAppContext _context = context;
public async Task<User> AddUserAsync(string email, string firstName, string lastName)
{
User user = new()
{
Email = email,
FirstName = firstName,
LastName = lastName
};
await _context.Users.AddAsync(user);
await _context.SaveChangesAsync();
return user;
}
public async Task<User> DeleteUserAsync(int id)
{
User? user = await _context.Users.FindAsync(id) ?? throw new EntityNotFoundException("User not found");
_context.Users.Remove(user);
await _context.SaveChangesAsync();
return user;
}
public async Task<User> GetUserAsync(int id)
{
User user = await _context.Users.FindAsync(id) ?? throw new EntityNotFoundException("User not found");
return user;
}
public async Task<IEnumerable<User>> GetUsersAsync()
{
return await _context.Users.ToListAsync();
}
public async Task<User> UpdateUserAsync(int id, string email, string firstName, string lastName)
{
User user = await _context.Users.FindAsync(id) ?? throw new EntityNotFoundException("User not found");
user.Email = email;
user.FirstName = firstName;
user.LastName = lastName;
await _context.SaveChangesAsync();
return user;
}
}
}
```
:::
## Setup Dependency Injection
We now need to add our DI config to the `Program.cs` file. Add the following to the service section, you will also need to install the following NuGet packages:
* Microsoft.EntityFrameworkCore.Sqlite
* Microsoft.EntityFrameworkCore.Design
* Microsoft.EntityFrameworkCore.Tools
::: code-group
```csharp [Program.cs]
builder.Services.AddDbContext<UserAppContext>(options =>
{
options.UseSqlite("UserApp");
});
builder.Services.AddScoped<IUserService, UserService>();
```
:::
Once this is done we can generate the database migrations that we need:
::: code-group
```bash [dotnet console]
dotnet ef migrations add InitialCreate
```
```bash [package manager console]
Add-Migration InitialCreate
```
:::
## Controller
Finally we can create a controller to interact with the service:
::: code-group
```csharp [UsersController.cs]
using Microsoft.AspNetCore.Mvc;
using UserApp.Data;
using UserApp.Infrastructure.Exceptions;
using UserApp.Models;
using UserApp.Services;
namespace UserApp.Controllers
{
[Route("api/[controller]")]
public class UserController(IUserService userService, ILogger<UserController> logger) : ControllerBase
{
private readonly ILogger<UserController> _logger = logger;
private readonly IUserService _userService = userService;
[HttpGet]
public async Task<IActionResult> GetUsersAsync()
{
try
{
return Ok(await _userService.GetUsersAsync());
}
catch (Exception exception)
{
_logger.LogError(exception, "An error occurred while getting the users");
return StatusCode(500);
}
}
[HttpGet("{id}")]
public async Task<IActionResult> GetUserAsync(int id)
{
try
{
return Ok(await _userService.GetUserAsync(id));
}
catch (EntityNotFoundException exception)
{
_logger.LogError(exception, "User not found");
return NotFound();
}
catch (Exception exception)
{
_logger.LogError(exception, "An error occurred while getting the user");
return StatusCode(500);
}
}
[HttpPost]
public async Task<IActionResult> AddUserAsync([FromBody] UserViewModel user)
{
try
{
User createdUser = await _userService.AddUserAsync(user.Email, user.FirstName, user.LastName);
return CreatedAtAction("GetUser", new { id = createdUser.Id }, createdUser);
}
catch (Exception exception)
{
_logger.LogError(exception, "An error occurred while adding the user");
return StatusCode(500);
}
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateUserAsync(int id, [FromBody] UserViewModel user)
{
try
{
return Ok(await _userService.UpdateUserAsync(id, user.Email, user.FirstName, user.LastName));
}
catch (EntityNotFoundException exception)
{
_logger.LogError(exception, "User not found");
return NotFound();
}
catch (Exception exception)
{
_logger.LogError(exception, "An error occurred while updating the user");
return StatusCode(500);
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUserAsync(int id)
{
try
{
return Ok(await _userService.DeleteUserAsync(id));
}
catch (EntityNotFoundException exception)
{
_logger.LogError(exception, "User not found");
return NotFound();
}
catch (Exception exception)
{
_logger.LogError(exception, "An error occurred while deleting the user");
return StatusCode(500);
}
}
}
}
```
:::
# Controller Testing
To test the controller we will create a new MSTest Test project called `UserApp.Tests`. Add the following NuGet packages:
* Microsoft.EntityFrameworkCore.Sqlite
* Microsoft.AspNetCore.Mvc.Testing
* Microsoft.EntityFrameworkCore.Sqlite
## Test Setup
For this example we are going to be using a SQLite database for testing the application. This is not needed for this paticular example, as the code is already using an in-memory database, but it is a good example showing how to override the database connection for testing.
In an actual system you would want to use a real database for testing, such as one spun up in a docker container in the CI.
To setup the test project add the following to the `Program.cs` file:
::: code-group
```csharp [Program.cs]
// ...
public partial class Program { }
```
:::
Now we want to create a new factory to generate the test server:
::: code-group
```csharp [Factories/UserAppApplicationFactory.cs]
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using UserApp.Data;
namespace UserApp.Tests.Factories
{
public class UserAppApplicationFactory<TProgram>(object[] existingEntites = null!) : WebApplicationFactory<TProgram> where TProgram : class
{
private readonly object[] _existingEntites = existingEntites ?? [];
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<UserAppContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
services.AddDbContext<UserAppContext>((container, options) =>
{
options.UseNpgsql("Server=localhost;Port=5432;Database=postgres;User Id=postgres;Password=postgres;");
});
// After the DbContext is registered, we need to create the database
using var scope = services.BuildServiceProvider().CreateScope();
var context = scope.ServiceProvider.GetRequiredService<UserAppContext>();
context.Database.ExecuteSqlRaw(DB_CLEAR);
context.Database.Migrate();
// Add any existing entities to the context
foreach (var entity in _existingEntites)
{
context.Add(entity);
}
if (_existingEntites.Length != 0)
{
context.SaveChanges();
}
});
}
public string DB_CLEAR = """
DROP TABLE IF EXISTS "Users";
DROP TABLE IF EXISTS "__EFMigrationsHistory";
""";
}
}
```
:::
::: danger
NOTE: The above code will delete any existing data in the database, so be careful when using this in a real application. Always make sure the connection string is pointing to a test database.
:::
This setup requires that a Postgres database is running, the best way to do this is to use a docker container (which is what will occur in the CI as well). A compose is below:
```yaml
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5432:5432"
```
# Writing Controller Tests
Now we can write some tests for the controller. Create a new file called `UsersControllerTests.cs` and add the following:
::: code-group
```csharp [UsersControllerTests.cs]
using Newtonsoft.Json;
using UserApp.Data;
using UserApp.Tests.Factories;
namespace UserApp.Tests
{
[TestClass]
public class UserControllerTests
{
private HttpClient _client = null!;
private UserAppApplicationFactory<Program> _factory = null!;
[TestCleanup]
public void Cleanup()
{
_client.Dispose();
_factory.Dispose();
}
[TestMethod]
public async Task GetUsers_ForNoUsers_ReturnsSuccessAndNoUsers()
{
// Arrange
_factory = new UserAppApplicationFactory<Program>();
_client = _factory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, "/api/user");
// Act
var response = await _client.SendAsync(request);
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
Assert.AreEqual("[]", content);
}
[TestMethod]
public async Task GetUsers_ForUsers_ReturnsSuccessAndUsers()
{
// Arrange
User user1 = new()
{
Id = 1,
FirstName = "John",
LastName = "Doe",
Email = "john.doe@test.com"
};
User user2 = new()
{
Id = 2,
FirstName = "Jane",
LastName = "Doe",
Email = "jane.doe@test.com"
};
_factory = new UserAppApplicationFactory<Program>([user1, user2]);
_client = _factory.CreateClient();
// Act
var response = await _client.GetAsync("/api/user");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var users = JsonConvert.DeserializeObject<User[]>(content);
Assert.AreEqual(2, users!.Length);
Assert.AreEqual("john.doe@test.com", users[0].Email);
Assert.AreEqual("jane.doe@test.com", users[1].Email);
}
}
}
```
:::