39. Testing Controllers

Unit testing ASP.NET Core controllers: testing action results, model validation, and authorization

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.