39. Testing Controllers
About this chapter
Test controllers in isolation by mocking repositories to verify correct HTTP responses, model validation, authorization, and error handling.
- Why test controllers: Verifying HTTP responses, validation, and authorization
- Controller isolation: Mocking repositories for unit-level testing
- Testing action results: Verifying 200, 201, 400, 404, 500 responses
- Model validation: Testing that invalid input is properly rejected
- Authorization testing: Verifying [Authorize] attributes are enforced
- DTO transformation: Testing AutoMapper integration
Learning outcomes:
- Unit test controllers with mocked dependencies
- Verify correct HTTP status codes are returned
- Test model validation and error responses
- Test authorization attributes work correctly
- Mock repositories and mappers
- Write comprehensive controller tests
39.1 Why Test Controllers?
Controllers are the entry point to your API. Testing them ensures:
- Correct HTTP responses (200, 201, 400, 404, etc.)
- Model validation works (invalid input rejected)
- Authorization enforced (only authenticated/authorized users can access)
- DTOs transformed correctly (domain models → DTOs)
- Error handling consistent (exceptions → proper responses)
The pyramid revisited:
Fewer, slower, more integration-like tests
/\
/ \
/ \ Integration (WebApplicationFactory)
/------\
/ \
/ \ Controller Unit Tests (mocked repos)
/------------\
/ \
/________________\ Repository Unit Tests (in-memory DB)
More, faster, more isolated tests
Controller unit tests sit in the middle—they test the controller logic in isolation.
39.2 The Controller to Test
Domain model:
// Models/Command.cs
public class Command
{
public int Id { get; set; }
public string HowTo { get; set; }
public string CommandLine { get; set; }
public int PlatformId { get; set; }
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
}
// Models/CommandReadDto.cs
public class CommandReadDto
{
public int Id { get; set; }
public string HowTo { get; set; }
public string CommandLine { get; set; }
public int PlatformId { get; set; }
}
// Models/CommandMutateDto.cs
public class CommandMutateDto
{
[Required]
[MaxLength(250)]
public string HowTo { get; set; }
[Required]
public string CommandLine { get; set; }
[Required]
public int PlatformId { get; set; }
}
The controller:
// Controllers/CommandsController.cs
[ApiController]
[Route("api/[controller]")]
public class CommandsController : ControllerBase
{
private readonly ICommandRepository _repository;
private readonly IMapper _mapper;
private readonly ILogger<CommandsController> _logger;
public CommandsController(
ICommandRepository repository,
IMapper mapper,
ILogger<CommandsController> logger)
{
_repository = repository;
_mapper = mapper;
_logger = logger;
}
[HttpGet("{id}")]
public async Task<ActionResult<CommandReadDto>> GetCommandById(int id)
{
_logger.LogInformation("Getting command {CommandId}", id);
var command = await _repository.GetCommandByIdAsync(id);
if (command == null)
{
_logger.LogWarning("Command {CommandId} not found", id);
return NotFound();
}
return Ok(_mapper.Map<CommandReadDto>(command));
}
[HttpGet]
public async Task<ActionResult<IEnumerable<CommandReadDto>>> GetCommands()
{
var commands = await _repository.GetCommandsAsync();
return Ok(_mapper.Map<IEnumerable<CommandReadDto>>(commands));
}
[HttpPost]
public async Task<ActionResult<CommandReadDto>> CreateCommand(CommandMutateDto createDto)
{
var command = _mapper.Map<Command>(createDto);
_repository.CreateCommand(command);
await _repository.SaveChangesAsync();
_logger.LogInformation("Created command {CommandId}", command.Id);
return CreatedAtRoute(nameof(GetCommandById),
new { id = command.Id },
_mapper.Map<CommandReadDto>(command));
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateCommand(int id, CommandMutateDto updateDto)
{
var command = await _repository.GetCommandByIdAsync(id);
if (command == null)
{
return NotFound();
}
_mapper.Map(updateDto, command);
_repository.UpdateCommand(command);
await _repository.SaveChangesAsync();
_logger.LogInformation("Updated command {CommandId}", id);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteCommand(int id)
{
var command = await _repository.GetCommandByIdAsync(id);
if (command == null)
{
return NotFound();
}
_repository.DeleteCommand(command);
await _repository.SaveChangesAsync();
_logger.LogInformation("Deleted command {CommandId}", id);
return NoContent();
}
}
39.3 Testing GET Endpoints
Get single resource:
// Tests/ControllersTests/CommandsControllerTests.cs
using Xunit;
using Moq;
using AutoMapper;
using Microsoft.Extensions.Logging;
using CommandAPI.Controllers;
using CommandAPI.Models;
using CommandAPI.Repositories;
using Microsoft.AspNetCore.Mvc;
public class CommandsControllerTests
{
private readonly Mock<ICommandRepository> _mockRepository;
private readonly Mock<IMapper> _mockMapper;
private readonly Mock<ILogger<CommandsController>> _mockLogger;
private readonly CommandsController _controller;
public CommandsControllerTests()
{
_mockRepository = new Mock<ICommandRepository>();
_mockMapper = new Mock<IMapper>();
_mockLogger = new Mock<ILogger<CommandsController>>();
_controller = new CommandsController(
_mockRepository.Object,
_mockMapper.Object,
_mockLogger.Object);
}
[Fact]
public async Task GetCommandById_WithValidId_ReturnsOkWithDto()
{
// Arrange
var commandId = 1;
var command = new Command
{
Id = commandId,
HowTo = "List files",
CommandLine = "ls -la",
PlatformId = 1
};
var commandDto = new CommandReadDto
{
Id = commandId,
HowTo = "List files",
CommandLine = "ls -la",
PlatformId = 1
};
_mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync(command);
_mockMapper.Setup(x => x.Map<CommandReadDto>(command))
.Returns(commandDto);
// Act
var result = await _controller.GetCommandById(commandId);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
Assert.Equal(200, okResult.StatusCode);
var returnedDto = Assert.IsType<CommandReadDto>(okResult.Value);
Assert.Equal(commandId, returnedDto.Id);
Assert.Equal("List files", returnedDto.HowTo);
_mockRepository.Verify(x => x.GetCommandByIdAsync(commandId), Times.Once);
_mockMapper.Verify(x => x.Map<CommandReadDto>(command), Times.Once);
}
[Fact]
public async Task GetCommandById_WithInvalidId_ReturnsNotFound()
{
// Arrange
var commandId = 9999;
_mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync((Command)null);
// Act
var result = await _controller.GetCommandById(commandId);
// Assert
var notFoundResult = Assert.IsType<NotFoundResult>(result.Result);
Assert.Equal(404, notFoundResult.StatusCode);
_mockRepository.Verify(x => x.GetCommandByIdAsync(commandId), Times.Once);
_mockMapper.Verify(x => x.Map<CommandReadDto>(It.IsAny<Command>()), Times.Never);
}
[Fact]
public async Task GetCommands_ReturnsOkWithDtos()
{
// Arrange
var commands = new List<Command>
{
new Command { Id = 1, HowTo = "List", CommandLine = "ls", PlatformId = 1 },
new Command { Id = 2, HowTo = "Copy", CommandLine = "cp", PlatformId = 1 }
};
var commandDtos = new List<CommandReadDto>
{
new CommandReadDto { Id = 1, HowTo = "List", CommandLine = "ls", PlatformId = 1 },
new CommandReadDto { Id = 2, HowTo = "Copy", CommandLine = "cp", PlatformId = 1 }
};
_mockRepository.Setup(x => x.GetCommandsAsync())
.ReturnsAsync(commands);
_mockMapper.Setup(x => x.Map<IEnumerable<CommandReadDto>>(commands))
.Returns(commandDtos);
// Act
var result = await _controller.GetCommands();
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
Assert.Equal(200, okResult.StatusCode);
var returnedDtos = Assert.IsType<List<CommandReadDto>>(okResult.Value);
Assert.Equal(2, returnedDtos.Count);
_mockRepository.Verify(x => x.GetCommandsAsync(), Times.Once);
}
}
39.4 Testing POST Endpoints
Create resource:
[Fact]
public async Task CreateCommand_WithValidDto_ReturnsCreatedAtRoute()
{
// Arrange
var createDto = new CommandMutateDto
{
HowTo = "Deploy app",
CommandLine = "docker push",
PlatformId = 1
};
var command = new Command
{
Id = 1,
HowTo = createDto.HowTo,
CommandLine = createDto.CommandLine,
PlatformId = createDto.PlatformId
};
var readDto = new CommandReadDto
{
Id = 1,
HowTo = createDto.HowTo,
CommandLine = createDto.CommandLine,
PlatformId = createDto.PlatformId
};
_mockMapper.Setup(x => x.Map<Command>(createDto))
.Returns(command);
_mockRepository.Setup(x => x.SaveChangesAsync())
.Returns(Task.CompletedTask);
_mockMapper.Setup(x => x.Map<CommandReadDto>(command))
.Returns(readDto);
// Act
var result = await _controller.CreateCommand(createDto);
// Assert
var createdResult = Assert.IsType<CreatedAtRouteResult>(result.Result);
Assert.Equal(201, createdResult.StatusCode);
Assert.Equal(nameof(CommandsController.GetCommandById), createdResult.RouteName);
Assert.Equal(new { id = 1 }, createdResult.RouteValues);
var returnedDto = Assert.IsType<CommandReadDto>(createdResult.Value);
Assert.Equal("Deploy app", returnedDto.HowTo);
_mockRepository.Verify(x => x.CreateCommand(It.IsAny<Command>()), Times.Once);
_mockRepository.Verify(x => x.SaveChangesAsync(), Times.Once);
}
[Fact]
public async Task CreateCommand_WhenRepositoryThrows_ThrowsException()
{
// Arrange
var createDto = new CommandMutateDto
{
HowTo = "Test",
CommandLine = "test",
PlatformId = 1
};
var command = new Command { HowTo = createDto.HowTo, CommandLine = createDto.CommandLine, PlatformId = createDto.PlatformId };
_mockMapper.Setup(x => x.Map<Command>(createDto))
.Returns(command);
_mockRepository.Setup(x => x.SaveChangesAsync())
.ThrowsAsync(new Exception("Database error"));
// Act & Assert
await Assert.ThrowsAsync<Exception>(
() => _controller.CreateCommand(createDto));
}
39.5 Testing PUT/PATCH Endpoints
Update resource:
[Fact]
public async Task UpdateCommand_WithValidId_ReturnsNoContent()
{
// Arrange
var commandId = 1;
var updateDto = new CommandMutateDto
{
HowTo = "Updated command",
CommandLine = "updated -cmd",
PlatformId = 1
};
var existingCommand = new Command
{
Id = commandId,
HowTo = "Old",
CommandLine = "old",
PlatformId = 1
};
_mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync(existingCommand);
_mockMapper.Setup(x => x.Map(updateDto, existingCommand))
.Callback<CommandMutateDto, Command>((src, dest) =>
{
dest.HowTo = src.HowTo;
dest.CommandLine = src.CommandLine;
dest.PlatformId = src.PlatformId;
});
_mockRepository.Setup(x => x.SaveChangesAsync())
.Returns(Task.CompletedTask);
// Act
var result = await _controller.UpdateCommand(commandId, updateDto);
// Assert
var noContentResult = Assert.IsType<NoContentResult>(result);
Assert.Equal(204, noContentResult.StatusCode);
_mockRepository.Verify(x => x.GetCommandByIdAsync(commandId), Times.Once);
_mockMapper.Verify(x => x.Map(updateDto, existingCommand), Times.Once);
_mockRepository.Verify(x => x.UpdateCommand(existingCommand), Times.Once);
_mockRepository.Verify(x => x.SaveChangesAsync(), Times.Once);
}
[Fact]
public async Task UpdateCommand_WithInvalidId_ReturnsNotFound()
{
// Arrange
var commandId = 9999;
var updateDto = new CommandMutateDto
{
HowTo = "Test",
CommandLine = "test",
PlatformId = 1
};
_mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync((Command)null);
// Act
var result = await _controller.UpdateCommand(commandId, updateDto);
// Assert
var notFoundResult = Assert.IsType<NotFoundResult>(result);
Assert.Equal(404, notFoundResult.StatusCode);
_mockRepository.Verify(x => x.GetCommandByIdAsync(commandId), Times.Once);
_mockRepository.Verify(x => x.SaveChangesAsync(), Times.Never);
}
39.6 Testing DELETE Endpoints
Delete resource:
[Fact]
public async Task DeleteCommand_WithValidId_ReturnsNoContent()
{
// Arrange
var commandId = 1;
var command = new Command
{
Id = commandId,
HowTo = "Delete me",
CommandLine = "rm",
PlatformId = 1
};
_mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync(command);
_mockRepository.Setup(x => x.SaveChangesAsync())
.Returns(Task.CompletedTask);
// Act
var result = await _controller.DeleteCommand(commandId);
// Assert
var noContentResult = Assert.IsType<NoContentResult>(result);
Assert.Equal(204, noContentResult.StatusCode);
_mockRepository.Verify(x => x.GetCommandByIdAsync(commandId), Times.Once);
_mockRepository.Verify(x => x.DeleteCommand(command), Times.Once);
_mockRepository.Verify(x => x.SaveChangesAsync(), Times.Once);
}
[Fact]
public async Task DeleteCommand_WithInvalidId_ReturnsNotFound()
{
// Arrange
var commandId = 9999;
_mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync((Command)null);
// Act
var result = await _controller.DeleteCommand(commandId);
// Assert
var notFoundResult = Assert.IsType<NotFoundResult>(result);
Assert.Equal(404, notFoundResult.StatusCode);
_mockRepository.Verify(x => x.GetCommandByIdAsync(commandId), Times.Once);
_mockRepository.Verify(x => x.DeleteCommand(It.IsAny<Command>()), Times.Never);
}
39.7 Testing Model Validation
ASP.NET Core validates models automatically, but we can test the validation logic:
[Fact]
public async Task CreateCommand_WithMissingHowTo_ReturnsBadRequest()
{
// Arrange
var createDto = new CommandMutateDto
{
HowTo = null, // Required!
CommandLine = "test",
PlatformId = 1
};
// Model validation happens automatically in controller
// This test verifies the ModelState is checked
_controller.ModelState.AddModelError("HowTo", "The HowTo field is required.");
// Act
// You need to check ModelState in your controller:
if (!_controller.ModelState.IsValid)
{
var result = new BadRequestObjectResult(_controller.ModelState);
// Assert
Assert.Equal(400, result.StatusCode);
}
}
[Fact]
public async Task CreateCommand_WithHowToTooLong_InvalidatesModel()
{
// Arrange
var createDto = new CommandMutateDto
{
HowTo = new string('a', 251), // MaxLength is 250
CommandLine = "test",
PlatformId = 1
};
_controller.ModelState.AddModelError("HowTo", "The field HowTo must be a string with a maximum length of 250.");
// Assert
Assert.False(_controller.ModelState.IsValid);
}
Better approach—use FluentValidation:
// Validators/CommandMutateDtoValidator.cs
using FluentValidation;
using CommandAPI.Models;
public class CommandMutateDtoValidator : AbstractValidator<CommandMutateDto>
{
public CommandMutateDtoValidator()
{
RuleFor(x => x.HowTo)
.NotEmpty().WithMessage("HowTo is required")
.MaximumLength(250).WithMessage("HowTo cannot exceed 250 characters");
RuleFor(x => x.CommandLine)
.NotEmpty().WithMessage("CommandLine is required");
RuleFor(x => x.PlatformId)
.GreaterThan(0).WithMessage("PlatformId must be greater than 0");
}
}
// Test FluentValidation
public class CommandMutateDtoValidatorTests
{
private readonly CommandMutateDtoValidator _validator;
public CommandMutateDtoValidatorTests()
{
_validator = new CommandMutateDtoValidator();
}
[Fact]
public void Validate_WithValidDto_IsValid()
{
// Arrange
var dto = new CommandMutateDto
{
HowTo = "List files",
CommandLine = "ls -la",
PlatformId = 1
};
// Act
var result = _validator.Validate(dto);
// Assert
Assert.True(result.IsValid);
}
[Fact]
public void Validate_WithMissingHowTo_IsInvalid()
{
// Arrange
var dto = new CommandMutateDto
{
HowTo = null,
CommandLine = "ls -la",
PlatformId = 1
};
// Act
var result = _validator.Validate(dto);
// Assert
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.PropertyName == "HowTo");
}
}
39.8 Testing Authorization
Secured controller:
[ApiController]
[Route("api/[controller]")]
[Authorize] // Requires authentication
public class AdminCommandsController : ControllerBase
{
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")] // Requires Admin role
public async Task<IActionResult> DeleteCommand(int id)
{
// ... delete logic ...
return NoContent();
}
}
// Test
public class AdminCommandsControllerTests
{
private readonly Mock<ICommandRepository> _mockRepository;
private readonly Mock<IMapper> _mockMapper;
private readonly Mock<ILogger<AdminCommandsController>> _mockLogger;
private readonly AdminCommandsController _controller;
public AdminCommandsControllerTests()
{
_mockRepository = new Mock<ICommandRepository>();
_mockMapper = new Mock<IMapper>();
_mockLogger = new Mock<ILogger<AdminCommandsController>>();
_controller = new AdminCommandsController(
_mockRepository.Object,
_mockMapper.Object,
_mockLogger.Object);
// Simulate authenticated user with Admin role
var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.NameIdentifier, "123"),
new Claim(ClaimTypes.Role, "Admin")
}, "TestAuthType"));
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
};
}
[Fact]
public async Task DeleteCommand_WithAdminRole_ReturnsNoContent()
{
// Arrange
var commandId = 1;
var command = new Command { Id = commandId, HowTo = "test", CommandLine = "test", PlatformId = 1 };
_mockRepository.Setup(x => x.GetCommandByIdAsync(commandId))
.ReturnsAsync(command);
_mockRepository.Setup(x => x.SaveChangesAsync())
.Returns(Task.CompletedTask);
// Act
var result = await _controller.DeleteCommand(commandId);
// Assert
var noContentResult = Assert.IsType<NoContentResult>(result);
Assert.Equal(204, noContentResult.StatusCode);
}
[Fact]
public async Task DeleteCommand_WithoutAdminRole_Forbidden()
{
// Arrange
var commandId = 1;
var user = new ClaimsPrincipal(new ClaimsIdentity(new Claim[]
{
new Claim(ClaimTypes.NameIdentifier, "123"),
new Claim(ClaimTypes.Role, "User") // Not Admin
}, "TestAuthType"));
_controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
};
// With [Authorize(Roles = "Admin")], ASP.NET Core prevents the action from being called
// In a real test, this is caught by the authorization middleware
// Unit tests can't easily test this—integration tests are better
// For unit testing, you might check if claims exist:
var isAdmin = _controller.User.IsInRole("Admin");
// Assert
Assert.False(isAdmin);
}
}
39.9 Testing with Mocking Best Practices
Use It.Is<T>() for precise assertions:
// ❌ VAGUE - Matches any Command
_mockRepository.Setup(x => x.CreateCommand(It.IsAny<Command>()))
.Callback<Command>(c => c.Id = 1);
// ✓ PRECISE - Matches specific properties
_mockRepository.Setup(x => x.CreateCommand(It.Is<Command>(c =>
c.HowTo == "List files" && c.CommandLine == "ls -la")))
.Callback<Command>(c => c.Id = 1);
Setup sequences for complex scenarios:
// Repository returns different commands on each call
_mockRepository.SetupSequence(x => x.GetCommandByIdAsync(It.IsAny<int>()))
.ReturnsAsync(new Command { Id = 1, HowTo = "First" })
.ReturnsAsync(new Command { Id = 2, HowTo = "Second" })
.ThrowsAsync(new Exception("Third call fails"));
Verify interaction count:
_mockRepository.Verify(
x => x.SaveChangesAsync(),
Times.Exactly(1)); // Called exactly once
_mockRepository.Verify(
x => x.GetCommandByIdAsync(It.IsAny<int>()),
Times.AtLeastOnce); // Called at least once
_mockRepository.Verify(
x => x.DeleteCommand(It.IsAny<Command>()),
Times.Never); // Never called
39.10 Common Controller Testing Pitfalls
Pitfall 1: Not checking HTTP status codes
// ❌ WRONG - Only checks the value
var result = await _controller.GetCommandById(1);
var dto = Assert.IsType<CommandReadDto>(result.Value);
// ✓ CORRECT - Checks the action result type and status
var result = await _controller.GetCommandById(1);
var okResult = Assert.IsType<OkObjectResult>(result.Result);
Assert.Equal(200, okResult.StatusCode);
Pitfall 2: Forgetting to setup mapper
// ❌ WRONG - Mapper throws null reference
var result = await _controller.GetCommandById(1);
// ✓ CORRECT - Mapper configured
_mockMapper.Setup(x => x.Map<CommandReadDto>(It.IsAny<Command>()))
.Returns(new CommandReadDto());
var result = await _controller.GetCommandById(1);
Pitfall 3: Not verifying method calls
// ❌ WRONG - Tests pass action result but not behavior
var result = await _controller.CreateCommand(dto);
Assert.IsType<CreatedAtRouteResult>(result.Result);
// ✓ CORRECT - Also verifies repository was called
var result = await _controller.CreateCommand(dto);
Assert.IsType<CreatedAtRouteResult>(result.Result);
_mockRepository.Verify(x => x.CreateCommand(It.IsAny<Command>()), Times.Once);
_mockRepository.Verify(x => x.SaveChangesAsync(), Times.Once);
39.11 Running Controller Tests
# Run all controller tests
dotnet test --filter "CommandsControllerTests"
# Run specific test method
dotnet test --filter "GetCommandById_WithValidId_ReturnsOkWithDto"
# Run with verbose output
dotnet test -v d --filter "AdminCommandsControllerTests"
# Watch mode
dotnet watch test
39.12 What’s Next
You now have:
- ✓ Understanding controller test strategies
- ✓ Testing GET, POST, PUT, DELETE endpoints
- ✓ Testing model validation
- ✓ Testing authorization and roles
- ✓ Mocking dependencies properly
- ✓ Verifying HTTP status codes
- ✓ Common pitfalls and solutions
Next: Integration Testing—Using WebApplicationFactory to test the complete request pipeline without mocks.